diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml
index d509b404a1..2c6d889d7b 100644
--- a/.github/workflows/build-mobile.yml
+++ b/.github/workflows/build-mobile.yml
@@ -94,7 +94,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
working_directory: ./mobile
@@ -216,7 +216,7 @@ jobs:
persist-credentials: false
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
working_directory: ./mobile
diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml
index 7cbab78069..a38f131a71 100644
--- a/.github/workflows/check-openapi.yml
+++ b/.github/workflows/check-openapi.yml
@@ -25,7 +25,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
- uses: oasdiff/oasdiff-action/breaking@4bdf89062dad92caea71236f59704b04d3ce9682 # v0.1.8
+ uses: oasdiff/oasdiff-action/breaking@0ab8ad204b00d25acc5ae87106281433e288d0c1 # v0.1.10
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
@@ -43,7 +43,7 @@ jobs:
persist-credentials: false
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ github.token }}
working_directory: ./mobile
diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml
index aa2c6297b2..7cfa4bbc92 100644
--- a/.github/workflows/cli.yml
+++ b/.github/workflows/cli.yml
@@ -44,7 +44,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
@@ -83,7 +83,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to GitHub Container Registry
- uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 9bf5abfcba..7e14227a51 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -58,7 +58,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
+ uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -71,7 +71,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@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
+ uses: github/codeql-action/autobuild@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
# âšī¸ 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
@@ -84,6 +84,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
+ uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
category: '/language:${{matrix.language}}'
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index ab1ec84136..657d61d647 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -61,7 +61,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps:
- name: Login to GitHub Container Registry
- uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -91,7 +91,7 @@ jobs:
suffix: ['']
steps:
- name: Login to GitHub Container Registry
- uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml
index 09c340e0fe..6d7bd497d7 100644
--- a/.github/workflows/docs-build.yml
+++ b/.github/workflows/docs-build.yml
@@ -68,7 +68,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml
index a452e2ae51..b88e3f56dc 100644
--- a/.github/workflows/docs-deploy.yml
+++ b/.github/workflows/docs-deploy.yml
@@ -143,7 +143,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml
index eec66484d3..cdb3f83c96 100644
--- a/.github/workflows/docs-destroy.yml
+++ b/.github/workflows/docs-destroy.yml
@@ -31,7 +31,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml
index 4c97f3f16c..2638c31fca 100644
--- a/.github/workflows/fix-format.yml
+++ b/.github/workflows/fix-format.yml
@@ -30,7 +30,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
diff --git a/.github/workflows/pr-label-validation.yml b/.github/workflows/pr-label-validation.yml
index abbcfaebd0..5820a8b284 100644
--- a/.github/workflows/pr-label-validation.yml
+++ b/.github/workflows/pr-label-validation.yml
@@ -8,7 +8,6 @@ permissions: {}
jobs:
validate-release-label:
- if: ${{ !startsWith(github.event.pull_request.head.ref, 'mergify/merge-queue/') }}
runs-on: ubuntu-latest
permissions:
issues: write
diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml
index 5150896192..1bf9a61e8c 100644
--- a/.github/workflows/prepare-release.yml
+++ b/.github/workflows/prepare-release.yml
@@ -68,7 +68,7 @@ jobs:
ref: main
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml
index a5eaf26181..9c6101e4e1 100644
--- a/.github/workflows/sdk.yml
+++ b/.github/workflows/sdk.yml
@@ -29,7 +29,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml
index 8fe1e1265c..3004f0323f 100644
--- a/.github/workflows/static_analysis.yml
+++ b/.github/workflows/static_analysis.yml
@@ -64,7 +64,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
working_directory: ./mobile
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index a01b429713..acf3a8d7f1 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -91,7 +91,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
@@ -120,7 +120,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
@@ -152,7 +152,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
@@ -184,7 +184,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
@@ -229,7 +229,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
@@ -268,7 +268,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
@@ -297,7 +297,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
@@ -348,7 +348,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
@@ -382,7 +382,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
@@ -606,7 +606,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
working_directory: ./mobile
@@ -644,7 +644,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
@@ -676,7 +676,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
@@ -729,7 +729,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
@@ -787,7 +787,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0
+ uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
with:
github_token: ${{ steps.token.outputs.token }}
diff --git a/mobile/.gitignore b/mobile/.gitignore
index 6730e3c46a..c426d46ec3 100644
--- a/mobile/.gitignore
+++ b/mobile/.gitignore
@@ -32,6 +32,7 @@
.pub/
/build/
lib/**/*.drift.dart
+lib/**/*.freezed.dart
lib/routing/router.gr.dart
test/drift/main/generated/
diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml
index e828760789..7e314e8d81 100644
--- a/mobile/analysis_options.yaml
+++ b/mobile/analysis_options.yaml
@@ -110,3 +110,17 @@ dart_code_metrics:
- prefer-const-border-radius
- prefer-declaring-const-constructor
- prefer-switch-expression
+
+ # Riverpod
+ - avoid-calling-notifier-members-inside-build
+ - avoid-notifier-constructors
+ - avoid-nullable-async-value-pattern
+ - avoid-public-notifier-properties
+ - avoid-ref-inside-state-dispose
+ - avoid-ref-read-inside-build
+ - avoid-ref-watch-outside-build
+ - avoid-unnecessary-consumer-widgets
+ - dispose-provided-instances
+ - prefer-immutable-provider-arguments
+ - use-ref-and-state-synchronously
+ - use-ref-read-synchronously
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index 1b8d2a97fb..5f4de8284a 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -152,12 +152,6 @@
This is used by the Flutter tool to
generate GeneratedPluginRegistrant.java -->
-
-
-
cursor.count.toLong() } ?: 0L
- fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result>) -> Unit) {
+ fun getAssetsForAlbum(
+ albumId: String,
+ updatedTimeCond: Long?,
+ callback: (Result>) -> Unit
+ ) {
runSync(callback) { getAssetsForAlbum(albumId, updatedTimeCond) }
}
@@ -419,7 +424,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
}.awaitAll()
completeWhenActive(callback, Result.success(results))
- } catch (e: CancellationException) {
+ } catch (_: CancellationException) {
completeWhenActive(
callback, Result.failure(
FlutterError(
@@ -437,23 +442,20 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
private suspend fun hashAsset(assetId: String): HashResult {
return try {
- val assetUri = ContentUris.withAppendedId(
- MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
- assetId.toLong()
- )
-
val digest = MessageDigest.getInstance("SHA-1")
- ctx.contentResolver.openInputStream(assetUri)?.use { inputStream ->
- var bytesRead: Int
+ openOriginalStream(assetId).use { inputStream ->
val buffer = ByteArray(HASH_BUFFER_SIZE)
- while (inputStream.read(buffer).also { bytesRead = it } > 0) {
+ while (true) {
+ val bytesRead = inputStream.read(buffer)
+ if (bytesRead == -1) break
currentCoroutineContext().ensureActive()
digest.update(buffer, 0, bytesRead)
}
- } ?: return HashResult(assetId, "Cannot open input stream for asset", null)
+ }
- val hashString = Base64.encodeToString(digest.digest(), Base64.NO_WRAP)
- HashResult(assetId, null, hashString)
+ HashResult(assetId, null, Base64.encodeToString(digest.digest(), Base64.NO_WRAP))
+ } catch (e: CancellationException) {
+ throw e
} catch (e: SecurityException) {
HashResult(assetId, "Permission denied accessing asset: ${e.message}", null)
} catch (e: Exception) {
@@ -461,6 +463,37 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
}
}
+ private fun openOriginalStream(assetId: String): InputStream {
+ val id = assetId.toLong()
+ val collection = when (getMediaType(id)) {
+ MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
+ else -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
+ }
+ val uri = ContentUris.withAppendedId(collection, id)
+ val original = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ MediaStore.setRequireOriginal(uri)
+ } else {
+ uri
+ }
+
+ return ctx.contentResolver.openInputStream(original)
+ ?: throw IOException("Cannot open original stream for asset $assetId")
+ }
+
+ private fun getMediaType(id: Long): Int =
+ getCursor(
+ MediaStore.VOLUME_EXTERNAL,
+ "${MediaStore.MediaColumns._ID} = ?",
+ arrayOf(id.toString()),
+ arrayOf(MediaStore.Files.FileColumns.MEDIA_TYPE)
+ )?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE))
+ } else {
+ MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE
+ }
+ } ?: MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE
+
fun cancelHashing() {
hashTask?.cancel()
hashTask = null
@@ -476,8 +509,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
syncJob = CoroutineScope(Dispatchers.IO).launch {
try {
completeWhenActive(callback, Result.success(work()))
- } catch (e: CancellationException) {
- completeWhenActive(callback, Result.failure(FlutterError(SYNC_CANCELLED_CODE, "Sync cancelled", null)))
+ } catch (_: CancellationException) {
+ completeWhenActive(
+ callback,
+ Result.failure(FlutterError(SYNC_CANCELLED_CODE, "Sync cancelled", null))
+ )
} catch (e: Exception) {
completeWhenActive(callback, Result.failure(e))
}
diff --git a/mobile/ios/Runner/Images/LocalImagesImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift
index 9c142da054..0d023ad466 100644
--- a/mobile/ios/Runner/Images/LocalImagesImpl.swift
+++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift
@@ -20,6 +20,7 @@ class LocalImageApiImpl: LocalImageApi {
requestOptions.version = .current
return requestOptions
}()
+ private static let maxPixelSize: CGFloat = 16384
private static let registry = RequestRegistry()
@@ -108,11 +109,16 @@ class LocalImageApiImpl: LocalImageApi {
]))
}
+ let isOriginal = !(width > 0 && height > 0)
+ let targetSize = isOriginal
+ ? CGSize(width: Self.maxPixelSize, height: Self.maxPixelSize)
+ : CGSize(width: Double(width), height: Double(height))
+ let contentMode: PHImageContentMode = isOriginal ? .aspectFit : .aspectFill
var image: UIImage?
Self.imageManager.requestImage(
for: asset,
- targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
- contentMode: .aspectFill,
+ targetSize: targetSize,
+ contentMode: contentMode,
options: Self.requestOptions,
resultHandler: { (_image, info) -> Void in
image = _image
@@ -123,12 +129,38 @@ class LocalImageApiImpl: LocalImageApi {
return request.completion(ImageProcessing.cancelledResult)
}
- guard let image = image,
- let cgImage = image.cgImage else {
+ guard let fastImage = image,
+ var cgImage = fastImage.cgImage else {
Self.registry.remove(requestId: requestId)
return request.completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
}
+ // .fast can return larger than the target, so retry with .exact to guarantee the bound.
+ if max(cgImage.width, cgImage.height) > Int(Self.maxPixelSize) {
+ let exactOptions = Self.requestOptions.copy() as! PHImageRequestOptions
+ exactOptions.resizeMode = .exact
+ image = nil
+ Self.imageManager.requestImage(
+ for: asset,
+ targetSize: targetSize,
+ contentMode: contentMode,
+ options: exactOptions,
+ resultHandler: { (_image, info) -> Void in
+ image = _image
+ }
+ )
+
+ if request.isCancelled {
+ return request.completion(ImageProcessing.cancelledResult)
+ }
+
+ guard let exactImage = image?.cgImage else {
+ Self.registry.remove(requestId: requestId)
+ return request.completion(.failure(PigeonError(code: "", message: "Could not resize image for \(assetId)", details: nil)))
+ }
+ cgImage = exactImage
+ }
+
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
diff --git a/mobile/ios/WidgetExtension/ImageEntry.swift b/mobile/ios/WidgetExtension/ImageEntry.swift
index ee371703a8..58f89339ac 100644
--- a/mobile/ios/WidgetExtension/ImageEntry.swift
+++ b/mobile/ios/WidgetExtension/ImageEntry.swift
@@ -12,13 +12,15 @@ struct ImageEntry: TimelineEntry {
var subtitle: String? = nil
var error: WidgetError? = nil
var deepLink: URL? = nil
+ var forceFullColor: Bool = true
}
static func build(
api: ImmichAPI,
asset: Asset,
dateOffset: Int,
- subtitle: String? = nil
+ subtitle: String? = nil,
+ forceFullColor: Bool = true
)
async throws -> Self
{
@@ -34,7 +36,8 @@ struct ImageEntry: TimelineEntry {
image: image,
metadata: EntryMetadata(
subtitle: subtitle,
- deepLink: asset.deepLink
+ deepLink: asset.deepLink,
+ forceFullColor: forceFullColor
)
)
}
@@ -111,12 +114,26 @@ struct ImageEntry: TimelineEntry {
}
+// Required so that we can decode older versions of the metadata
+extension ImageEntry.Metadata {
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle)
+ error = try container.decodeIfPresent(WidgetError.self, forKey: .error)
+ deepLink = try container.decodeIfPresent(URL.self, forKey: .deepLink)
+
+ forceFullColor =
+ try container.decodeIfPresent(Bool.self, forKey: .forceFullColor) ?? true
+ }
+}
+
func generateRandomEntries(
api: ImmichAPI,
now: Date,
count: Int,
filter: SearchFilter = Album.NONE.filter,
- subtitle: String? = nil
+ subtitle: String? = nil,
+ forceFullColor: Bool = true
)
async throws -> [ImageEntry]
{
@@ -132,7 +149,8 @@ func generateRandomEntries(
api: api,
asset: asset,
dateOffset: dateOffset,
- subtitle: subtitle
+ subtitle: subtitle,
+ forceFullColor: forceFullColor
)
}
}
diff --git a/mobile/ios/WidgetExtension/ImageWidgetView.swift b/mobile/ios/WidgetExtension/ImageWidgetView.swift
index 17088d43b8..2e3a816d92 100644
--- a/mobile/ios/WidgetExtension/ImageWidgetView.swift
+++ b/mobile/ios/WidgetExtension/ImageWidgetView.swift
@@ -3,10 +3,10 @@ import WidgetKit
extension Image {
@ViewBuilder
- func tintedWidgetImageModifier() -> some View {
+ func tintedWidgetImageModifier(forceFullColor: Bool) -> some View {
if #available(iOS 18.0, *) {
self
- .widgetAccentedRenderingMode(.accentedDesaturated)
+ .widgetAccentedRenderingMode(forceFullColor ? .accentedDesaturated : .fullColor)
} else {
self
}
@@ -18,15 +18,24 @@ struct ImmichWidgetView: View {
var body: some View {
if let image = entry.image {
- ImmichWidgetContentView(image: image, subtitle: entry.metadata.subtitle, deepLink: entry.metadata.deepLink)
+ ImmichWidgetContentView(
+ image: image,
+ subtitle: entry.metadata.subtitle,
+ deepLink: entry.metadata.deepLink,
+ forceFullColor: entry.metadata.forceFullColor
+ )
} else {
- ImmichWidgetLoadingView(message: entry.metadata.error?.errorDescription)
+ ImmichWidgetLoadingView(
+ message: entry.metadata.error?.errorDescription,
+ forceFullColor: entry.metadata.forceFullColor
+ )
}
}
}
private struct ImmichWidgetLoadingView: View {
let message: String?
+ let forceFullColor: Bool
var body: some View {
let messageText = Text(message ?? "")
@@ -39,7 +48,7 @@ private struct ImmichWidgetLoadingView: View {
messageText.hidden()
Image("LaunchImage")
- .tintedWidgetImageModifier()
+ .tintedWidgetImageModifier(forceFullColor: forceFullColor)
messageText
}
@@ -50,13 +59,14 @@ private struct ImmichWidgetContentView: View {
let image: UIImage
let subtitle: String?
let deepLink: URL?
+ let forceFullColor: Bool
var body: some View {
ZStack(alignment: .leading) {
Color.clear.overlay(
Image(uiImage: image)
.resizable()
- .tintedWidgetImageModifier()
+ .tintedWidgetImageModifier(forceFullColor: forceFullColor)
.scaledToFill()
)
diff --git a/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift
index 15fa3d722e..dee5a32159 100644
--- a/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift
+++ b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift
@@ -2,7 +2,21 @@ import AppIntents
import SwiftUI
import WidgetKit
-struct ImmichMemoryProvider: TimelineProvider {
+// MARK: Widget Configuration
+
+struct MemoryConfigurationAppIntent: WidgetConfigurationIntent {
+ static var title: LocalizedStringResource { "Memories" }
+ static var description: IntentDescription {
+ "See memories from Immich."
+ }
+
+ @Parameter(title: "Always Display in Full Color", default: false)
+ var forceFullColor: Bool
+}
+
+// MARK: Provider
+
+struct ImmichMemoryProvider: AppIntentTimelineProvider {
func getYearDifferenceSubtitle(assetYear: Int) -> String {
let currentYear = Calendar.current.component(.year, from: Date.now)
// construct a "X years ago" subtitle
@@ -15,144 +29,132 @@ struct ImmichMemoryProvider: TimelineProvider {
ImageEntry(date: Date(), image: nil)
}
- func getSnapshot(
- in context: Context,
- completion: @escaping @Sendable (ImageEntry) -> Void
- ) {
+ func snapshot(
+ for configuration: MemoryConfigurationAppIntent,
+ in context: Context
+ ) async -> ImageEntry {
let cacheKey = "memory_\(context.family.rawValue)"
- Task {
- guard let api = try? await ImmichAPI() else {
- completion(
- ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first!
- )
- return
- }
-
- guard let memories = try? await api.fetchMemory(for: Date.now)
- else {
- completion(ImageEntry.handleError(for: cacheKey).entries.first!)
- return
- }
-
- for memory in memories {
- if let asset = memory.assets.first(where: { $0.type == .image }),
- let entry = try? await ImageEntry.build(
- api: api,
- asset: asset,
- dateOffset: 0,
- subtitle: getYearDifferenceSubtitle(assetYear: memory.data.year)
- )
- {
- completion(entry)
- return
- }
- }
-
- // fallback to random image
- guard
- let randomImage = try? await api.fetchSearchResults().first,
- let imageEntry = try? await ImageEntry.build(
- api: api,
- asset: randomImage,
- dateOffset: 0
- )
- else {
- completion(ImageEntry.handleError(for: cacheKey).entries.first!)
- return
- }
-
- completion(imageEntry)
+ guard let api = try? await ImmichAPI() else {
+ return ImageEntry.handleError(for: cacheKey, error: .noLogin).entries
+ .first!
}
+
+ guard let memories = try? await api.fetchMemory(for: Date.now) else {
+ return ImageEntry.handleError(for: cacheKey).entries.first!
+ }
+
+ for memory in memories {
+ if let asset = memory.assets.first(where: { $0.type == .image }),
+ let entry = try? await ImageEntry.build(
+ api: api,
+ asset: asset,
+ dateOffset: 0,
+ subtitle: getYearDifferenceSubtitle(assetYear: memory.data.year),
+ forceFullColor: configuration.forceFullColor
+ )
+ {
+ return entry
+ }
+ }
+
+ // fallback to random image
+ guard
+ let randomImage = try? await api.fetchSearchResults().first,
+ let imageEntry = try? await ImageEntry.build(
+ api: api,
+ asset: randomImage,
+ dateOffset: 0,
+ forceFullColor: configuration.forceFullColor
+ )
+ else {
+ return ImageEntry.handleError(for: cacheKey).entries.first!
+ }
+
+ return imageEntry
}
- func getTimeline(
- in context: Context,
- completion: @escaping @Sendable (Timeline) -> Void
- ) {
- Task {
- var entries: [ImageEntry] = []
- let now = Date()
+ func timeline(
+ for configuration: MemoryConfigurationAppIntent,
+ in context: Context
+ ) async
+ -> Timeline
+ {
+ var entries: [ImageEntry] = []
+ let now = Date()
- let cacheKey = "memory_\(context.family.rawValue)"
+ let cacheKey = "memory_\(context.family.rawValue)"
- guard let api = try? await ImmichAPI() else {
- completion(
- ImageEntry.handleError(for: cacheKey, error: .noLogin)
- )
- return
- }
-
- let memories: [MemoryResult]
- do {
- memories = try await api.fetchMemory(for: Date.now)
-
- await withTaskGroup(of: ImageEntry?.self) { group in
- var totalAssets = 0
-
- for memory in memories {
- for asset in memory.assets {
- if asset.type == .image && totalAssets < 12 {
- group.addTask {
- try? await ImageEntry.build(
- api: api,
- asset: asset,
- dateOffset: totalAssets,
- subtitle: getYearDifferenceSubtitle(
- assetYear: memory.data.year
- )
- )
- }
-
- totalAssets += 1
- }
- }
- }
-
- for await result in group {
- if let entry = result {
- entries.append(entry)
- }
- }
- }
-
- } catch {
- completion(ImageEntry.handleError(for: cacheKey))
- return
- }
-
- // If we didn't add any memory images (some failure occurred or no images in memory),
- // default to 12 hours of random photos
- if entries.count == 0 {
- // this must be a do/catch since we need to
- // distinguish between a network fail and an empty search
- do {
- let search = try await generateRandomEntries(
- api: api,
- now: now,
- count: 12
- )
-
- // Load or save a cached asset for when network conditions are bad
- if search.count == 0 {
- completion(
- ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
- )
- return
- }
-
- entries.append(contentsOf: search)
- } catch {
- completion(ImageEntry.handleError(for: cacheKey))
- return
- }
- }
-
- // cache the last image
- try? entries.last!.cache(for: cacheKey)
-
- completion(Timeline(entries: entries, policy: .atEnd))
+ guard let api = try? await ImmichAPI() else {
+ return ImageEntry.handleError(for: cacheKey, error: .noLogin)
}
+
+ let memories: [MemoryResult]
+ do {
+ memories = try await api.fetchMemory(for: Date.now)
+
+ await withTaskGroup(of: ImageEntry?.self) { group in
+ var totalAssets = 0
+
+ for memory in memories {
+ for asset in memory.assets {
+ if asset.type == .image && totalAssets < 12 {
+ group.addTask {
+ try? await ImageEntry.build(
+ api: api,
+ asset: asset,
+ dateOffset: totalAssets,
+ subtitle: getYearDifferenceSubtitle(
+ assetYear: memory.data.year
+ ),
+ forceFullColor: configuration.forceFullColor
+ )
+ }
+
+ totalAssets += 1
+ }
+ }
+ }
+
+ for await result in group {
+ if let entry = result {
+ entries.append(entry)
+ }
+ }
+ }
+
+ } catch {
+ return ImageEntry.handleError(for: cacheKey)
+ }
+
+ // If we didn't add any memory images (some failure occurred or no images in memory),
+ // default to 12 hours of random photos
+ if entries.count == 0 {
+ // this must be a do/catch since we need to
+ // distinguish between a network fail and an empty search
+ do {
+ let search = try await generateRandomEntries(
+ api: api,
+ now: now,
+ count: 12,
+ forceFullColor: configuration.forceFullColor
+ )
+
+ // Load or save a cached asset for when network conditions are bad
+ if search.count == 0 {
+ return ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
+ }
+
+ entries.append(contentsOf: search)
+ } catch {
+ return ImageEntry.handleError(for: cacheKey)
+ }
+ }
+
+ // cache the last image
+ try? entries.last!.cache(for: cacheKey)
+
+ return Timeline(entries: entries, policy: .atEnd)
}
}
@@ -160,8 +162,9 @@ struct ImmichMemoryWidget: Widget {
let kind: String = "com.immich.widget.memory"
var body: some WidgetConfiguration {
- StaticConfiguration(
+ AppIntentConfiguration(
kind: kind,
+ intent: MemoryConfigurationAppIntent.self,
provider: ImmichMemoryProvider()
) { entry in
ImmichWidgetView(entry: entry)
diff --git a/mobile/ios/WidgetExtension/widgets/RandomWidget.swift b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift
index 37f3c5e596..d987464152 100644
--- a/mobile/ios/WidgetExtension/widgets/RandomWidget.swift
+++ b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift
@@ -47,6 +47,9 @@ struct RandomConfigurationAppIntent: WidgetConfigurationIntent {
@Parameter(title: "Show Album Name", default: false)
var showAlbumName: Bool
+
+ @Parameter(title: "Always Display in Full Color", default: false)
+ var forceFullColor: Bool
}
// MARK: Provider
@@ -76,7 +79,8 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
let entry = try? await ImageEntry.build(
api: api,
asset: randomImage,
- dateOffset: 0
+ dateOffset: 0,
+ forceFullColor: configuration.forceFullColor
)
else {
return ImageEntry.handleError(for: cacheKey).entries.first!
@@ -114,7 +118,8 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
now: now,
count: 12,
filter: album.filter,
- subtitle: configuration.showAlbumName ? albumName : nil
+ subtitle: configuration.showAlbumName ? albumName : nil,
+ forceFullColor: configuration.forceFullColor
)
// Load or save a cached asset for when network conditions are bad
diff --git a/mobile/lib/domain/models/album/album.model.dart b/mobile/lib/domain/models/album/album.model.dart
index 63f4ed6be3..e63a3774e1 100644
--- a/mobile/lib/domain/models/album/album.model.dart
+++ b/mobile/lib/domain/models/album/album.model.dart
@@ -1,3 +1,8 @@
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'album.model.freezed.dart';
+
enum AlbumAssetOrder {
// do not change this order!
asc,
@@ -12,118 +17,20 @@ enum AlbumUserRole {
}
// Model for an album stored in the server
-class RemoteAlbum {
- final String id;
- final String name;
- final String ownerId;
- final String description;
- final DateTime createdAt;
- final DateTime updatedAt;
- final String? thumbnailAssetId;
- final bool isActivityEnabled;
- final AlbumAssetOrder order;
- final int assetCount;
- final String ownerName;
- final bool isShared;
-
- const RemoteAlbum({
- required this.id,
- required this.name,
- required this.ownerId,
- required this.description,
- required this.createdAt,
- required this.updatedAt,
- this.thumbnailAssetId,
- required this.isActivityEnabled,
- required this.order,
- required this.assetCount,
- required this.ownerName,
- required this.isShared,
- });
-
- @override
- String toString() {
- return '''Album {
- id: $id,
- name: $name,
- ownerId: $ownerId,
- description: $description,
- createdAt: $createdAt,
- updatedAt: $updatedAt,
- isActivityEnabled: $isActivityEnabled,
- order: $order,
- thumbnailAssetId: ${thumbnailAssetId ?? ""}
- assetCount: $assetCount
- ownerName: $ownerName
- isShared: $isShared
- }''';
- }
-
- @override
- bool operator ==(Object other) {
- if (other is! RemoteAlbum) {
- return false;
- }
- if (identical(this, other)) {
- return true;
- }
- return id == other.id &&
- name == other.name &&
- ownerId == other.ownerId &&
- description == other.description &&
- createdAt == other.createdAt &&
- updatedAt == other.updatedAt &&
- thumbnailAssetId == other.thumbnailAssetId &&
- isActivityEnabled == other.isActivityEnabled &&
- order == other.order &&
- assetCount == other.assetCount &&
- ownerName == other.ownerName &&
- isShared == other.isShared;
- }
-
- @override
- int get hashCode {
- return id.hashCode ^
- name.hashCode ^
- ownerId.hashCode ^
- description.hashCode ^
- createdAt.hashCode ^
- updatedAt.hashCode ^
- thumbnailAssetId.hashCode ^
- isActivityEnabled.hashCode ^
- order.hashCode ^
- assetCount.hashCode ^
- ownerName.hashCode ^
- isShared.hashCode;
- }
-
- RemoteAlbum copyWith({
- String? id,
- String? name,
- String? ownerId,
- String? description,
- DateTime? createdAt,
- DateTime? updatedAt,
+@freezed
+abstract class RemoteAlbum with _$RemoteAlbum {
+ const factory RemoteAlbum({
+ required String id,
+ required String name,
+ required String ownerId,
+ required String description,
+ required DateTime createdAt,
+ required DateTime updatedAt,
String? thumbnailAssetId,
- bool? isActivityEnabled,
- AlbumAssetOrder? order,
- int? assetCount,
- String? ownerName,
- bool? isShared,
- }) {
- return RemoteAlbum(
- id: id ?? this.id,
- name: name ?? this.name,
- ownerId: ownerId ?? this.ownerId,
- description: description ?? this.description,
- createdAt: createdAt ?? this.createdAt,
- updatedAt: updatedAt ?? this.updatedAt,
- thumbnailAssetId: thumbnailAssetId ?? this.thumbnailAssetId,
- isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled,
- order: order ?? this.order,
- assetCount: assetCount ?? this.assetCount,
- ownerName: ownerName ?? this.ownerName,
- isShared: isShared ?? this.isShared,
- );
- }
+ required bool isActivityEnabled,
+ required AlbumAssetOrder order,
+ required int assetCount,
+ required String ownerName,
+ required bool isShared,
+ }) = _RemoteAlbum;
}
diff --git a/mobile/lib/domain/models/album/local_album.model.dart b/mobile/lib/domain/models/album/local_album.model.dart
index 9e8521fa02..cddc1d1953 100644
--- a/mobile/lib/domain/models/album/local_album.model.dart
+++ b/mobile/lib/domain/models/album/local_album.model.dart
@@ -1,3 +1,8 @@
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'local_album.model.freezed.dart';
+
enum BackupSelection {
// Used to sort albums based on the backupSelection
// selected -> none -> excluded
@@ -7,85 +12,15 @@ enum BackupSelection {
excluded,
}
-class LocalAlbum {
- final String id;
- final String name;
- final DateTime updatedAt;
- final bool isIosSharedAlbum;
-
- final int assetCount;
- final BackupSelection backupSelection;
- final String? linkedRemoteAlbumId;
-
- const LocalAlbum({
- required this.id,
- required this.name,
- required this.updatedAt,
- this.assetCount = 0,
- this.backupSelection = BackupSelection.none,
- this.isIosSharedAlbum = false,
- this.linkedRemoteAlbumId,
- });
-
- LocalAlbum copyWith({
- String? id,
- String? name,
- DateTime? updatedAt,
- int? assetCount,
- BackupSelection? backupSelection,
- bool? isIosSharedAlbum,
+@freezed
+abstract class LocalAlbum with _$LocalAlbum {
+ const factory LocalAlbum({
+ required String id,
+ required String name,
+ required DateTime updatedAt,
+ @Default(0) int assetCount,
+ @Default(BackupSelection.none) BackupSelection backupSelection,
+ @Default(false) bool isIosSharedAlbum,
String? linkedRemoteAlbumId,
- }) {
- return LocalAlbum(
- id: id ?? this.id,
- name: name ?? this.name,
- updatedAt: updatedAt ?? this.updatedAt,
- assetCount: assetCount ?? this.assetCount,
- backupSelection: backupSelection ?? this.backupSelection,
- isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum,
- linkedRemoteAlbumId: linkedRemoteAlbumId ?? this.linkedRemoteAlbumId,
- );
- }
-
- @override
- bool operator ==(Object other) {
- if (other is! LocalAlbum) {
- return false;
- }
- if (identical(this, other)) {
- return true;
- }
-
- return other.id == id &&
- other.name == name &&
- other.updatedAt == updatedAt &&
- other.assetCount == assetCount &&
- other.backupSelection == backupSelection &&
- other.isIosSharedAlbum == isIosSharedAlbum &&
- other.linkedRemoteAlbumId == linkedRemoteAlbumId;
- }
-
- @override
- int get hashCode {
- return id.hashCode ^
- name.hashCode ^
- updatedAt.hashCode ^
- assetCount.hashCode ^
- backupSelection.hashCode ^
- isIosSharedAlbum.hashCode ^
- linkedRemoteAlbumId.hashCode;
- }
-
- @override
- String toString() {
- return '''LocalAlbum: {
-id: $id,
-name: $name,
-updatedAt: $updatedAt,
-assetCount: $assetCount,
-backupSelection: $backupSelection,
-isIosSharedAlbum: $isIosSharedAlbum
-linkedRemoteAlbumId: $linkedRemoteAlbumId,
-}''';
- }
+ }) = _LocalAlbum;
}
diff --git a/mobile/lib/domain/models/asset_face.model.dart b/mobile/lib/domain/models/asset_face.model.dart
index 1388836946..c65f159fe7 100644
--- a/mobile/lib/domain/models/asset_face.model.dart
+++ b/mobile/lib/domain/models/asset_face.model.dart
@@ -1,100 +1,21 @@
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'asset_face.model.freezed.dart';
+
// Model for an asset face stored in the server
-class AssetFace {
- final String id;
- final String assetId;
- final String? personId;
- final int imageWidth;
- final int imageHeight;
- final int boundingBoxX1;
- final int boundingBoxY1;
- final int boundingBoxX2;
- final int boundingBoxY2;
- final String sourceType;
-
- const AssetFace({
- required this.id,
- required this.assetId,
- this.personId,
- required this.imageWidth,
- required this.imageHeight,
- required this.boundingBoxX1,
- required this.boundingBoxY1,
- required this.boundingBoxX2,
- required this.boundingBoxY2,
- required this.sourceType,
- });
-
- AssetFace copyWith({
- String? id,
- String? assetId,
+@freezed
+abstract class AssetFace with _$AssetFace {
+ const factory AssetFace({
+ required String id,
+ required String assetId,
String? personId,
- int? imageWidth,
- int? imageHeight,
- int? boundingBoxX1,
- int? boundingBoxY1,
- int? boundingBoxX2,
- int? boundingBoxY2,
- String? sourceType,
- }) {
- return AssetFace(
- id: id ?? this.id,
- assetId: assetId ?? this.assetId,
- personId: personId ?? this.personId,
- imageWidth: imageWidth ?? this.imageWidth,
- imageHeight: imageHeight ?? this.imageHeight,
- boundingBoxX1: boundingBoxX1 ?? this.boundingBoxX1,
- boundingBoxY1: boundingBoxY1 ?? this.boundingBoxY1,
- boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2,
- boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2,
- sourceType: sourceType ?? this.sourceType,
- );
- }
-
- @override
- String toString() {
- return '''AssetFace {
- id: $id,
- assetId: $assetId,
- personId: ${personId ?? ""},
- imageWidth: $imageWidth,
- imageHeight: $imageHeight,
- boundingBoxX1: $boundingBoxX1,
- boundingBoxY1: $boundingBoxY1,
- boundingBoxX2: $boundingBoxX2,
- boundingBoxY2: $boundingBoxY2,
- sourceType: $sourceType,
-}''';
- }
-
- @override
- bool operator ==(covariant AssetFace other) {
- if (identical(this, other)) {
- return true;
- }
-
- return other.id == id &&
- other.assetId == assetId &&
- other.personId == personId &&
- other.imageWidth == imageWidth &&
- other.imageHeight == imageHeight &&
- other.boundingBoxX1 == boundingBoxX1 &&
- other.boundingBoxY1 == boundingBoxY1 &&
- other.boundingBoxX2 == boundingBoxX2 &&
- other.boundingBoxY2 == boundingBoxY2 &&
- other.sourceType == sourceType;
- }
-
- @override
- int get hashCode {
- return id.hashCode ^
- assetId.hashCode ^
- personId.hashCode ^
- imageWidth.hashCode ^
- imageHeight.hashCode ^
- boundingBoxX1.hashCode ^
- boundingBoxY1.hashCode ^
- boundingBoxX2.hashCode ^
- boundingBoxY2.hashCode ^
- sourceType.hashCode;
- }
+ required int imageWidth,
+ required int imageHeight,
+ required int boundingBoxX1,
+ required int boundingBoxY1,
+ required int boundingBoxX2,
+ required int boundingBoxY2,
+ required String sourceType,
+ }) = _AssetFace;
}
diff --git a/mobile/lib/domain/models/config/album_config.dart b/mobile/lib/domain/models/config/album_config.dart
index a83fc32fbf..16deca1b8f 100644
--- a/mobile/lib/domain/models/config/album_config.dart
+++ b/mobile/lib/domain/models/config/album_config.dart
@@ -1,26 +1,14 @@
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
-class AlbumConfig {
- final AlbumSortMode sortMode;
- final bool isReverse;
- final bool isGrid;
+part 'album_config.freezed.dart';
- const AlbumConfig({this.sortMode = AlbumSortMode.mostRecent, this.isReverse = true, this.isGrid = false});
-
- AlbumConfig copyWith({AlbumSortMode? sortMode, bool? isReverse, bool? isGrid}) => AlbumConfig(
- sortMode: sortMode ?? this.sortMode,
- isReverse: isReverse ?? this.isReverse,
- isGrid: isGrid ?? this.isGrid,
- );
-
- @override
- bool operator ==(Object other) =>
- identical(this, other) ||
- (other is AlbumConfig && other.sortMode == sortMode && other.isReverse == isReverse && other.isGrid == isGrid);
-
- @override
- int get hashCode => Object.hash(sortMode, isReverse, isGrid);
-
- @override
- String toString() => 'AlbumConfig(sortMode: $sortMode, isReverse: $isReverse, isGrid: $isGrid)';
+@freezed
+abstract class AlbumConfig with _$AlbumConfig {
+ const factory AlbumConfig({
+ @Default(AlbumSortMode.mostRecent) AlbumSortMode sortMode,
+ @Default(true) bool isReverse,
+ @Default(false) bool isGrid,
+ }) = _AlbumConfig;
}
diff --git a/mobile/lib/domain/models/config/app_config.dart b/mobile/lib/domain/models/config/app_config.dart
index df147f3e3a..bc089e621e 100644
--- a/mobile/lib/domain/models/config/app_config.dart
+++ b/mobile/lib/domain/models/config/app_config.dart
@@ -1,4 +1,6 @@
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/config/album_config.dart';
@@ -19,107 +21,29 @@ import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/utils/semver.dart';
+part 'app_config.freezed.dart';
+
const defaultConfig = AppConfig();
-class AppConfig {
- final LogLevel logLevel;
- final ThemeConfig theme;
- final CleanupConfig cleanup;
- final MapConfig map;
- final TimelineConfig timeline;
- final ImageConfig image;
- final ViewerConfig viewer;
- final SlideshowConfig slideshow;
- final AlbumConfig album;
- final BackupConfig backup;
- final NetworkConfig network;
- final ShareConfig share;
- final FeatureMessageConfig featureMessage;
+@freezed
+abstract class AppConfig with _$AppConfig {
+ const AppConfig._();
- const AppConfig({
- this.logLevel = .info,
- this.theme = const .new(),
- this.cleanup = const .new(),
- this.map = const .new(),
- this.timeline = const .new(),
- this.image = const .new(),
- this.viewer = const .new(),
- this.slideshow = const .new(),
- this.album = const .new(),
- this.backup = const .new(),
- this.network = const .new(),
- this.share = const .new(),
- this.featureMessage = const .new(),
- });
-
- AppConfig copyWith({
- LogLevel? logLevel,
- ThemeConfig? theme,
- CleanupConfig? cleanup,
- MapConfig? map,
- TimelineConfig? timeline,
- ImageConfig? image,
- ViewerConfig? viewer,
- SlideshowConfig? slideshow,
- AlbumConfig? album,
- BackupConfig? backup,
- NetworkConfig? network,
- ShareConfig? share,
- FeatureMessageConfig? featureMessage,
- }) => .new(
- logLevel: logLevel ?? this.logLevel,
- theme: theme ?? this.theme,
- cleanup: cleanup ?? this.cleanup,
- map: map ?? this.map,
- timeline: timeline ?? this.timeline,
- image: image ?? this.image,
- viewer: viewer ?? this.viewer,
- slideshow: slideshow ?? this.slideshow,
- album: album ?? this.album,
- backup: backup ?? this.backup,
- network: network ?? this.network,
- share: share ?? this.share,
- featureMessage: featureMessage ?? this.featureMessage,
- );
-
- @override
- bool operator ==(Object other) =>
- identical(this, other) ||
- (other is AppConfig &&
- other.logLevel == logLevel &&
- other.theme == theme &&
- other.cleanup == cleanup &&
- other.map == map &&
- other.timeline == timeline &&
- other.image == image &&
- other.viewer == viewer &&
- other.slideshow == slideshow &&
- other.album == album &&
- other.backup == backup &&
- other.network == network &&
- other.share == share &&
- other.featureMessage == featureMessage);
-
- @override
- int get hashCode => Object.hash(
- logLevel,
- theme,
- cleanup,
- map,
- timeline,
- image,
- viewer,
- slideshow,
- album,
- backup,
- network,
- share,
- featureMessage,
- );
-
- @override
- String toString() =>
- 'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share, featureMessage: $featureMessage)';
+ const factory AppConfig({
+ @Default(LogLevel.info) LogLevel logLevel,
+ @Default(ThemeConfig()) ThemeConfig theme,
+ @Default(CleanupConfig()) CleanupConfig cleanup,
+ @Default(MapConfig()) MapConfig map,
+ @Default(TimelineConfig()) TimelineConfig timeline,
+ @Default(ImageConfig()) ImageConfig image,
+ @Default(ViewerConfig()) ViewerConfig viewer,
+ @Default(SlideshowConfig()) SlideshowConfig slideshow,
+ @Default(AlbumConfig()) AlbumConfig album,
+ @Default(BackupConfig()) BackupConfig backup,
+ @Default(NetworkConfig()) NetworkConfig network,
+ @Default(ShareConfig()) ShareConfig share,
+ @Default(FeatureMessageConfig()) FeatureMessageConfig featureMessage,
+ }) = _AppConfig;
T read(SettingsKey key) =>
(switch (key) {
diff --git a/mobile/lib/domain/models/config/backup_config.dart b/mobile/lib/domain/models/config/backup_config.dart
index 19f91a4ed7..4177408fcc 100644
--- a/mobile/lib/domain/models/config/backup_config.dart
+++ b/mobile/lib/domain/models/config/backup_config.dart
@@ -1,52 +1,16 @@
-class BackupConfig {
- final bool enabled;
- final bool useCellularForVideos;
- final bool useCellularForPhotos;
- final bool requireCharging;
- final int triggerDelay;
- final bool syncAlbums;
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
- const BackupConfig({
- this.enabled = false,
- this.useCellularForVideos = false,
- this.useCellularForPhotos = false,
- this.requireCharging = false,
- this.triggerDelay = 30,
- this.syncAlbums = false,
- });
+part 'backup_config.freezed.dart';
- BackupConfig copyWith({
- bool? enabled,
- bool? useCellularForVideos,
- bool? useCellularForPhotos,
- bool? requireCharging,
- int? triggerDelay,
- bool? syncAlbums,
- }) => BackupConfig(
- enabled: enabled ?? this.enabled,
- useCellularForVideos: useCellularForVideos ?? this.useCellularForVideos,
- useCellularForPhotos: useCellularForPhotos ?? this.useCellularForPhotos,
- requireCharging: requireCharging ?? this.requireCharging,
- triggerDelay: triggerDelay ?? this.triggerDelay,
- syncAlbums: syncAlbums ?? this.syncAlbums,
- );
-
- @override
- bool operator ==(Object other) =>
- identical(this, other) ||
- (other is BackupConfig &&
- other.enabled == enabled &&
- other.useCellularForVideos == useCellularForVideos &&
- other.useCellularForPhotos == useCellularForPhotos &&
- other.requireCharging == requireCharging &&
- other.triggerDelay == triggerDelay &&
- other.syncAlbums == syncAlbums);
-
- @override
- int get hashCode =>
- Object.hash(enabled, useCellularForVideos, useCellularForPhotos, requireCharging, triggerDelay, syncAlbums);
-
- @override
- String toString() =>
- 'BackupConfig(enabled: $enabled, useCellularForVideos: $useCellularForVideos, useCellularForPhotos: $useCellularForPhotos, requireCharging: $requireCharging, triggerDelay: $triggerDelay, syncAlbums: $syncAlbums)';
+@freezed
+abstract class BackupConfig with _$BackupConfig {
+ const factory BackupConfig({
+ @Default(false) bool enabled,
+ @Default(false) bool useCellularForVideos,
+ @Default(false) bool useCellularForPhotos,
+ @Default(false) bool requireCharging,
+ @Default(30) int triggerDelay,
+ @Default(false) bool syncAlbums,
+ }) = _BackupConfig;
}
diff --git a/mobile/lib/domain/models/config/feature_message_config.dart b/mobile/lib/domain/models/config/feature_message_config.dart
index d5c7c69550..0fc13fd51f 100644
--- a/mobile/lib/domain/models/config/feature_message_config.dart
+++ b/mobile/lib/domain/models/config/feature_message_config.dart
@@ -1,20 +1,11 @@
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/utils/semver.dart';
-class FeatureMessageConfig {
- final SemVer seenRelease;
+part 'feature_message_config.freezed.dart';
- const FeatureMessageConfig({this.seenRelease = const SemVer(major: 0, minor: 0, patch: 0)});
-
- FeatureMessageConfig copyWith({SemVer? seenRelease}) =>
- FeatureMessageConfig(seenRelease: seenRelease ?? this.seenRelease);
-
- @override
- bool operator ==(Object other) =>
- identical(this, other) || (other is FeatureMessageConfig && other.seenRelease == seenRelease);
-
- @override
- int get hashCode => seenRelease.hashCode;
-
- @override
- String toString() => 'FeatureMessageConfig(seenRelease: $seenRelease)';
+@freezed
+abstract class FeatureMessageConfig with _$FeatureMessageConfig {
+ const factory FeatureMessageConfig({@Default(SemVer(major: 0, minor: 0, patch: 0)) SemVer seenRelease}) =
+ _FeatureMessageConfig;
}
diff --git a/mobile/lib/domain/models/config/image_config.dart b/mobile/lib/domain/models/config/image_config.dart
index 8410a9010b..a51193d1d3 100644
--- a/mobile/lib/domain/models/config/image_config.dart
+++ b/mobile/lib/domain/models/config/image_config.dart
@@ -1,20 +1,9 @@
-class ImageConfig {
- final bool preferRemote;
- final bool loadOriginal;
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
- const ImageConfig({this.preferRemote = false, this.loadOriginal = false});
+part 'image_config.freezed.dart';
- ImageConfig copyWith({bool? preferRemote, bool? loadOriginal}) =>
- ImageConfig(preferRemote: preferRemote ?? this.preferRemote, loadOriginal: loadOriginal ?? this.loadOriginal);
-
- @override
- bool operator ==(Object other) =>
- identical(this, other) ||
- (other is ImageConfig && other.preferRemote == preferRemote && other.loadOriginal == loadOriginal);
-
- @override
- int get hashCode => Object.hash(preferRemote, loadOriginal);
-
- @override
- String toString() => 'ImageConfig(preferRemoteImage: $preferRemote, loadOriginal: $loadOriginal)';
+@freezed
+abstract class ImageConfig with _$ImageConfig {
+ const factory ImageConfig({@Default(false) bool preferRemote, @Default(false) bool loadOriginal}) = _ImageConfig;
}
diff --git a/mobile/lib/domain/models/config/share_config.dart b/mobile/lib/domain/models/config/share_config.dart
index 898867ff78..49530aca30 100644
--- a/mobile/lib/domain/models/config/share_config.dart
+++ b/mobile/lib/domain/models/config/share_config.dart
@@ -1,18 +1,10 @@
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/constants/enums.dart';
-class ShareConfig {
- final ShareAssetType fileType;
+part 'share_config.freezed.dart';
- const ShareConfig({this.fileType = ShareAssetType.original});
-
- ShareConfig copyWith({ShareAssetType? fileType}) => ShareConfig(fileType: fileType ?? this.fileType);
-
- @override
- bool operator ==(Object other) => identical(this, other) || (other is ShareConfig && other.fileType == fileType);
-
- @override
- int get hashCode => fileType.hashCode;
-
- @override
- String toString() => 'ShareConfig(fileType: $fileType)';
+@freezed
+abstract class ShareConfig with _$ShareConfig {
+ const factory ShareConfig({@Default(ShareAssetType.original) ShareAssetType fileType}) = _ShareConfig;
}
diff --git a/mobile/lib/domain/models/config/slideshow_config.dart b/mobile/lib/domain/models/config/slideshow_config.dart
index 6bcdaadc77..cacba1a89e 100644
--- a/mobile/lib/domain/models/config/slideshow_config.dart
+++ b/mobile/lib/domain/models/config/slideshow_config.dart
@@ -1,38 +1,15 @@
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/constants/enums.dart';
-class SlideshowConfig {
- final bool repeat;
- final int duration;
- final SlideshowLook look;
- final SlideshowDirection direction;
+part 'slideshow_config.freezed.dart';
- const SlideshowConfig({
- this.repeat = true,
- this.duration = 5,
- this.look = SlideshowLook.blurredBackground,
- this.direction = SlideshowDirection.forward,
- });
-
- SlideshowConfig copyWith({bool? repeat, int? duration, SlideshowLook? look, SlideshowDirection? direction}) =>
- SlideshowConfig(
- repeat: repeat ?? this.repeat,
- duration: duration ?? this.duration,
- look: look ?? this.look,
- direction: direction ?? this.direction,
- );
-
- @override
- bool operator ==(Object other) =>
- identical(this, other) ||
- (other is SlideshowConfig &&
- other.repeat == repeat &&
- other.duration == duration &&
- other.look == look &&
- other.direction == direction);
-
- @override
- int get hashCode => Object.hash(repeat, duration, look, direction);
-
- @override
- String toString() => 'SlideshowConfig(repeat: $repeat, duration: $duration, look: $look, direction: $direction)';
+@freezed
+abstract class SlideshowConfig with _$SlideshowConfig {
+ const factory SlideshowConfig({
+ @Default(true) bool repeat,
+ @Default(5) int duration,
+ @Default(SlideshowLook.blurredBackground) SlideshowLook look,
+ @Default(SlideshowDirection.forward) SlideshowDirection direction,
+ }) = _SlideshowConfig;
}
diff --git a/mobile/lib/domain/models/config/theme_config.dart b/mobile/lib/domain/models/config/theme_config.dart
index fa955c5d46..ac651a8062 100644
--- a/mobile/lib/domain/models/config/theme_config.dart
+++ b/mobile/lib/domain/models/config/theme_config.dart
@@ -1,44 +1,16 @@
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/constants/colors.dart';
-class ThemeConfig {
- final ThemeMode mode;
- final ImmichColorPreset primaryColor;
- final bool dynamicTheme;
- final bool colorfulInterface;
+part 'theme_config.freezed.dart';
- const ThemeConfig({
- this.mode = .system,
- this.primaryColor = .indigo,
- this.dynamicTheme = false,
- this.colorfulInterface = true,
- });
-
- ThemeConfig copyWith({
- ThemeMode? mode,
- ImmichColorPreset? primaryColor,
- bool? dynamicTheme,
- bool? colorfulInterface,
- }) => .new(
- mode: mode ?? this.mode,
- primaryColor: primaryColor ?? this.primaryColor,
- dynamicTheme: dynamicTheme ?? this.dynamicTheme,
- colorfulInterface: colorfulInterface ?? this.colorfulInterface,
- );
-
- @override
- bool operator ==(Object other) =>
- identical(this, other) ||
- (other is ThemeConfig &&
- other.mode == mode &&
- other.primaryColor == primaryColor &&
- other.dynamicTheme == dynamicTheme &&
- other.colorfulInterface == colorfulInterface);
-
- @override
- int get hashCode => Object.hash(mode, primaryColor, dynamicTheme, colorfulInterface);
-
- @override
- String toString() =>
- 'ThemeConfig(mode: $mode, primaryColor: $primaryColor, dynamicTheme: $dynamicTheme, colorfulInterface: $colorfulInterface)';
+@freezed
+abstract class ThemeConfig with _$ThemeConfig {
+ const factory ThemeConfig({
+ @Default(ThemeMode.system) ThemeMode mode,
+ @Default(ImmichColorPreset.indigo) ImmichColorPreset primaryColor,
+ @Default(false) bool dynamicTheme,
+ @Default(true) bool colorfulInterface,
+ }) = _ThemeConfig;
}
diff --git a/mobile/lib/domain/models/config/timeline_config.dart b/mobile/lib/domain/models/config/timeline_config.dart
index 4b6b9d5625..c4681b0a62 100644
--- a/mobile/lib/domain/models/config/timeline_config.dart
+++ b/mobile/lib/domain/models/config/timeline_config.dart
@@ -1,30 +1,14 @@
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
-class TimelineConfig {
- final int tilesPerRow;
- final GroupAssetsBy groupAssetsBy;
- final bool storageIndicator;
+part 'timeline_config.freezed.dart';
- const TimelineConfig({this.tilesPerRow = 4, this.groupAssetsBy = GroupAssetsBy.day, this.storageIndicator = true});
-
- TimelineConfig copyWith({int? tilesPerRow, GroupAssetsBy? groupAssetsBy, bool? storageIndicator}) => TimelineConfig(
- tilesPerRow: tilesPerRow ?? this.tilesPerRow,
- groupAssetsBy: groupAssetsBy ?? this.groupAssetsBy,
- storageIndicator: storageIndicator ?? this.storageIndicator,
- );
-
- @override
- bool operator ==(Object other) =>
- identical(this, other) ||
- (other is TimelineConfig &&
- other.tilesPerRow == tilesPerRow &&
- other.groupAssetsBy == groupAssetsBy &&
- other.storageIndicator == storageIndicator);
-
- @override
- int get hashCode => Object.hash(tilesPerRow, groupAssetsBy, storageIndicator);
-
- @override
- String toString() =>
- 'TimelineConfig(tilesPerRow: $tilesPerRow, groupAssetsBy: $groupAssetsBy, storageIndicator: $storageIndicator)';
+@freezed
+abstract class TimelineConfig with _$TimelineConfig {
+ const factory TimelineConfig({
+ @Default(4) int tilesPerRow,
+ @Default(GroupAssetsBy.day) GroupAssetsBy groupAssetsBy,
+ @Default(true) bool storageIndicator,
+ }) = _TimelineConfig;
}
diff --git a/mobile/lib/domain/models/config/viewer_config.dart b/mobile/lib/domain/models/config/viewer_config.dart
index 595f2bee5d..e799843034 100644
--- a/mobile/lib/domain/models/config/viewer_config.dart
+++ b/mobile/lib/domain/models/config/viewer_config.dart
@@ -1,37 +1,14 @@
-class ViewerConfig {
- final bool loopVideo;
- final bool loadOriginalVideo;
- final bool autoPlayVideo;
- final bool tapToNavigate;
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
- const ViewerConfig({
- this.loopVideo = true,
- this.loadOriginalVideo = false,
- this.autoPlayVideo = true,
- this.tapToNavigate = false,
- });
+part 'viewer_config.freezed.dart';
- ViewerConfig copyWith({bool? loopVideo, bool? loadOriginalVideo, bool? autoPlayVideo, bool? tapToNavigate}) =>
- ViewerConfig(
- loopVideo: loopVideo ?? this.loopVideo,
- loadOriginalVideo: loadOriginalVideo ?? this.loadOriginalVideo,
- autoPlayVideo: autoPlayVideo ?? this.autoPlayVideo,
- tapToNavigate: tapToNavigate ?? this.tapToNavigate,
- );
-
- @override
- bool operator ==(Object other) =>
- identical(this, other) ||
- (other is ViewerConfig &&
- other.loopVideo == loopVideo &&
- other.loadOriginalVideo == loadOriginalVideo &&
- other.autoPlayVideo == autoPlayVideo &&
- other.tapToNavigate == tapToNavigate);
-
- @override
- int get hashCode => Object.hash(loopVideo, loadOriginalVideo, autoPlayVideo, tapToNavigate);
-
- @override
- String toString() =>
- 'ViewerConfig(loopVideo: $loopVideo, loadOriginalVideo: $loadOriginalVideo, autoPlayVideo: $autoPlayVideo, tapToNavigate: $tapToNavigate)';
+@freezed
+abstract class ViewerConfig with _$ViewerConfig {
+ const factory ViewerConfig({
+ @Default(true) bool loopVideo,
+ @Default(false) bool loadOriginalVideo,
+ @Default(true) bool autoPlayVideo,
+ @Default(false) bool tapToNavigate,
+ }) = _ViewerConfig;
}
diff --git a/mobile/lib/domain/models/exif.model.dart b/mobile/lib/domain/models/exif.model.dart
index 4284aef2ab..3536048ecc 100644
--- a/mobile/lib/domain/models/exif.model.dart
+++ b/mobile/lib/domain/models/exif.model.dart
@@ -1,30 +1,40 @@
-class ExifInfo {
- final int? assetId;
- final int? fileSize;
- final String? description;
- final bool isFlipped;
- final String? orientation;
- final String? timeZone;
- final DateTime? dateTimeOriginal;
- final int? rating;
- final int? width;
- final int? height;
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
- // GPS
- final double? latitude;
- final double? longitude;
- final String? city;
- final String? state;
- final String? country;
+part 'exif.model.freezed.dart';
- // Camera related
- final String? make;
- final String? model;
- final String? lens;
- final double? f;
- final double? mm;
- final int? iso;
- final double? exposureSeconds;
+@freezed
+abstract class ExifInfo with _$ExifInfo {
+ const ExifInfo._();
+
+ const factory ExifInfo({
+ int? assetId,
+ int? fileSize,
+ String? description,
+ @Default(false) bool isFlipped,
+ String? orientation,
+ String? timeZone,
+ DateTime? dateTimeOriginal,
+ int? rating,
+ int? width,
+ int? height,
+
+ // GPS
+ double? latitude,
+ double? longitude,
+ String? city,
+ String? state,
+ String? country,
+
+ // Camera related
+ String? make,
+ String? model,
+ String? lens,
+ double? f,
+ double? mm,
+ int? iso,
+ double? exposureSeconds,
+ }) = _ExifInfo;
bool get hasCoordinates => latitude != null && longitude != null && latitude != 0 && longitude != 0;
@@ -41,162 +51,4 @@ class ExifInfo {
String get fNumber => f == null ? "" : f!.toStringAsFixed(1);
String get focalLength => mm == null ? "" : mm!.toStringAsFixed(3);
-
- const ExifInfo({
- this.assetId,
- this.fileSize,
- this.description,
- this.orientation,
- this.timeZone,
- this.dateTimeOriginal,
- this.rating,
- this.width,
- this.height,
- this.isFlipped = false,
- this.latitude,
- this.longitude,
- this.city,
- this.state,
- this.country,
- this.make,
- this.model,
- this.lens,
- this.f,
- this.mm,
- this.iso,
- this.exposureSeconds,
- });
-
- @override
- bool operator ==(covariant ExifInfo other) {
- if (identical(this, other)) {
- return true;
- }
-
- return other.fileSize == fileSize &&
- other.description == description &&
- other.isFlipped == isFlipped &&
- other.orientation == orientation &&
- other.timeZone == timeZone &&
- other.dateTimeOriginal == dateTimeOriginal &&
- other.rating == rating &&
- other.width == width &&
- other.height == height &&
- other.latitude == latitude &&
- other.longitude == longitude &&
- other.city == city &&
- other.state == state &&
- other.country == country &&
- other.make == make &&
- other.model == model &&
- other.lens == lens &&
- other.f == f &&
- other.mm == mm &&
- other.iso == iso &&
- other.exposureSeconds == exposureSeconds &&
- other.assetId == assetId;
- }
-
- @override
- int get hashCode {
- return fileSize.hashCode ^
- description.hashCode ^
- orientation.hashCode ^
- isFlipped.hashCode ^
- timeZone.hashCode ^
- dateTimeOriginal.hashCode ^
- rating.hashCode ^
- width.hashCode ^
- height.hashCode ^
- latitude.hashCode ^
- longitude.hashCode ^
- city.hashCode ^
- state.hashCode ^
- country.hashCode ^
- make.hashCode ^
- model.hashCode ^
- lens.hashCode ^
- f.hashCode ^
- mm.hashCode ^
- iso.hashCode ^
- exposureSeconds.hashCode ^
- assetId.hashCode;
- }
-
- @override
- String toString() {
- return '''{
-fileSize: ${fileSize ?? 'NA'},
-description: ${description ?? 'NA'},
-orientation: ${orientation ?? 'NA'},
-isFlipped: $isFlipped,
-timeZone: ${timeZone ?? 'NA'},
-dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},
-rating: ${rating ?? 'NA'},
-width: ${width ?? 'NA'},
-height: ${height ?? 'NA'},
-latitude: ${latitude ?? 'NA'},
-longitude: ${longitude ?? 'NA'},
-city: ${city ?? 'NA'},
-state: ${state ?? 'NA'},
-country: ${country ?? ''},
-make: ${make ?? 'NA'},
-model: ${model ?? 'NA'},
-lens: ${lens ?? 'NA'},
-f: ${f ?? 'NA'},
-mm: ${mm ?? ''},
-iso: ${iso ?? 'NA'},
-exposureSeconds: ${exposureSeconds ?? 'NA'},
-}''';
- }
-
- ExifInfo copyWith({
- int? assetId,
- int? fileSize,
- String? description,
- String? orientation,
- String? timeZone,
- DateTime? dateTimeOriginal,
- int? rating,
- int? width,
- int? height,
- double? latitude,
- double? longitude,
- String? city,
- String? state,
- String? country,
- bool? isFlipped,
- String? make,
- String? model,
- String? lens,
- double? f,
- double? mm,
- int? iso,
- double? exposureSeconds,
- }) {
- return ExifInfo(
- assetId: assetId ?? this.assetId,
- fileSize: fileSize ?? this.fileSize,
- description: description ?? this.description,
- orientation: orientation ?? this.orientation,
- timeZone: timeZone ?? this.timeZone,
- dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
- rating: rating ?? this.rating,
- width: width ?? this.width,
- height: height ?? this.height,
- isFlipped: isFlipped ?? this.isFlipped,
- latitude: latitude ?? this.latitude,
- longitude: longitude ?? this.longitude,
- city: city ?? this.city,
- state: state ?? this.state,
- country: country ?? this.country,
- make: make ?? this.make,
- model: model ?? this.model,
- lens: lens ?? this.lens,
- f: f ?? this.f,
- mm: mm ?? this.mm,
- iso: iso ?? this.iso,
- exposureSeconds: exposureSeconds ?? this.exposureSeconds,
- );
- }
}
diff --git a/mobile/lib/domain/models/ocr.model.dart b/mobile/lib/domain/models/ocr.model.dart
index 403c06d44e..bfb398bc79 100644
--- a/mobile/lib/domain/models/ocr.model.dart
+++ b/mobile/lib/domain/models/ocr.model.dart
@@ -1,128 +1,24 @@
-class Ocr {
- final String id;
- final String assetId;
- final double x1;
- final double y1;
- final double x2;
- final double y2;
- final double x3;
- final double y3;
- final double x4;
- final double y4;
- final double boxScore;
- final double textScore;
- final String text;
- final bool isVisible;
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
- const Ocr({
- required this.id,
- required this.assetId,
- required this.x1,
- required this.y1,
- required this.x2,
- required this.y2,
- required this.x3,
- required this.y3,
- required this.x4,
- required this.y4,
- required this.boxScore,
- required this.textScore,
- required this.text,
- required this.isVisible,
- });
+part 'ocr.model.freezed.dart';
- Ocr copyWith({
- String? id,
- String? assetId,
- double? x1,
- double? y1,
- double? x2,
- double? y2,
- double? x3,
- double? y3,
- double? x4,
- double? y4,
- double? boxScore,
- double? textScore,
- String? text,
- bool? isVisible,
- }) {
- return Ocr(
- id: id ?? this.id,
- assetId: assetId ?? this.assetId,
- x1: x1 ?? this.x1,
- y1: y1 ?? this.y1,
- x2: x2 ?? this.x2,
- y2: y2 ?? this.y2,
- x3: x3 ?? this.x3,
- y3: y3 ?? this.y3,
- x4: x4 ?? this.x4,
- y4: y4 ?? this.y4,
- boxScore: boxScore ?? this.boxScore,
- textScore: textScore ?? this.textScore,
- text: text ?? this.text,
- isVisible: isVisible ?? this.isVisible,
- );
- }
-
- @override
- String toString() {
- return '''Ocr {
- id: $id,
- assetId: $assetId,
- x1: $x1,
- y1: $y1,
- x2: $x2,
- y2: $y2,
- x3: $x3,
- y3: $y3,
- x4: $x4,
- y4: $y4,
- boxScore: $boxScore,
- textScore: $textScore,
- text: $text,
- isVisible: $isVisible
- }''';
- }
-
- @override
- bool operator ==(Object other) {
- if (identical(this, other)) {
- return true;
- }
-
- return other is Ocr &&
- other.id == id &&
- other.assetId == assetId &&
- other.x1 == x1 &&
- other.y1 == y1 &&
- other.x2 == x2 &&
- other.y2 == y2 &&
- other.x3 == x3 &&
- other.y3 == y3 &&
- other.x4 == x4 &&
- other.y4 == y4 &&
- other.boxScore == boxScore &&
- other.textScore == textScore &&
- other.text == text &&
- other.isVisible == isVisible;
- }
-
- @override
- int get hashCode {
- return id.hashCode ^
- assetId.hashCode ^
- x1.hashCode ^
- y1.hashCode ^
- x2.hashCode ^
- y2.hashCode ^
- x3.hashCode ^
- y3.hashCode ^
- x4.hashCode ^
- y4.hashCode ^
- boxScore.hashCode ^
- textScore.hashCode ^
- text.hashCode ^
- isVisible.hashCode;
- }
+@freezed
+abstract class Ocr with _$Ocr {
+ const factory Ocr({
+ required String id,
+ required String assetId,
+ required double x1,
+ required double y1,
+ required double x2,
+ required double y2,
+ required double x3,
+ required double y3,
+ required double x4,
+ required double y4,
+ required double boxScore,
+ required double textScore,
+ required String text,
+ required bool isVisible,
+ }) = _Ocr;
}
diff --git a/mobile/lib/domain/models/person.model.dart b/mobile/lib/domain/models/person.model.dart
index c7cdcff3af..38d740eeb4 100644
--- a/mobile/lib/domain/models/person.model.dart
+++ b/mobile/lib/domain/models/person.model.dart
@@ -1,5 +1,10 @@
import 'dart:convert';
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'person.model.freezed.dart';
+
// TODO: Remove PersonDto once Isar is removed
class PersonDto {
const PersonDto({
@@ -93,102 +98,18 @@ class PersonDto {
}
// Model for a person stored in the server
-class DriftPerson {
- final String id;
- final DateTime createdAt;
- final DateTime updatedAt;
- final String ownerId;
- final String name;
- final String? faceAssetId;
- final bool isFavorite;
- final bool isHidden;
- final String? color;
- final DateTime? birthDate;
-
- const DriftPerson({
- required this.id,
- required this.createdAt,
- required this.updatedAt,
- required this.ownerId,
- required this.name,
- this.faceAssetId,
- required this.isFavorite,
- required this.isHidden,
- required this.color,
- this.birthDate,
- });
-
- DriftPerson copyWith({
- String? id,
- DateTime? createdAt,
- DateTime? updatedAt,
- String? ownerId,
- String? name,
+@freezed
+abstract class DriftPerson with _$DriftPerson {
+ const factory DriftPerson({
+ required String id,
+ required DateTime createdAt,
+ required DateTime updatedAt,
+ required String ownerId,
+ required String name,
String? faceAssetId,
- bool? isFavorite,
- bool? isHidden,
- String? color,
+ required bool isFavorite,
+ required bool isHidden,
+ required String? color,
DateTime? birthDate,
- }) {
- return DriftPerson(
- id: id ?? this.id,
- createdAt: createdAt ?? this.createdAt,
- updatedAt: updatedAt ?? this.updatedAt,
- ownerId: ownerId ?? this.ownerId,
- name: name ?? this.name,
- faceAssetId: faceAssetId ?? this.faceAssetId,
- isFavorite: isFavorite ?? this.isFavorite,
- isHidden: isHidden ?? this.isHidden,
- color: color ?? this.color,
- birthDate: birthDate ?? this.birthDate,
- );
- }
-
- @override
- String toString() {
- return '''Person {
- id: $id,
- createdAt: $createdAt,
- updatedAt: $updatedAt,
- ownerId: $ownerId,
- name: $name,
- faceAssetId: ${faceAssetId ?? ""},
- isFavorite: $isFavorite,
- isHidden: $isHidden,
- color: ${color ?? ""},
- birthDate: ${birthDate ?? ""}
-}''';
- }
-
- @override
- bool operator ==(covariant DriftPerson other) {
- if (identical(this, other)) {
- return true;
- }
-
- return other.id == id &&
- other.createdAt == createdAt &&
- other.updatedAt == updatedAt &&
- other.ownerId == ownerId &&
- other.name == name &&
- other.faceAssetId == faceAssetId &&
- other.isFavorite == isFavorite &&
- other.isHidden == isHidden &&
- other.color == color &&
- other.birthDate == birthDate;
- }
-
- @override
- int get hashCode {
- return id.hashCode ^
- createdAt.hashCode ^
- updatedAt.hashCode ^
- ownerId.hashCode ^
- name.hashCode ^
- faceAssetId.hashCode ^
- isFavorite.hashCode ^
- isHidden.hashCode ^
- color.hashCode ^
- birthDate.hashCode;
- }
+ }) = _DriftPerson;
}
diff --git a/mobile/lib/domain/models/stack.model.dart b/mobile/lib/domain/models/stack.model.dart
index f17f5788c9..4e88a02c6c 100644
--- a/mobile/lib/domain/models/stack.model.dart
+++ b/mobile/lib/domain/models/stack.model.dart
@@ -1,57 +1,18 @@
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'stack.model.freezed.dart';
+
// Model for a stack stored in the server
-class Stack {
- final String id;
- final DateTime createdAt;
- final DateTime updatedAt;
- final String ownerId;
- final String primaryAssetId;
-
- const Stack({
- required this.id,
- required this.createdAt,
- required this.updatedAt,
- required this.ownerId,
- required this.primaryAssetId,
- });
-
- Stack copyWith({String? id, DateTime? createdAt, DateTime? updatedAt, String? ownerId, String? primaryAssetId}) {
- return Stack(
- id: id ?? this.id,
- createdAt: createdAt ?? this.createdAt,
- updatedAt: updatedAt ?? this.updatedAt,
- ownerId: ownerId ?? this.ownerId,
- primaryAssetId: primaryAssetId ?? this.primaryAssetId,
- );
- }
-
- @override
- String toString() {
- return '''Stack {
- id: $id,
- createdAt: $createdAt,
- updatedAt: $updatedAt,
- ownerId: $ownerId,
- primaryAssetId: $primaryAssetId
-}''';
- }
-
- @override
- bool operator ==(covariant Stack other) {
- if (identical(this, other)) {
- return true;
- }
-
- return other.id == id &&
- other.createdAt == createdAt &&
- other.updatedAt == updatedAt &&
- other.ownerId == ownerId &&
- other.primaryAssetId == primaryAssetId;
- }
-
- @override
- int get hashCode {
- return id.hashCode ^ createdAt.hashCode ^ updatedAt.hashCode ^ ownerId.hashCode ^ primaryAssetId.hashCode;
- }
+@freezed
+abstract class Stack with _$Stack {
+ const factory Stack({
+ required String id,
+ required DateTime createdAt,
+ required DateTime updatedAt,
+ required String ownerId,
+ required String primaryAssetId,
+ }) = _Stack;
}
class StackResponse {
diff --git a/mobile/lib/domain/models/user_metadata.model.dart b/mobile/lib/domain/models/user_metadata.model.dart
index 0e702ba868..8bce1aca13 100644
--- a/mobile/lib/domain/models/user_metadata.model.dart
+++ b/mobile/lib/domain/models/user_metadata.model.dart
@@ -1,5 +1,9 @@
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
+part 'user_metadata.model.freezed.dart';
+
enum UserMetadataKey {
// do not change this order!
onboarding,
@@ -224,61 +228,17 @@ licenseKey: $licenseKey,
}
// Model for a user metadata stored in the server
-class UserMetadata {
- final String userId;
- final UserMetadataKey key;
- final Onboarding? onboarding;
- final Preferences? preferences;
- final License? license;
-
- const UserMetadata({required this.userId, required this.key, this.onboarding, this.preferences, this.license})
- : assert(
- onboarding != null || preferences != null || license != null,
- 'One of onboarding, preferences and license must be provided',
- );
-
- UserMetadata copyWith({
- String? userId,
- UserMetadataKey? key,
+@freezed
+abstract class UserMetadata with _$UserMetadata {
+ @Assert(
+ 'onboarding != null || preferences != null || license != null',
+ 'One of onboarding, preferences and license must be provided',
+ )
+ const factory UserMetadata({
+ required String userId,
+ required UserMetadataKey key,
Onboarding? onboarding,
Preferences? preferences,
License? license,
- }) {
- return UserMetadata(
- userId: userId ?? this.userId,
- key: key ?? this.key,
- onboarding: onboarding ?? this.onboarding,
- preferences: preferences ?? this.preferences,
- license: license ?? this.license,
- );
- }
-
- @override
- String toString() {
- return '''UserMetadata: {
-userId: $userId,
-key: $key,
-onboarding: ${onboarding ?? ""},
-preferences: ${preferences ?? ""},
-license: ${license ?? ""},
-}''';
- }
-
- @override
- bool operator ==(covariant UserMetadata other) {
- if (identical(this, other)) {
- return true;
- }
-
- return other.userId == userId &&
- other.key == key &&
- other.onboarding == onboarding &&
- other.preferences == preferences &&
- other.license == license;
- }
-
- @override
- int get hashCode {
- return userId.hashCode ^ key.hashCode ^ onboarding.hashCode ^ preferences.hashCode ^ license.hashCode;
- }
+ }) = _UserMetadata;
}
diff --git a/mobile/lib/domain/services/hash.service.dart b/mobile/lib/domain/services/hash.service.dart
index b0dbf8fbea..8a573ee209 100644
--- a/mobile/lib/domain/services/hash.service.dart
+++ b/mobile/lib/domain/services/hash.service.dart
@@ -66,11 +66,12 @@ class HashService {
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true);
}
}
- } on PlatformException catch (e) {
+ } on PlatformException catch (e, s) {
if (e.code == _kHashCancelledCode) {
_log.warning("Hashing cancelled by platform");
return;
}
+ _log.severe("Native hashing failed: ${e.code}", e, s);
} catch (e, s) {
_log.severe("Error during hashing", e, s);
}
diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart
index 82f397d9b6..db0d93c92f 100644
--- a/mobile/lib/domain/utils/background_sync.dart
+++ b/mobile/lib/domain/utils/background_sync.dart
@@ -28,6 +28,7 @@ class BackgroundSyncManager {
final SyncErrorCallback? onCloudIdSyncError;
Cancelable? _syncTask;
+ bool _syncQueued = false;
Cancelable? _syncWebsocketTask;
Cancelable? _cloudIdSyncTask;
Cancelable? _deviceAlbumSyncTask;
@@ -50,6 +51,7 @@ class BackgroundSyncManager {
});
Future cancel() async {
+ _syncQueued = false;
final tasks = [
_syncTask,
_syncWebsocketTask,
@@ -133,8 +135,9 @@ class BackgroundSyncManager {
});
}
- Future syncRemote() {
+ Future syncRemote({bool enqueue = false}) {
if (_syncTask != null) {
+ _syncQueued |= enqueue;
return _syncTask!.future.then((result) => result ?? false).catchError((_) => false);
}
@@ -148,15 +151,20 @@ class BackgroundSyncManager {
.then((result) {
final success = result ?? false;
onRemoteSyncComplete?.call(success);
+ _syncQueued &= success;
return success;
})
.catchError((error) {
onRemoteSyncError?.call(error.toString());
- _syncTask = null;
+ _syncQueued = false;
return false;
})
.whenComplete(() {
_syncTask = null;
+ if (_syncQueued) {
+ _syncQueued = false;
+ unawaited(syncRemote());
+ }
});
}
diff --git a/mobile/lib/models/activities/activity.model.dart b/mobile/lib/models/activities/activity.model.dart
index d3f99aea64..9a589ef729 100644
--- a/mobile/lib/models/activities/activity.model.dart
+++ b/mobile/lib/models/activities/activity.model.dart
@@ -1,65 +1,21 @@
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
+part 'activity.model.freezed.dart';
+
enum ActivityType { comment, like }
-class Activity {
- final String id;
- final String? assetId;
- final String? comment;
- final DateTime createdAt;
- final ActivityType type;
- final UserDto user;
-
- const Activity({
- required this.id,
- this.assetId,
- this.comment,
- required this.createdAt,
- required this.type,
- required this.user,
- });
-
- Activity copyWith({
- String? id,
+@freezed
+abstract class Activity with _$Activity {
+ const factory Activity({
+ required String id,
String? assetId,
String? comment,
- DateTime? createdAt,
- ActivityType? type,
- UserDto? user,
- }) {
- return Activity(
- id: id ?? this.id,
- assetId: assetId ?? this.assetId,
- comment: comment ?? this.comment,
- createdAt: createdAt ?? this.createdAt,
- type: type ?? this.type,
- user: user ?? this.user,
- );
- }
-
- @override
- String toString() {
- return 'Activity(id: $id, assetId: $assetId, comment: $comment, createdAt: $createdAt, type: $type, user: $user)';
- }
-
- @override
- bool operator ==(covariant Activity other) {
- if (identical(this, other)) {
- return true;
- }
-
- return other.id == id &&
- other.assetId == assetId &&
- other.comment == comment &&
- other.createdAt == createdAt &&
- other.type == type &&
- other.user == user;
- }
-
- @override
- int get hashCode {
- return id.hashCode ^ assetId.hashCode ^ comment.hashCode ^ createdAt.hashCode ^ type.hashCode ^ user.hashCode;
- }
+ required DateTime createdAt,
+ required ActivityType type,
+ required UserDto user,
+ }) = _Activity;
}
class ActivityStats {
diff --git a/mobile/lib/models/auth/auth_state.model.dart b/mobile/lib/models/auth/auth_state.model.dart
index c8a8018929..0a3bf983b2 100644
--- a/mobile/lib/models/auth/auth_state.model.dart
+++ b/mobile/lib/models/auth/auth_state.model.dart
@@ -1,71 +1,17 @@
-class AuthState {
- final String deviceId;
- final String userId;
- final String userEmail;
- final bool isAuthenticated;
- final String name;
- final bool isAdmin;
- final String profileImagePath;
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
- const AuthState({
- required this.deviceId,
- required this.userId,
- required this.userEmail,
- required this.isAuthenticated,
- required this.name,
- required this.isAdmin,
- required this.profileImagePath,
- });
+part 'auth_state.model.freezed.dart';
- AuthState copyWith({
- String? deviceId,
- String? userId,
- String? userEmail,
- bool? isAuthenticated,
- String? name,
- bool? isAdmin,
- String? profileImagePath,
- }) {
- return AuthState(
- deviceId: deviceId ?? this.deviceId,
- userId: userId ?? this.userId,
- userEmail: userEmail ?? this.userEmail,
- isAuthenticated: isAuthenticated ?? this.isAuthenticated,
- name: name ?? this.name,
- isAdmin: isAdmin ?? this.isAdmin,
- profileImagePath: profileImagePath ?? this.profileImagePath,
- );
- }
-
- @override
- String toString() {
- return 'AuthenticationState(deviceId: $deviceId, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, name: $name, isAdmin: $isAdmin, profileImagePath: $profileImagePath)';
- }
-
- @override
- bool operator ==(Object other) {
- if (identical(this, other)) {
- return true;
- }
-
- return other is AuthState &&
- other.deviceId == deviceId &&
- other.userId == userId &&
- other.userEmail == userEmail &&
- other.isAuthenticated == isAuthenticated &&
- other.name == name &&
- other.isAdmin == isAdmin &&
- other.profileImagePath == profileImagePath;
- }
-
- @override
- int get hashCode {
- return deviceId.hashCode ^
- userId.hashCode ^
- userEmail.hashCode ^
- isAuthenticated.hashCode ^
- name.hashCode ^
- isAdmin.hashCode ^
- profileImagePath.hashCode;
- }
+@freezed
+abstract class AuthState with _$AuthState {
+ const factory AuthState({
+ required String deviceId,
+ required String userId,
+ required String userEmail,
+ required bool isAuthenticated,
+ required String name,
+ required bool isAdmin,
+ required String profileImagePath,
+ }) = _AuthState;
}
diff --git a/mobile/lib/models/map/map_marker.model.dart b/mobile/lib/models/map/map_marker.model.dart
index d730f9bd6d..4093747e84 100644
--- a/mobile/lib/models/map/map_marker.model.dart
+++ b/mobile/lib/models/map/map_marker.model.dart
@@ -1,29 +1,14 @@
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:openapi/api.dart';
-class MapMarker {
- final LatLng latLng;
- final String assetRemoteId;
- const MapMarker({required this.latLng, required this.assetRemoteId});
+part 'map_marker.model.freezed.dart';
- MapMarker copyWith({LatLng? latLng, String? assetRemoteId}) {
- return MapMarker(latLng: latLng ?? this.latLng, assetRemoteId: assetRemoteId ?? this.assetRemoteId);
- }
+@freezed
+abstract class MapMarker with _$MapMarker {
+ const factory MapMarker({required LatLng latLng, required String assetRemoteId}) = _MapMarker;
- MapMarker.fromDto(MapMarkerResponseDto dto) : latLng = LatLng(dto.lat, dto.lon), assetRemoteId = dto.id;
-
- @override
- String toString() => 'MapMarker(latLng: $latLng, assetRemoteId: $assetRemoteId)';
-
- @override
- bool operator ==(covariant MapMarker other) {
- if (identical(this, other)) {
- return true;
- }
-
- return other.latLng == latLng && other.assetRemoteId == assetRemoteId;
- }
-
- @override
- int get hashCode => latLng.hashCode ^ assetRemoteId.hashCode;
+ factory MapMarker.fromDto(MapMarkerResponseDto dto) =>
+ MapMarker(latLng: LatLng(dto.lat, dto.lon), assetRemoteId: dto.id);
}
diff --git a/mobile/lib/models/map/map_state.model.dart b/mobile/lib/models/map/map_state.model.dart
index a4863aa465..f7bd435073 100644
--- a/mobile/lib/models/map/map_state.model.dart
+++ b/mobile/lib/models/map/map_state.model.dart
@@ -1,79 +1,20 @@
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
-class MapState {
- final ThemeMode themeMode;
- final bool showFavoriteOnly;
- final bool includeArchived;
- final bool withPartners;
- final int relativeTime;
- final bool shouldRefetchMarkers;
- final AsyncValue lightStyleFetched;
- final AsyncValue darkStyleFetched;
+part 'map_state.model.freezed.dart';
- const MapState({
- this.themeMode = ThemeMode.system,
- this.showFavoriteOnly = false,
- this.includeArchived = false,
- this.withPartners = false,
- this.relativeTime = 0,
- this.shouldRefetchMarkers = false,
- this.lightStyleFetched = const AsyncLoading(),
- this.darkStyleFetched = const AsyncLoading(),
- });
-
- MapState copyWith({
- ThemeMode? themeMode,
- bool? showFavoriteOnly,
- bool? includeArchived,
- bool? withPartners,
- int? relativeTime,
- bool? shouldRefetchMarkers,
- AsyncValue? lightStyleFetched,
- AsyncValue? darkStyleFetched,
- }) {
- return MapState(
- themeMode: themeMode ?? this.themeMode,
- showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly,
- includeArchived: includeArchived ?? this.includeArchived,
- withPartners: withPartners ?? this.withPartners,
- relativeTime: relativeTime ?? this.relativeTime,
- shouldRefetchMarkers: shouldRefetchMarkers ?? this.shouldRefetchMarkers,
- lightStyleFetched: lightStyleFetched ?? this.lightStyleFetched,
- darkStyleFetched: darkStyleFetched ?? this.darkStyleFetched,
- );
- }
-
- @override
- String toString() {
- return 'MapState(themeMode: $themeMode, showFavoriteOnly: $showFavoriteOnly, includeArchived: $includeArchived, withPartners: $withPartners, relativeTime: $relativeTime, shouldRefetchMarkers: $shouldRefetchMarkers, lightStyleFetched: $lightStyleFetched, darkStyleFetched: $darkStyleFetched)';
- }
-
- @override
- bool operator ==(covariant MapState other) {
- if (identical(this, other)) {
- return true;
- }
-
- return other.themeMode == themeMode &&
- other.showFavoriteOnly == showFavoriteOnly &&
- other.includeArchived == includeArchived &&
- other.withPartners == withPartners &&
- other.relativeTime == relativeTime &&
- other.shouldRefetchMarkers == shouldRefetchMarkers &&
- other.lightStyleFetched == lightStyleFetched &&
- other.darkStyleFetched == darkStyleFetched;
- }
-
- @override
- int get hashCode {
- return themeMode.hashCode ^
- showFavoriteOnly.hashCode ^
- includeArchived.hashCode ^
- withPartners.hashCode ^
- relativeTime.hashCode ^
- shouldRefetchMarkers.hashCode ^
- lightStyleFetched.hashCode ^
- darkStyleFetched.hashCode;
- }
+@freezed
+abstract class MapState with _$MapState {
+ const factory MapState({
+ @Default(ThemeMode.system) ThemeMode themeMode,
+ @Default(false) bool showFavoriteOnly,
+ @Default(false) bool includeArchived,
+ @Default(false) bool withPartners,
+ @Default(0) int relativeTime,
+ @Default(false) bool shouldRefetchMarkers,
+ @Default(AsyncLoading()) AsyncValue lightStyleFetched,
+ @Default(AsyncLoading()) AsyncValue darkStyleFetched,
+ }) = _MapState;
}
diff --git a/mobile/lib/models/server_info/server_disk_info.model.dart b/mobile/lib/models/server_info/server_disk_info.model.dart
index 16e58b331d..910f06b065 100644
--- a/mobile/lib/models/server_info/server_disk_info.model.dart
+++ b/mobile/lib/models/server_info/server_disk_info.model.dart
@@ -1,53 +1,22 @@
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:openapi/api.dart';
-class ServerDiskInfo {
- final String diskAvailable;
- final String diskSize;
- final String diskUse;
- final double diskUsagePercentage;
+part 'server_disk_info.model.freezed.dart';
- const ServerDiskInfo({
- required this.diskAvailable,
- required this.diskSize,
- required this.diskUse,
- required this.diskUsagePercentage,
- });
+@freezed
+abstract class ServerDiskInfo with _$ServerDiskInfo {
+ const factory ServerDiskInfo({
+ required String diskAvailable,
+ required String diskSize,
+ required String diskUse,
+ required double diskUsagePercentage,
+ }) = _ServerDiskInfo;
- ServerDiskInfo copyWith({String? diskAvailable, String? diskSize, String? diskUse, double? diskUsagePercentage}) {
- return ServerDiskInfo(
- diskAvailable: diskAvailable ?? this.diskAvailable,
- diskSize: diskSize ?? this.diskSize,
- diskUse: diskUse ?? this.diskUse,
- diskUsagePercentage: diskUsagePercentage ?? this.diskUsagePercentage,
- );
- }
-
- @override
- String toString() {
- return 'ServerDiskInfo(diskAvailable: $diskAvailable, diskSize: $diskSize, diskUse: $diskUse, diskUsagePercentage: $diskUsagePercentage)';
- }
-
- ServerDiskInfo.fromDto(ServerStorageResponseDto dto)
- : diskAvailable = dto.diskAvailable,
- diskSize = dto.diskSize,
- diskUse = dto.diskUse,
- diskUsagePercentage = dto.diskUsagePercentage;
-
- @override
- bool operator ==(Object other) {
- if (identical(this, other)) {
- return true;
- }
-
- return other is ServerDiskInfo &&
- other.diskAvailable == diskAvailable &&
- other.diskSize == diskSize &&
- other.diskUse == diskUse &&
- other.diskUsagePercentage == diskUsagePercentage;
- }
-
- @override
- int get hashCode {
- return diskAvailable.hashCode ^ diskSize.hashCode ^ diskUse.hashCode ^ diskUsagePercentage.hashCode;
- }
+ factory ServerDiskInfo.fromDto(ServerStorageResponseDto dto) => ServerDiskInfo(
+ diskAvailable: dto.diskAvailable,
+ diskSize: dto.diskSize,
+ diskUse: dto.diskUse,
+ diskUsagePercentage: dto.diskUsagePercentage,
+ );
}
diff --git a/mobile/lib/models/server_info/server_features.model.dart b/mobile/lib/models/server_info/server_features.model.dart
index c288c1bfbf..4de7e2e3cd 100644
--- a/mobile/lib/models/server_info/server_features.model.dart
+++ b/mobile/lib/models/server_info/server_features.model.dart
@@ -1,74 +1,26 @@
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:openapi/api.dart';
-class ServerFeatures {
- final bool trash;
- final bool map;
- final bool oauthEnabled;
- final bool passwordLogin;
- final bool ocr;
- final bool smartSearch;
+part 'server_features.model.freezed.dart';
- const ServerFeatures({
- required this.trash,
- required this.map,
- required this.oauthEnabled,
- required this.passwordLogin,
- this.ocr = false,
- this.smartSearch = false,
- });
+@freezed
+abstract class ServerFeatures with _$ServerFeatures {
+ const factory ServerFeatures({
+ required bool trash,
+ required bool map,
+ required bool oauthEnabled,
+ required bool passwordLogin,
+ @Default(false) bool ocr,
+ @Default(false) bool smartSearch,
+ }) = _ServerFeatures;
- ServerFeatures copyWith({
- bool? trash,
- bool? map,
- bool? oauthEnabled,
- bool? passwordLogin,
- bool? ocr,
- bool? smartSearch,
- }) {
- return ServerFeatures(
- trash: trash ?? this.trash,
- map: map ?? this.map,
- oauthEnabled: oauthEnabled ?? this.oauthEnabled,
- passwordLogin: passwordLogin ?? this.passwordLogin,
- ocr: ocr ?? this.ocr,
- smartSearch: smartSearch ?? this.smartSearch,
- );
- }
-
- @override
- String toString() {
- return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr, smartSearch: $smartSearch)';
- }
-
- ServerFeatures.fromDto(ServerFeaturesDto dto)
- : trash = dto.trash,
- map = dto.map,
- oauthEnabled = dto.oauth,
- passwordLogin = dto.passwordLogin,
- ocr = dto.ocr,
- smartSearch = dto.smartSearch;
-
- @override
- bool operator ==(covariant ServerFeatures other) {
- if (identical(this, other)) {
- return true;
- }
-
- return other.trash == trash &&
- other.map == map &&
- other.oauthEnabled == oauthEnabled &&
- other.passwordLogin == passwordLogin &&
- other.ocr == ocr &&
- other.smartSearch == smartSearch;
- }
-
- @override
- int get hashCode {
- return trash.hashCode ^
- map.hashCode ^
- oauthEnabled.hashCode ^
- passwordLogin.hashCode ^
- ocr.hashCode ^
- smartSearch.hashCode;
- }
+ factory ServerFeatures.fromDto(ServerFeaturesDto dto) => ServerFeatures(
+ trash: dto.trash,
+ map: dto.map,
+ oauthEnabled: dto.oauth,
+ passwordLogin: dto.passwordLogin,
+ ocr: dto.ocr,
+ smartSearch: dto.smartSearch,
+ );
}
diff --git a/mobile/lib/models/server_info/server_info.model.dart b/mobile/lib/models/server_info/server_info.model.dart
index 33d6393e15..adc55d3f4e 100644
--- a/mobile/lib/models/server_info/server_info.model.dart
+++ b/mobile/lib/models/server_info/server_info.model.dart
@@ -1,9 +1,13 @@
import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/models/server_info/server_config.model.dart';
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
import 'package:immich_mobile/models/server_info/server_features.model.dart';
import 'package:immich_mobile/models/server_info/server_version.model.dart';
+part 'server_info.model.freezed.dart';
+
enum VersionStatus {
upToDate,
clientOutOfDate,
@@ -18,68 +22,14 @@ enum VersionStatus {
};
}
-class ServerInfo {
- final ServerVersion serverVersion;
- final ServerVersion? latestVersion;
- final ServerFeatures serverFeatures;
- final ServerConfig serverConfig;
- final ServerDiskInfo serverDiskInfo;
- final VersionStatus versionStatus;
-
- const ServerInfo({
- required this.serverVersion,
- this.latestVersion,
- required this.serverFeatures,
- required this.serverConfig,
- required this.serverDiskInfo,
- required this.versionStatus,
- });
-
- ServerInfo copyWith({
- ServerVersion? serverVersion,
+@freezed
+abstract class ServerInfo with _$ServerInfo {
+ const factory ServerInfo({
+ required ServerVersion serverVersion,
ServerVersion? latestVersion,
- ServerFeatures? serverFeatures,
- ServerConfig? serverConfig,
- ServerDiskInfo? serverDiskInfo,
- VersionStatus? versionStatus,
- }) {
- return ServerInfo(
- serverVersion: serverVersion ?? this.serverVersion,
- latestVersion: latestVersion ?? this.latestVersion,
- serverFeatures: serverFeatures ?? this.serverFeatures,
- serverConfig: serverConfig ?? this.serverConfig,
- serverDiskInfo: serverDiskInfo ?? this.serverDiskInfo,
- versionStatus: versionStatus ?? this.versionStatus,
- );
- }
-
- @override
- String toString() {
- return 'ServerInfo(serverVersion: $serverVersion, latestVersion: $latestVersion, serverFeatures: $serverFeatures, serverConfig: $serverConfig, serverDiskInfo: $serverDiskInfo, versionStatus: $versionStatus)';
- }
-
- @override
- bool operator ==(Object other) {
- if (identical(this, other)) {
- return true;
- }
-
- return other is ServerInfo &&
- other.serverVersion == serverVersion &&
- other.latestVersion == latestVersion &&
- other.serverFeatures == serverFeatures &&
- other.serverConfig == serverConfig &&
- other.serverDiskInfo == serverDiskInfo &&
- other.versionStatus == versionStatus;
- }
-
- @override
- int get hashCode {
- return serverVersion.hashCode ^
- latestVersion.hashCode ^
- serverFeatures.hashCode ^
- serverConfig.hashCode ^
- serverDiskInfo.hashCode ^
- versionStatus.hashCode;
- }
+ required ServerFeatures serverFeatures,
+ required ServerConfig serverConfig,
+ required ServerDiskInfo serverDiskInfo,
+ required VersionStatus versionStatus,
+ }) = _ServerInfo;
}
diff --git a/mobile/lib/models/shared_link/shared_link.model.dart b/mobile/lib/models/shared_link/shared_link.model.dart
index e7b65a96ef..cf96609d3c 100644
--- a/mobile/lib/models/shared_link/shared_link.model.dart
+++ b/mobile/lib/models/shared_link/shared_link.model.dart
@@ -1,119 +1,47 @@
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:openapi/api.dart';
+part 'shared_link.model.freezed.dart';
+
enum SharedLinkSource { album, individual }
-class SharedLink {
- final String id;
- final String title;
- final bool allowDownload;
- final bool allowUpload;
- final String? thumbAssetId;
- final String? description;
- final String? password;
- final DateTime? expiresAt;
- final String key;
- final bool showMetadata;
- final SharedLinkSource type;
- final String? slug;
+@freezed
+abstract class SharedLink with _$SharedLink {
+ const factory SharedLink({
+ required String id,
+ required String title,
+ required bool allowDownload,
+ required bool allowUpload,
+ required String? thumbAssetId,
+ required String? description,
+ required String? password,
+ required DateTime? expiresAt,
+ required String key,
+ required bool showMetadata,
+ required SharedLinkSource type,
+ required String? slug,
+ }) = _SharedLink;
- const SharedLink({
- required this.id,
- required this.title,
- required this.allowDownload,
- required this.allowUpload,
- required this.thumbAssetId,
- required this.description,
- required this.password,
- required this.expiresAt,
- required this.key,
- required this.showMetadata,
- required this.type,
- required this.slug,
- });
-
- SharedLink copyWith({
- String? id,
- String? title,
- String? thumbAssetId,
- bool? allowDownload,
- bool? allowUpload,
- String? description,
- String? password,
- DateTime? expiresAt,
- String? key,
- bool? showMetadata,
- SharedLinkSource? type,
- String? slug,
- }) {
+ factory SharedLink.fromDto(SharedLinkResponseDto dto) {
+ final isAlbum = dto.type == SharedLinkType.ALBUM;
return SharedLink(
- id: id ?? this.id,
- title: title ?? this.title,
- thumbAssetId: thumbAssetId ?? this.thumbAssetId,
- allowDownload: allowDownload ?? this.allowDownload,
- allowUpload: allowUpload ?? this.allowUpload,
- description: description ?? this.description,
- password: password ?? this.password,
- expiresAt: expiresAt ?? this.expiresAt,
- key: key ?? this.key,
- showMetadata: showMetadata ?? this.showMetadata,
- type: type ?? this.type,
- slug: slug ?? this.slug,
- );
- }
-
- SharedLink.fromDto(SharedLinkResponseDto dto)
- : id = dto.id,
- allowDownload = dto.allowDownload,
- allowUpload = dto.allowUpload,
- description = dto.description,
- password = dto.password,
- expiresAt = dto.expiresAt,
- key = dto.key,
- showMetadata = dto.showMetadata,
- slug = dto.slug,
- type = dto.type == SharedLinkType.ALBUM ? SharedLinkSource.album : SharedLinkSource.individual,
- title = dto.type == SharedLinkType.ALBUM
- ? dto.album.orElse(null)?.albumName.toUpperCase() ?? "UNKNOWN SHARE"
- : "INDIVIDUAL SHARE",
- thumbAssetId = dto.type == SharedLinkType.ALBUM
+ id: dto.id,
+ allowDownload: dto.allowDownload,
+ allowUpload: dto.allowUpload,
+ description: dto.description,
+ password: dto.password,
+ expiresAt: dto.expiresAt,
+ key: dto.key,
+ showMetadata: dto.showMetadata,
+ slug: dto.slug,
+ type: isAlbum ? SharedLinkSource.album : SharedLinkSource.individual,
+ title: isAlbum ? dto.album.orElse(null)?.albumName.toUpperCase() ?? "UNKNOWN SHARE" : "INDIVIDUAL SHARE",
+ thumbAssetId: isAlbum
? dto.album.orElse(null)?.albumThumbnailAssetId
: dto.assets.isNotEmpty
? dto.assets[0].id
- : null;
-
- @override
- String toString() =>
- 'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type, slug=$slug)';
-
- @override
- bool operator ==(Object other) =>
- identical(this, other) ||
- other is SharedLink &&
- other.id == id &&
- other.title == title &&
- other.thumbAssetId == thumbAssetId &&
- other.allowDownload == allowDownload &&
- other.allowUpload == allowUpload &&
- other.description == description &&
- other.password == password &&
- other.expiresAt == expiresAt &&
- other.key == key &&
- other.showMetadata == showMetadata &&
- other.type == type &&
- other.slug == slug;
-
- @override
- int get hashCode =>
- id.hashCode ^
- title.hashCode ^
- thumbAssetId.hashCode ^
- allowDownload.hashCode ^
- allowUpload.hashCode ^
- description.hashCode ^
- password.hashCode ^
- expiresAt.hashCode ^
- key.hashCode ^
- showMetadata.hashCode ^
- type.hashCode ^
- slug.hashCode;
+ : null,
+ );
+ }
}
diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart
index cc618dbe60..e2472f1063 100644
--- a/mobile/lib/pages/backup/drift_backup.page.dart
+++ b/mobile/lib/pages/backup/drift_backup.page.dart
@@ -81,8 +81,8 @@ class _DriftBackupPageState extends ConsumerState {
final error = ref.watch(driftBackupProvider.select((p) => p.error));
- final backupNotifier = ref.read(driftBackupProvider.notifier);
- final backupSyncManager = ref.read(backgroundSyncProvider);
+ final backupNotifier = ref.watch(driftBackupProvider.notifier);
+ final backupSyncManager = ref.watch(backgroundSyncProvider);
Future startBackup() async {
final currentUser = Store.tryGet(StoreKey.currentUser);
@@ -143,7 +143,7 @@ class _DriftBackupPageState extends ConsumerState {
onStart: () async => await startBackup(),
onStop: () {
syncSuccess = null;
- backupNotifier.stopForegroundBackup();
+ backupNotifier.stopForegroundBackup(reason: "backup button toggled off");
},
),
switch (error) {
@@ -321,7 +321,7 @@ class _BackupAlbumSelectionCard extends ConsumerWidget {
Widget buildSelectedAlbumName() {
String text = "backup_controller_page_backup_selected".tr();
final albums = ref
- .watch(backupAlbumProvider)
+ .read(backupAlbumProvider)
.where((album) => album.backupSelection == BackupSelection.selected)
.toList();
@@ -355,7 +355,7 @@ class _BackupAlbumSelectionCard extends ConsumerWidget {
Widget buildExcludedAlbumName() {
String text = "backup_controller_page_excluded".tr();
final albums = ref
- .watch(backupAlbumProvider)
+ .read(backupAlbumProvider)
.where((album) => album.backupSelection == BackupSelection.excluded)
.toList();
@@ -403,6 +403,10 @@ class _BackupAlbumSelectionCard extends ConsumerWidget {
trailing: ElevatedButton(
onPressed: () async {
await context.pushRoute(const DriftBackupAlbumSelectionRoute());
+ if (!context.mounted) {
+ return;
+ }
+
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
@@ -562,6 +566,11 @@ class _PreparingStatusState extends ConsumerState {
final currentUser = ref.read(currentUserProvider);
if (currentUser != null && mounted) {
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
+ if (!context.mounted) {
+ timer.cancel();
+ _pollingTimer = null;
+ return;
+ }
// Stop polling if processing count reaches 0
final updatedProcessingCount = ref.read(driftBackupProvider.select((p) => p.processingCount));
diff --git a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart
index 7667dbc3f1..de0ad52b5a 100644
--- a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart
+++ b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart
@@ -102,6 +102,9 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState p.totalCount));
final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
final backupNotifier = ref.read(driftBackupProvider.notifier);
@@ -119,7 +126,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState backgroundSync.hashAssets()));
if (isBackupEnabled) {
- backupNotifier.stopForegroundBackup();
+ backupNotifier.stopForegroundBackup(reason: "backup albums updated");
unawaited(
backgroundSync.syncRemote().then((success) {
if (success) {
diff --git a/mobile/lib/pages/backup/drift_backup_options.page.dart b/mobile/lib/pages/backup/drift_backup_options.page.dart
index ef4974d4dd..ccd486e04d 100644
--- a/mobile/lib/pages/backup/drift_backup_options.page.dart
+++ b/mobile/lib/pages/backup/drift_backup_options.page.dart
@@ -19,7 +19,7 @@ class DriftBackupOptionsPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
bool hasPopped = false;
- final previousBackup = ref.read(appConfigProvider).backup;
+ final previousBackup = ref.watch(appConfigProvider.select((s) => s.backup));
final previousCellularForVideos = previousBackup.useCellularForVideos;
final previousCellularForPhotos = previousBackup.useCellularForPhotos;
return PopScope(
@@ -59,7 +59,7 @@ class DriftBackupOptionsPage extends ConsumerWidget {
final backupNotifier = ref.read(driftBackupProvider.notifier);
final backgroundSync = ref.read(backgroundSyncProvider);
- backupNotifier.stopForegroundBackup();
+ backupNotifier.stopForegroundBackup(reason: "backup settings updated");
unawaited(
backgroundSync.syncRemote().then((success) {
if (success) {
diff --git a/mobile/lib/pages/common/app_log.page.dart b/mobile/lib/pages/common/app_log.page.dart
index b04d8dc926..370d6a106d 100644
--- a/mobile/lib/pages/common/app_log.page.dart
+++ b/mobile/lib/pages/common/app_log.page.dart
@@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -13,11 +12,11 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/immich_logger.service.dart';
@RoutePage()
-class AppLogPage extends HookConsumerWidget {
+class AppLogPage extends HookWidget {
const AppLogPage({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final immichLogger = LogService.I;
final shouldReload = useState(false);
final logMessages = useFuture(useMemoized(() => immichLogger.getMessages(), [shouldReload.value]));
diff --git a/mobile/lib/pages/common/app_log_detail.page.dart b/mobile/lib/pages/common/app_log_detail.page.dart
index 52f792df9d..bc3d26a019 100644
--- a/mobile/lib/pages/common/app_log_detail.page.dart
+++ b/mobile/lib/pages/common/app_log_detail.page.dart
@@ -4,18 +4,18 @@ import 'package:auto_route/auto_route.dart';
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:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@RoutePage()
-class AppLogDetailPage extends HookConsumerWidget {
+class AppLogDetailPage extends HookWidget {
const AppLogDetailPage({super.key, required this.logMessage});
final LogMessage logMessage;
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
Padding buildTextWithCopyButton(String header, String text) {
return Padding(
padding: const EdgeInsets.all(8.0),
diff --git a/mobile/lib/pages/common/download_panel.dart b/mobile/lib/pages/common/download_panel.dart
index 267d32fc3d..2015948fad 100644
--- a/mobile/lib/pages/common/download_panel.dart
+++ b/mobile/lib/pages/common/download_panel.dart
@@ -17,7 +17,7 @@ class DownloadPanel extends ConsumerWidget {
final tasks = ref.watch(downloadStateProvider.select((state) => state.taskProgress)).entries.toList();
void onCancelDownload(String id) {
- unawaited(ref.watch(downloadStateProvider.notifier).cancelDownload(id));
+ unawaited(ref.read(downloadStateProvider.notifier).cancelDownload(id));
}
return Positioned(
diff --git a/mobile/lib/pages/common/headers_settings.page.dart b/mobile/lib/pages/common/headers_settings.page.dart
index f0b3f4b67f..e0228eecac 100644
--- a/mobile/lib/pages/common/headers_settings.page.dart
+++ b/mobile/lib/pages/common/headers_settings.page.dart
@@ -21,7 +21,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
final headers = useState>([]);
final setInitialHeaders = useState(false);
- final storedHeaders = ref.read(appConfigProvider).network.customHeaders;
+ final storedHeaders = ref.watch(appConfigProvider.select((s) => s.network.customHeaders));
if (!setInitialHeaders.value) {
storedHeaders.forEach((k, v) {
final header = SettingsHeader();
@@ -93,8 +93,9 @@ class HeaderSettingsPage extends HookConsumerWidget {
headersMap[key] = value;
}
+ final apiService = ref.read(apiServiceProvider);
await ref.read(settingsProvider).write(.networkCustomHeaders, headersMap);
- await ref.read(apiServiceProvider).updateHeaders();
+ await apiService.updateHeaders();
}
}
diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart
index 2c39883b7e..65a78aed8a 100644
--- a/mobile/lib/pages/common/splash_screen.page.dart
+++ b/mobile/lib/pages/common/splash_screen.page.dart
@@ -353,11 +353,11 @@ class SplashScreenPageState extends ConsumerState {
},
onError: (exception) {
log.severe('Failed to update auth info with access token: $accessToken');
- unawaited(ref.read(authProvider.notifier).logout());
if (!mounted) {
return;
}
+ unawaited(ref.read(authProvider.notifier).logout());
unawaited(context.router.replaceAll([const LoginRoute()]));
},
),
diff --git a/mobile/lib/pages/login/change_password.page.dart b/mobile/lib/pages/login/change_password.page.dart
index 248526df1b..051f7f408c 100644
--- a/mobile/lib/pages/login/change_password.page.dart
+++ b/mobile/lib/pages/login/change_password.page.dart
@@ -1,14 +1,11 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/widgets/forms/change_password_form.dart';
@RoutePage()
-class ChangePasswordPage extends HookConsumerWidget {
+class ChangePasswordPage extends StatelessWidget {
const ChangePasswordPage({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
- return const Scaffold(body: ChangePasswordForm());
- }
+ Widget build(BuildContext context) => const Scaffold(body: ChangePasswordForm());
}
diff --git a/mobile/lib/pages/login/login.page.dart b/mobile/lib/pages/login/login.page.dart
index e225c4c066..228fa1cc30 100644
--- a/mobile/lib/pages/login/login.page.dart
+++ b/mobile/lib/pages/login/login.page.dart
@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -11,11 +10,11 @@ import 'package:immich_mobile/widgets/forms/login/login_form.dart';
import 'package:package_info_plus/package_info_plus.dart';
@RoutePage()
-class LoginPage extends HookConsumerWidget {
+class LoginPage extends HookWidget {
const LoginPage({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final appVersion = useState('0.0.0');
Future getAppInfo() async {
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 bb848b24bc..e8a507bfdc 100644
--- a/mobile/lib/pages/search/map/map_location_picker.page.dart
+++ b/mobile/lib/pages/search/map/map_location_picker.page.dart
@@ -5,7 +5,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
-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';
@@ -14,13 +13,13 @@ import 'package:immich_mobile/widgets/map/map_theme_override.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
-class MapLocationPickerPage extends HookConsumerWidget {
+class MapLocationPickerPage extends HookWidget {
final LatLng initialLatLng;
const MapLocationPickerPage({super.key, this.initialLatLng = const LatLng(0, 0)});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final selectedLatLng = useValueNotifier(initialLatLng);
final controller = useRef(null);
final marker = useRef(null);
diff --git a/mobile/lib/presentation/pages/download_info.page.dart b/mobile/lib/presentation/pages/download_info.page.dart
index c2c63c7860..8073d3098c 100644
--- a/mobile/lib/presentation/pages/download_info.page.dart
+++ b/mobile/lib/presentation/pages/download_info.page.dart
@@ -17,7 +17,7 @@ class DownloadInfoPage extends ConsumerWidget {
final tasks = ref.watch(downloadStateProvider.select((state) => state.taskProgress)).entries.toList();
void onCancelDownload(String id) {
- unawaited(ref.watch(downloadStateProvider.notifier).cancelDownload(id));
+ unawaited(ref.read(downloadStateProvider.notifier).cancelDownload(id));
}
return Scaffold(
diff --git a/mobile/lib/presentation/pages/drift_activities.page.dart b/mobile/lib/presentation/pages/drift_activities.page.dart
index ebf7c2efa7..2bb3507e6e 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 activityNotifier = ref.read(albumActivityProvider((album.id, assetId)).notifier);
+ final activityNotifier = ref.watch(albumActivityProvider((album.id, assetId)).notifier);
final activities = ref.watch(albumActivityProvider((album.id, assetId)));
final listViewScrollController = useScrollController();
diff --git a/mobile/lib/presentation/pages/drift_album_options.page.dart b/mobile/lib/presentation/pages/drift_album_options.page.dart
index 9c5161fa8a..7b4c48a6b0 100644
--- a/mobile/lib/presentation/pages/drift_album_options.page.dart
+++ b/mobile/lib/presentation/pages/drift_album_options.page.dart
@@ -75,6 +75,10 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
}
try {
+ if (!context.mounted) {
+ return;
+ }
+
await ref.read(remoteAlbumProvider.notifier).addUsers(album.id, newUsers);
ref.invalidate(remoteAlbumSharedUsersProvider(album.id));
if (!context.mounted) {
@@ -137,7 +141,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
Widget buildOwnerInfo() {
if (isOwner) {
- final owner = ref.watch(currentUserProvider);
+ final owner = ref.read(currentUserProvider);
return ListTile(
leading: owner != null ? UserCircleAvatar(user: owner) : const SizedBox(),
title: Text(album.ownerName, style: const TextStyle(fontWeight: FontWeight.w500)),
@@ -145,7 +149,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
trailing: Text("owner", style: context.textTheme.labelLarge).t(context: context),
);
} else {
- final usersProvider = ref.watch(driftUsersProvider);
+ final usersProvider = ref.read(driftUsersProvider);
return usersProvider.maybeWhen(
data: (users) {
final user = users.firstWhereOrNull((u) => u.id == album.ownerId);
diff --git a/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart b/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart
index a12ca4932b..d256cc2220 100644
--- a/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart
+++ b/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart
@@ -7,12 +7,12 @@ import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@RoutePage()
-class DriftAssetSelectionTimelinePage extends ConsumerWidget {
+class DriftAssetSelectionTimelinePage extends StatelessWidget {
final Set lockedSelectionAssets;
const DriftAssetSelectionTimelinePage({super.key, this.lockedSelectionAssets = const {}});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
return ProviderScope(
overrides: [
multiSelectProvider.overrideWith(
diff --git a/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart b/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart
index d50e4b190e..7e98d25cf0 100644
--- a/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart
+++ b/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart
@@ -10,13 +10,13 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
@RoutePage()
-class AssetTroubleshootPage extends ConsumerWidget {
+class AssetTroubleshootPage extends StatelessWidget {
final BaseAsset asset;
const AssetTroubleshootPage({super.key, required this.asset});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('asset_troubleshoot'.tr())),
body: SingleChildScrollView(
@@ -29,13 +29,13 @@ class AssetTroubleshootPage extends ConsumerWidget {
}
}
-class _AssetDetailsView extends ConsumerWidget {
+class _AssetDetailsView extends StatelessWidget {
final BaseAsset asset;
const _AssetDetailsView({required this.asset});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart
index b2b4d250f1..740de441e7 100644
--- a/mobile/lib/presentation/pages/drift_library.page.dart
+++ b/mobile/lib/presentation/pages/drift_library.page.dart
@@ -21,11 +21,11 @@ import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
-class DriftLibraryPage extends ConsumerWidget {
+class DriftLibraryPage extends StatelessWidget {
const DriftLibraryPage({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
return const Scaffold(
body: CustomScrollView(
slivers: [
diff --git a/mobile/lib/presentation/pages/drift_remote_album.page.dart b/mobile/lib/presentation/pages/drift_remote_album.page.dart
index 5d018561cc..f9e59da0a3 100644
--- a/mobile/lib/presentation/pages/drift_remote_album.page.dart
+++ b/mobile/lib/presentation/pages/drift_remote_album.page.dart
@@ -76,6 +76,10 @@ class _RemoteAlbumPageState extends ConsumerState {
}
try {
+ if (!context.mounted) {
+ return;
+ }
+
await ref.read(remoteAlbumProvider.notifier).addUsers(_album.id, newUsers);
ref.invalidate(remoteAlbumSharedUsersProvider(_album.id));
if (!context.mounted) {
@@ -133,6 +137,10 @@ class _RemoteAlbumPageState extends ConsumerState {
if (confirmed == true) {
try {
+ if (!context.mounted) {
+ return;
+ }
+
await ref.read(remoteAlbumProvider.notifier).deleteAlbum(_album.id);
if (!context.mounted) {
return;
@@ -431,7 +439,7 @@ class _AlbumKebabMenu extends ConsumerWidget {
return FutureBuilder(
future: ref
- .read(remoteAlbumServiceProvider)
+ .watch(remoteAlbumServiceProvider)
.getUserRole(album.id, user?.id ?? '')
.then((role) => role == AlbumUserRole.editor),
builder: (context, snapshot) {
diff --git a/mobile/lib/presentation/pages/drift_slideshow.page.dart b/mobile/lib/presentation/pages/drift_slideshow.page.dart
index 81c09bea67..e6c78796d3 100644
--- a/mobile/lib/presentation/pages/drift_slideshow.page.dart
+++ b/mobile/lib/presentation/pages/drift_slideshow.page.dart
@@ -300,7 +300,7 @@ class _DriftSlideshowPageState extends ConsumerState with Si
borderRadius: BorderRadius.zero,
minHeight: 5,
value:
- ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.position)).inMilliseconds /
+ ref.read(videoPlayerProvider(asset.heroTag).select((s) => s.position)).inMilliseconds /
asset.duration.inMilliseconds,
);
}
@@ -374,7 +374,7 @@ class _DriftSlideshowPageState extends ConsumerState with Si
builder: (context, value, _) => buildPhotoView(scale * (1.0 + value * _kenBurnsZoom)),
);
} else {
- final status = ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.status));
+ final status = ref.read(videoPlayerProvider(asset.heroTag).select((s) => s.status));
final position = ref.read(videoPlayerProvider(asset.heroTag)).position;
if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) {
diff --git a/mobile/lib/presentation/pages/drift_user_selection.page.dart b/mobile/lib/presentation/pages/drift_user_selection.page.dart
index 19a450b435..a145b96a70 100644
--- a/mobile/lib/presentation/pages/drift_user_selection.page.dart
+++ b/mobile/lib/presentation/pages/drift_user_selection.page.dart
@@ -137,7 +137,7 @@ class DriftUserSelectionPage extends HookConsumerWidget {
body: suggestedShareUsers.widgetWhen(
onData: (users) {
// Get shared users for this album from the database
- final sharedUsers = ref.watch(remoteAlbumSharedUsersProvider(album.id));
+ final sharedUsers = ref.read(remoteAlbumSharedUsersProvider(album.id));
return sharedUsers.when(
data: (albumSharedUsers) {
diff --git a/mobile/lib/presentation/pages/edit/drift_edit.page.dart b/mobile/lib/presentation/pages/edit/drift_edit.page.dart
index ec7d8ac3d7..0fe5d28850 100644
--- a/mobile/lib/presentation/pages/edit/drift_edit.page.dart
+++ b/mobile/lib/presentation/pages/edit/drift_edit.page.dart
@@ -220,7 +220,7 @@ class _AspectRatioSelector extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final editorState = ref.watch(editorStateProvider);
- final editorNotifier = ref.read(editorStateProvider.notifier);
+ final editorNotifier = ref.watch(editorStateProvider.notifier);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
@@ -245,7 +245,7 @@ class _TransformControls extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final editorNotifier = ref.read(editorStateProvider.notifier);
+ final editorNotifier = ref.watch(editorStateProvider.notifier);
return Column(
mainAxisSize: MainAxisSize.min,
@@ -333,7 +333,7 @@ class _ResetEditsButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final editorState = ref.watch(editorStateProvider);
- final editorNotifier = ref.read(editorStateProvider.notifier);
+ final editorNotifier = ref.watch(editorStateProvider.notifier);
return ImmichTextButton(
labelText: 'reset'.tr(),
@@ -384,7 +384,7 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi
@override
Widget build(BuildContext context) {
final editorState = ref.watch(editorStateProvider);
- final editorNotifier = ref.read(editorStateProvider.notifier);
+ final editorNotifier = ref.watch(editorStateProvider.notifier);
ref.listen(editorStateProvider, (previous, current) {
// Only re-apply the aspect ratio when it changes, otherwise the crop rect will shrink on every rotation
diff --git a/mobile/lib/presentation/pages/edit/editor.provider.dart b/mobile/lib/presentation/pages/edit/editor.provider.dart
index 3d97f2173f..39cf4cc3ff 100644
--- a/mobile/lib/presentation/pages/edit/editor.provider.dart
+++ b/mobile/lib/presentation/pages/edit/editor.provider.dart
@@ -1,10 +1,14 @@
+import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/aspect_ratios.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/utils/editor.utils.dart';
+part 'editor.provider.freezed.dart';
+
final editorStateProvider = NotifierProvider(EditorProvider.new);
class EditorProvider extends Notifier {
@@ -32,8 +36,8 @@ class EditorProvider extends Notifier {
final transform = normalizeTransformEdits(edits);
state = state.copyWith(
- originalWidth: originalWidth,
- originalHeight: originalHeight,
+ originalWidth: originalWidth ?? state.originalWidth,
+ originalHeight: originalHeight ?? state.originalHeight,
crop: crop,
flipHorizontal: transform.mirrorHorizontal,
flipVertical: transform.mirrorVertical,
@@ -106,104 +110,24 @@ class EditorProvider extends Notifier {
}
}
-class EditorState {
- final bool isApplyingEdits;
+@freezed
+abstract class EditorState with _$EditorState {
+ const EditorState._();
- final int rotationAngle;
- final bool flipHorizontal;
- final bool flipVertical;
- final Rect crop;
- final CropAspectRatio aspectRatio;
-
- final int originalWidth;
- final int originalHeight;
-
- final Duration animationDuration;
-
- final bool hasUnsavedEdits;
-
- const EditorState({
- bool? isApplyingEdits,
- int? rotationAngle,
- bool? flipHorizontal,
- bool? flipVertical,
- Rect? crop,
- CropAspectRatio? aspectRatio,
- int? originalWidth,
- int? originalHeight,
- Duration? animationDuration,
- bool? hasUnsavedEdits,
- }) : isApplyingEdits = isApplyingEdits ?? false,
- rotationAngle = rotationAngle ?? 0,
- flipHorizontal = flipHorizontal ?? false,
- flipVertical = flipVertical ?? false,
- animationDuration = animationDuration ?? Duration.zero,
- originalWidth = originalWidth ?? 0,
- originalHeight = originalHeight ?? 0,
- crop = crop ?? const Rect.fromLTRB(0, 0, 1, 1),
- aspectRatio = aspectRatio ?? CropAspectRatio.free,
- hasUnsavedEdits = hasUnsavedEdits ?? false;
-
- EditorState copyWith({
- bool? isApplyingEdits,
- int? rotationAngle,
- bool? flipHorizontal,
- bool? flipVertical,
- CropAspectRatio? aspectRatio,
- int? originalWidth,
- int? originalHeight,
- Duration? animationDuration,
- Rect? crop,
- bool? hasUnsavedEdits,
- }) {
- return EditorState(
- isApplyingEdits: isApplyingEdits ?? this.isApplyingEdits,
- rotationAngle: rotationAngle ?? this.rotationAngle,
- flipHorizontal: flipHorizontal ?? this.flipHorizontal,
- flipVertical: flipVertical ?? this.flipVertical,
- aspectRatio: aspectRatio ?? this.aspectRatio,
- animationDuration: animationDuration ?? this.animationDuration,
- originalWidth: originalWidth ?? this.originalWidth,
- originalHeight: originalHeight ?? this.originalHeight,
- crop: crop ?? this.crop,
- hasUnsavedEdits: hasUnsavedEdits ?? this.hasUnsavedEdits,
- );
- }
+ const factory EditorState({
+ @Default(false) bool isApplyingEdits,
+ @Default(0) int rotationAngle,
+ @Default(false) bool flipHorizontal,
+ @Default(false) bool flipVertical,
+ @Default(Rect.fromLTRB(0, 0, 1, 1)) Rect crop,
+ @Default(CropAspectRatio.free) CropAspectRatio aspectRatio,
+ @Default(0) int originalWidth,
+ @Default(0) int originalHeight,
+ @Default(Duration.zero) Duration animationDuration,
+ @Default(false) bool hasUnsavedEdits,
+ }) = _EditorState;
bool get hasEdits {
return rotationAngle != 0 || flipHorizontal || flipVertical || crop != const Rect.fromLTRB(0, 0, 1, 1);
}
-
- @override
- bool operator ==(Object other) {
- if (identical(this, other)) {
- return true;
- }
-
- return other is EditorState &&
- other.isApplyingEdits == isApplyingEdits &&
- other.rotationAngle == rotationAngle &&
- other.flipHorizontal == flipHorizontal &&
- other.flipVertical == flipVertical &&
- other.crop == crop &&
- other.aspectRatio == aspectRatio &&
- other.originalWidth == originalWidth &&
- other.originalHeight == originalHeight &&
- other.animationDuration == animationDuration &&
- other.hasUnsavedEdits == hasUnsavedEdits;
- }
-
- @override
- int get hashCode {
- return isApplyingEdits.hashCode ^
- rotationAngle.hashCode ^
- flipHorizontal.hashCode ^
- flipVertical.hashCode ^
- crop.hashCode ^
- aspectRatio.hashCode ^
- originalWidth.hashCode ^
- originalHeight.hashCode ^
- animationDuration.hashCode ^
- hasUnsavedEdits.hashCode;
- }
}
diff --git a/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart b/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart
index a987c9ca29..523781b274 100644
--- a/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart
+++ b/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart
@@ -70,6 +70,10 @@ class _ProfilePictureCropPageState extends ConsumerState
final croppedImage = await _cropController.croppedImage();
final pngBytes = await imageToUint8List(croppedImage);
final xFile = XFile.fromData(pngBytes, mimeType: 'image/png');
+ if (!context.mounted) {
+ return;
+ }
+
final success = await ref
.read(uploadProfileImageProvider.notifier)
.upload(xFile, fileName: 'profile-picture.png');
diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart
index 39588cf051..12935b4f23 100644
--- a/mobile/lib/presentation/pages/search/drift_search.page.dart
+++ b/mobile/lib/presentation/pages/search/drift_search.page.dart
@@ -749,7 +749,7 @@ class _SearchResultGrid extends ConsumerWidget {
}
Widget? _bottomWidget(BuildContext context, WidgetRef ref) {
- final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading));
+ final isLoading = ref.read(paginatedSearchProvider.select((s) => s.isLoading));
if (isLoading) {
return const SliverFillRemaining(
@@ -761,7 +761,7 @@ class _SearchResultGrid extends ConsumerWidget {
);
}
- final hasMore = ref.watch(paginatedSearchProvider.select((s) => s.nextPage != null));
+ final hasMore = ref.read(paginatedSearchProvider.select((s) => s.nextPage != null));
if (hasMore) {
return null;
diff --git a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart
index ee2dace729..bb25fc4739 100644
--- a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart
+++ b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart
@@ -1,8 +1,7 @@
import 'package:flutter/material.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
-class BaseActionButton extends ConsumerWidget {
+class BaseActionButton extends StatelessWidget {
const BaseActionButton({
super.key,
required this.label,
@@ -31,7 +30,7 @@ class BaseActionButton extends ConsumerWidget {
final void Function()? onLongPressed;
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
final iconTheme = IconTheme.of(context);
final iconSize = iconTheme.size ?? 24.0;
diff --git a/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart
index 3bd67978e2..878c589b21 100644
--- a/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart
+++ b/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart
@@ -17,7 +17,7 @@ class MotionPhotoActionButton extends ConsumerWidget {
return BaseActionButton(
iconData: isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded,
label: "play_motion_photo".t(context: context),
- onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle,
+ onPressed: ref.watch(isPlayingMotionVideoProvider.notifier).toggle,
iconOnly: iconOnly,
menuItem: menuItem,
);
diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart
index f30796e406..9babdb5b57 100644
--- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart
+++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart
@@ -669,7 +669,7 @@ class _GridAlbumCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? "");
+ final albumThumbnailAsset = ref.watch(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? "");
return GestureDetector(
onTap: () => onAlbumSelected(album),
@@ -746,7 +746,7 @@ class AddToAlbumHeader extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
Future onCreateAlbum() async {
final albumName = await showDialog(context: context, builder: (context) => const NewAlbumNameModal());
- if (albumName == null) {
+ if (albumName == null || !context.mounted) {
return;
}
diff --git a/mobile/lib/presentation/widgets/album/album_tile.dart b/mobile/lib/presentation/widgets/album/album_tile.dart
index bbf7e11e5a..598666cda9 100644
--- a/mobile/lib/presentation/widgets/album/album_tile.dart
+++ b/mobile/lib/presentation/widgets/album/album_tile.dart
@@ -17,7 +17,7 @@ class AlbumTile extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? "");
+ final albumThumbnailAsset = ref.watch(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? "");
return LargeLeadingTile(
title: Text(
diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart
index e97fa4889b..e92b671562 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart
@@ -72,7 +72,7 @@ class TechnicalDetails extends ConsumerWidget {
final subtitleStyle = context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary);
if (asset is LocalAsset) {
- final assetMediaRepository = ref.watch(assetMediaRepositoryProvider);
+ final assetMediaRepository = ref.read(assetMediaRepositoryProvider);
return FutureBuilder(
future: assetMediaRepository.getOriginalFilename(asset.id),
builder: (context, snapshot) {
diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart
index ce7b75a6ff..271c2d47dc 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart
@@ -401,7 +401,7 @@ class _AssetPageState extends ConsumerState {
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
- final timelineOrigin = ref.read(timelineServiceProvider).origin;
+ final timelineOrigin = ref.watch(timelineServiceProvider).origin;
final showingOcr = ref.watch(assetViewerProvider.select((s) => s.showingOcr));
final asset = timelineOrigin.isDeepLink && currentAsset != null ? currentAsset : _asset;
diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart
index 695937b10b..4925da219f 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart
@@ -17,7 +17,7 @@ class AssetStackRow extends ConsumerWidget {
return const SizedBox.shrink();
}
- final hideAssetStack = ref.read(timelineServiceProvider).origin.isTrash;
+ final hideAssetStack = ref.watch(timelineServiceProvider).origin.isTrash;
if (hideAssetStack) {
return const SizedBox.shrink();
}
diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart
index 3da35c18ea..fd3b18a70f 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart
@@ -22,7 +22,9 @@ import 'package:immich_ui/immich_ui.dart';
List _actionColumnButtons(BuildContext context, WidgetRef ref, List actions) => actions
.map((a) => a.create(context, ref))
.nonNulls
- .map((item) => ImmichColumnButton(icon: item.icon, label: item.label, onPressed: item.onAction))
+ .map(
+ (item) => .new(icon: item.icon, label: item.label, onPressed: item.onAction, onLongPress: item.onSecondaryAction),
+ )
.toList(growable: false);
class ViewerBottomBar extends ConsumerWidget {
@@ -51,7 +53,7 @@ class ViewerBottomBar extends ConsumerWidget {
UploadAction(source: .viewer, showProgress: true),
EditAssetAction(source: .viewer),
]),
- if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
+ if (asset.hasRemote) ImmichColorOverride(color: null, child: AddActionButton(originalTheme: originalTheme)),
],
..._actionColumnButtons(context, ref, const [DeleteAction(source: .viewer)]),
],
@@ -94,7 +96,10 @@ class ViewerBottomBar extends ConsumerWidget {
if (asset.isImage) OcrToggleButton(asset: asset),
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled)
- Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
+ ImmichColorOverride(
+ color: Colors.white,
+ child: Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
+ ),
],
),
),
diff --git a/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart
index 800af23039..08d6b59423 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart
@@ -30,7 +30,7 @@ class MotionPhotoPlayButton extends ConsumerWidget {
child: Center(
child: _MotionButton(
isPlaying: isPlaying,
- onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle,
+ onPressed: ref.watch(isPlayingMotionVideoProvider.notifier).toggle,
),
),
),
diff --git a/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart
index 73c7b2d3e7..cdff727967 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart
@@ -28,7 +28,7 @@ class OcrToggleButton extends ConsumerWidget {
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
- onTap: ref.read(assetViewerProvider.notifier).toggleOcr,
+ onTap: ref.watch(assetViewerProvider.notifier).toggleOcr,
child: const Padding(
padding: EdgeInsets.all(10.0),
child: Icon(Icons.text_fields_rounded, size: 22, color: Colors.white),
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 63b5663d9d..8b3d8a9978 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart
@@ -150,6 +150,10 @@ class _NativeVideoViewerState extends ConsumerState with Widg
final remoteAsset = videoAsset as RemoteAsset;
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
+ if (!context.mounted) {
+ return null;
+ }
+
final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo;
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String assetId = remoteAsset.livePhotoVideoId ?? remoteAsset.id;
diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart
index da0abad1dd..cbbc851108 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart
@@ -29,7 +29,7 @@ class ViewerKebabMenu extends ConsumerWidget {
final user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
- final timelineOrigin = ref.read(timelineServiceProvider).origin;
+ final timelineOrigin = ref.watch(timelineServiceProvider).origin;
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final isInLockedView = ref.watch(inLockedViewProvider);
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
diff --git a/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart
index d7334337ae..3064ced2a4 100644
--- a/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart
+++ b/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/download.action.dart';
@@ -7,11 +6,11 @@ import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
-class LockedFolderBottomSheet extends ConsumerWidget {
+class LockedFolderBottomSheet extends StatelessWidget {
const LockedFolderBottomSheet({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
return const BaseBottomSheet(
initialChildSize: 0.25,
maxChildSize: 0.4,
diff --git a/mobile/lib/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart
index 1d378f1350..b496503dad 100644
--- a/mobile/lib/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart
+++ b/mobile/lib/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart
@@ -1,15 +1,14 @@
import 'package:flutter/material.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/restore.action.dart';
-class TrashBottomBar extends ConsumerWidget {
+class TrashBottomBar extends StatelessWidget {
const TrashBottomBar({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
return Align(
alignment: Alignment.bottomCenter,
child: Container(
diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart
index 927734ca25..aaf3e0dbaf 100644
--- a/mobile/lib/presentation/widgets/images/image_provider.dart
+++ b/mobile/lib/presentation/widgets/images/image_provider.dart
@@ -162,7 +162,15 @@ ImageProvider getFullImageProvider(
provider = FileImage(File(localFilePath));
} else if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
- provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
+ provider = LocalFullImageProvider(
+ id: id,
+ size: size,
+ assetType: asset.type,
+ isAnimated: asset.isAnimatedImage,
+ width: asset.width,
+ height: asset.height,
+ checksum: asset.checksum,
+ );
} else {
final String assetId;
final String thumbhash;
@@ -190,7 +198,7 @@ ImageProvider getFullImageProvider(
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) {
if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
- return LocalThumbProvider(id: id, size: size, assetType: asset.type);
+ return LocalThumbProvider(id: id, size: size, assetType: asset.type, checksum: asset.checksum);
}
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart
index eba4f0a1cd..46e8eda85b 100644
--- a/mobile/lib/presentation/widgets/images/local_image_provider.dart
+++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart
@@ -1,3 +1,5 @@
+import 'dart:math' as math;
+
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -8,13 +10,19 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
+// iOS GPU textures max out at 16384px; larger images squish.
+const _kMaxPixelSize = 16384;
+
class LocalThumbProvider extends CancellableImageProvider
with CancellableImageProviderMixin {
final String id;
final Size size;
final AssetType assetType;
- LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution});
+ // an edit on the device keeps the id and changes the bytes, so the checksum is what separates two renders
+ final String? checksum;
+
+ LocalThumbProvider({required this.id, required this.assetType, this.checksum, this.size = kThumbnailResolution});
@override
Future obtainKey(ImageConfiguration configuration) {
@@ -44,13 +52,13 @@ class LocalThumbProvider extends CancellableImageProvider
return true;
}
if (other is LocalThumbProvider) {
- return id == other.id;
+ return id == other.id && checksum == other.checksum;
}
return false;
}
@override
- int get hashCode => id.hashCode;
+ int get hashCode => Object.hash(id, checksum);
}
class LocalFullImageProvider extends CancellableImageProvider
@@ -59,8 +67,38 @@ class LocalFullImageProvider extends CancellableImageProvider
+ previewTargetSize(size.width * dpr, size.height * dpr, width, height, previewIsFinal: previewIsFinal);
+
+ // Use an aspect-correct target when aspectFill would exceed the texture limit.
+ @visibleForTesting
+ static Size previewTargetSize(double boxW, double boxH, int? width, int? height, {required bool previewIsFinal}) {
+ if (width == null || height == null || width <= 0 || height <= 0) {
+ return Size(boxW, boxH);
+ }
+ final imgLong = math.max(width, height).toDouble();
+ final coverLong = imgLong * math.max(boxW / width, boxH / height);
+ if (coverLong <= _kMaxPixelSize) {
+ return Size(boxW, boxH);
+ }
+ final bound = previewIsFinal ? _kMaxPixelSize.toDouble() : math.max(boxW, boxH);
+ final scale = math.min(1.0, bound / imgLong);
+ return Size(math.max(1.0, width * scale), math.max(1.0, height * scale));
+ }
@override
Future obtainKey(ImageConfiguration configuration) {
@@ -73,7 +111,7 @@ class LocalFullImageProvider extends CancellableImageProvider [
DiagnosticsProperty('Image provider', this),
DiagnosticsProperty('Id', key.id),
@@ -86,7 +124,7 @@ class LocalFullImageProvider extends CancellableImageProvider [
DiagnosticsProperty('Image provider', this),
DiagnosticsProperty('Id', key.id),
@@ -108,7 +146,7 @@ class LocalFullImageProvider extends CancellableImageProvider id.hashCode ^ size.hashCode ^ isAnimated.hashCode;
+ int get hashCode => Object.hash(id, size, isAnimated, width, height, checksum);
}
diff --git a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart
index b0b7816889..94a91437b0 100644
--- a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart
+++ b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart
@@ -45,13 +45,13 @@ class DriftMemoryLane extends ConsumerWidget {
}
}
-class DriftMemoryCard extends ConsumerWidget {
+class DriftMemoryCard extends StatelessWidget {
const DriftMemoryCard({super.key, required this.memory});
final DriftMemory memory;
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final yearsAgo = DateTime.now().year - memory.data.year;
final title = 'years_ago'.t(context: context, args: {'years': yearsAgo.toString()});
return Center(
diff --git a/mobile/lib/presentation/widgets/people/person_option_sheet.widget.dart b/mobile/lib/presentation/widgets/people/person_option_sheet.widget.dart
index b0ded02624..ac2d4580c9 100644
--- a/mobile/lib/presentation/widgets/people/person_option_sheet.widget.dart
+++ b/mobile/lib/presentation/widgets/people/person_option_sheet.widget.dart
@@ -1,8 +1,7 @@
import 'package:flutter/material.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
-class PersonOptionSheet extends ConsumerWidget {
+class PersonOptionSheet extends StatelessWidget {
const PersonOptionSheet({super.key, this.onEditName, this.onEditBirthday, this.birthdayExists = false});
final VoidCallback? onEditName;
@@ -10,7 +9,7 @@ class PersonOptionSheet extends ConsumerWidget {
final bool birthdayExists;
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final TextStyle textStyle = Theme.of(context).textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w600);
return SafeArea(
diff --git a/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart b/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart
index 355e1a01a8..cd94ef89a8 100644
--- a/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart
+++ b/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart
@@ -1,10 +1,9 @@
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/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
-class DriftRemoteAlbumOption extends ConsumerWidget {
+class DriftRemoteAlbumOption extends StatelessWidget {
const DriftRemoteAlbumOption({
super.key,
this.onAddPhotos,
@@ -31,7 +30,7 @@ class DriftRemoteAlbumOption extends ConsumerWidget {
final List? iconShadows;
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final theme = context.themeData;
final menuChildren = [];
diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart
index 16c947ca5e..b108acde3b 100644
--- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart
+++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart
@@ -107,7 +107,7 @@ class _FixedSegmentRow extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
- final timelineService = ref.read(timelineServiceProvider);
+ final timelineService = ref.watch(timelineServiceProvider);
final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3);
if (timelineService.hasRange(assetIndex, assetCount)) {
@@ -261,7 +261,7 @@ class _AssetTileWidget extends ConsumerWidget {
final lockSelection = _getLockSelectionStatus(ref);
final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator));
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
- final showStackIndicator = ref.read(timelineServiceProvider).origin != TimelineOrigin.trash;
+ final showStackIndicator = ref.watch(timelineServiceProvider).origin != TimelineOrigin.trash;
return RepaintBoundary(
child: GestureDetector(
diff --git a/mobile/lib/presentation/widgets/timeline/header.widget.dart b/mobile/lib/presentation/widgets/timeline/header.widget.dart
index 76176041fa..433118d13c 100644
--- a/mobile/lib/presentation/widgets/timeline/header.widget.dart
+++ b/mobile/lib/presentation/widgets/timeline/header.widget.dart
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
@@ -12,7 +13,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
-class TimelineHeader extends HookConsumerWidget {
+class TimelineHeader extends HookWidget {
final Bucket bucket;
final HeaderType header;
final double height;
@@ -39,7 +40,7 @@ class TimelineHeader extends HookConsumerWidget {
}
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
if (bucket is! TimeBucket || header == HeaderType.none) {
return const SizedBox.shrink();
}
diff --git a/mobile/lib/presentation/widgets/timeline/timeline.state.dart b/mobile/lib/presentation/widgets/timeline/timeline.state.dart
index b39c431b43..bcdc3b4783 100644
--- a/mobile/lib/presentation/widgets/timeline/timeline.state.dart
+++ b/mobile/lib/presentation/widgets/timeline/timeline.state.dart
@@ -1,5 +1,7 @@
import 'dart:math' as math;
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
@@ -8,6 +10,8 @@ import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
+part 'timeline.state.freezed.dart';
+
class TimelineArgs {
final double maxWidth;
final double maxHeight;
@@ -49,25 +53,13 @@ class TimelineArgs {
groupBy.hashCode;
}
-class TimelineState {
- final bool isScrubbing;
- final bool isScrolling;
+@freezed
+abstract class TimelineState with _$TimelineState {
+ const TimelineState._();
- const TimelineState({this.isScrubbing = false, this.isScrolling = false});
+ const factory TimelineState({@Default(false) bool isScrubbing, @Default(false) bool isScrolling}) = _TimelineState;
bool get isInteracting => isScrubbing || isScrolling;
-
- @override
- bool operator ==(covariant TimelineState other) {
- return isScrubbing == other.isScrubbing && isScrolling == other.isScrolling;
- }
-
- @override
- int get hashCode => isScrubbing.hashCode ^ isScrolling.hashCode;
-
- TimelineState copyWith({bool? isScrubbing, bool? isScrolling}) {
- return TimelineState(isScrubbing: isScrubbing ?? this.isScrubbing, isScrolling: isScrolling ?? this.isScrolling);
- }
}
class TimelineStateNotifier extends Notifier {
diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart
index 8678f7c32e..7dc475ab93 100644
--- a/mobile/lib/providers/app_life_cycle.provider.dart
+++ b/mobile/lib/providers/app_life_cycle.provider.dart
@@ -81,6 +81,10 @@ class AppLifeCycleNotifier extends StateNotifier {
await _ref.read(serverInfoProvider.notifier).getServerVersion();
}
+ if (!_shouldContinueOperation()) {
+ _wasPaused = true;
+ return;
+ }
_ref.read(websocketProvider.notifier).connect();
await _handleBetaTimelineResume();
@@ -89,13 +93,13 @@ class AppLifeCycleNotifier extends StateNotifier {
await _ref.read(galleryPermissionNotifier.notifier).getGalleryPermissionStatus();
}
- Future _safeRun(Future action, String debugName) async {
+ Future _safeRun(Future Function() action, String debugName) async {
if (!_shouldContinueOperation()) {
return;
}
try {
- await action;
+ await action();
} catch (e, stackTrace) {
_log.warning("Error during $debugName operation", e, stackTrace);
}
@@ -113,13 +117,15 @@ class AppLifeCycleNotifier extends StateNotifier {
try {
bool syncSuccess = false;
await Future.wait([
- _safeRun(backgroundManager.syncLocal(full: CurrentPlatform.isAndroid ? true : false), "syncLocal"),
- _safeRun(backgroundManager.syncRemote().then((success) => syncSuccess = success), "syncRemote"),
+ _safeRun(() => backgroundManager.syncLocal(full: CurrentPlatform.isAndroid), "syncLocal"),
+ _safeRun(() async {
+ syncSuccess = await backgroundManager.syncRemote();
+ }, "syncRemote"),
]);
_ref.invalidate(driftMemoryFutureProvider);
if (syncSuccess) {
await Future.wait([
- _safeRun(backgroundManager.hashAssets(), "hashAssets").then((_) {
+ _safeRun(backgroundManager.hashAssets, "hashAssets").then((_) {
unawaited(_resumeBackup());
}),
_resumeBackup(),
@@ -127,11 +133,11 @@ class AppLifeCycleNotifier extends StateNotifier {
// _safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
]);
} else {
- await _safeRun(backgroundManager.hashAssets(), "hashAssets");
+ await _safeRun(backgroundManager.hashAssets, "hashAssets");
}
if (isAlbumLinkedSyncEnable) {
- await _safeRun(backgroundManager.syncLinkedAlbum(), "syncLinkedAlbum");
+ await _safeRun(backgroundManager.syncLinkedAlbum, "syncLinkedAlbum");
}
} catch (e, stackTrace) {
_log.severe("Error during background sync", e, stackTrace);
@@ -145,7 +151,7 @@ class AppLifeCycleNotifier extends StateNotifier {
final currentUser = Store.tryGet(StoreKey.currentUser);
if (currentUser != null) {
await _safeRun(
- _ref.read(driftBackupProvider.notifier).startForegroundBackup(currentUser.id),
+ () => _ref.read(driftBackupProvider.notifier).startForegroundBackup(currentUser.id),
"handleBackupResume",
);
}
@@ -195,7 +201,7 @@ class AppLifeCycleNotifier extends StateNotifier {
Future _performPause() {
if (_ref.read(authProvider).isAuthenticated) {
- _ref.read(driftBackupProvider.notifier).stopForegroundBackup();
+ _ref.read(driftBackupProvider.notifier).stopForegroundBackup(reason: "the app being sent to the background");
_ref.read(websocketProvider.notifier).disconnect();
}
diff --git a/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart
index 7b1d5d2caa..fd2a6aebd9 100644
--- a/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart
+++ b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart
@@ -1,81 +1,25 @@
import 'dart:async';
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
-class AssetViewerState {
- final double backgroundOpacity;
- final bool showingDetails;
- final bool showingControls;
- final bool isZoomed;
- final bool showingOcr;
- final BaseAsset? currentAsset;
- final int stackIndex;
+part 'asset_viewer.provider.freezed.dart';
- const AssetViewerState({
- this.backgroundOpacity = 1.0,
- this.showingDetails = false,
- this.showingControls = true,
- this.isZoomed = false,
- this.showingOcr = false,
- this.currentAsset,
- this.stackIndex = 0,
- });
-
- AssetViewerState copyWith({
- double? backgroundOpacity,
- bool? showingDetails,
- bool? showingControls,
- bool? isZoomed,
- bool? showingOcr,
+@freezed
+abstract class AssetViewerState with _$AssetViewerState {
+ const factory AssetViewerState({
+ @Default(1.0) double backgroundOpacity,
+ @Default(false) bool showingDetails,
+ @Default(true) bool showingControls,
+ @Default(false) bool isZoomed,
+ @Default(false) bool showingOcr,
BaseAsset? currentAsset,
- int? stackIndex,
- }) {
- return AssetViewerState(
- backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity,
- showingDetails: showingDetails ?? this.showingDetails,
- showingControls: showingControls ?? this.showingControls,
- isZoomed: isZoomed ?? this.isZoomed,
- showingOcr: showingOcr ?? this.showingOcr,
- currentAsset: currentAsset ?? this.currentAsset,
- stackIndex: stackIndex ?? this.stackIndex,
- );
- }
-
- @override
- String toString() {
- return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed, showingOcr: $showingOcr)';
- }
-
- @override
- bool operator ==(Object other) {
- if (identical(this, other)) {
- return true;
- }
- if (other.runtimeType != runtimeType) {
- return false;
- }
- return other is AssetViewerState &&
- other.backgroundOpacity == backgroundOpacity &&
- other.showingDetails == showingDetails &&
- other.showingControls == showingControls &&
- other.isZoomed == isZoomed &&
- other.showingOcr == showingOcr &&
- other.currentAsset == currentAsset &&
- other.stackIndex == stackIndex;
- }
-
- @override
- int get hashCode =>
- backgroundOpacity.hashCode ^
- showingDetails.hashCode ^
- showingControls.hashCode ^
- isZoomed.hashCode ^
- showingOcr.hashCode ^
- currentAsset.hashCode ^
- stackIndex.hashCode;
+ @Default(0) int stackIndex,
+ }) = _AssetViewerState;
}
class AssetViewerStateNotifier extends Notifier {
diff --git a/mobile/lib/providers/background_sync.provider.dart b/mobile/lib/providers/background_sync.provider.dart
index 37b3145eb4..5873e992b1 100644
--- a/mobile/lib/providers/background_sync.provider.dart
+++ b/mobile/lib/providers/background_sync.provider.dart
@@ -9,6 +9,7 @@ final backgroundSyncProvider = Provider((ref) {
final manager = BackgroundSyncManager(
onRemoteSyncStart: () {
syncStatusNotifier.startRemoteSync();
+ // ignore: dispose-provided-instances
final backupProvider = ref.read(driftBackupProvider.notifier);
if (backupProvider.mounted) {
backupProvider.updateError(BackupError.none);
@@ -16,6 +17,7 @@ final backgroundSyncProvider = Provider((ref) {
},
onRemoteSyncComplete: (isSuccess) {
syncStatusNotifier.completeRemoteSync();
+ // ignore: dispose-provided-instances
final backupProvider = ref.read(driftBackupProvider.notifier);
if (backupProvider.mounted) {
backupProvider.updateError(isSuccess == true ? BackupError.none : BackupError.syncFailed);
diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart
index b43fec023c..1756d0fd30 100644
--- a/mobile/lib/providers/backup/drift_backup.provider.dart
+++ b/mobile/lib/providers/backup/drift_backup.provider.dart
@@ -1,6 +1,7 @@
import 'dart:async';
-import 'package:collection/collection.dart';
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
@@ -12,6 +13,8 @@ import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/upload_speed_calculator.dart';
import 'package:logging/logging.dart';
+part 'drift_backup.provider.freezed.dart';
+
class EnqueueStatus {
final int enqueueCount;
final int totalCount;
@@ -26,75 +29,17 @@ class EnqueueStatus {
String toString() => 'EnqueueStatus(enqueueCount: $enqueueCount, totalCount: $totalCount)';
}
-class DriftUploadStatus {
- final String taskId;
- final String filename;
- final double progress;
- final int fileSize;
- final String networkSpeedAsString;
- final bool? isFailed;
- final String? error;
-
- const DriftUploadStatus({
- required this.taskId,
- required this.filename,
- required this.progress,
- required this.fileSize,
- required this.networkSpeedAsString,
- this.isFailed,
- this.error,
- });
-
- DriftUploadStatus copyWith({
- String? taskId,
- String? filename,
- double? progress,
- int? fileSize,
- String? networkSpeedAsString,
+@freezed
+abstract class DriftUploadStatus with _$DriftUploadStatus {
+ const factory DriftUploadStatus({
+ required String taskId,
+ required String filename,
+ required double progress,
+ required int fileSize,
+ required String networkSpeedAsString,
bool? isFailed,
String? error,
- }) {
- return DriftUploadStatus(
- taskId: taskId ?? this.taskId,
- filename: filename ?? this.filename,
- progress: progress ?? this.progress,
- fileSize: fileSize ?? this.fileSize,
- networkSpeedAsString: networkSpeedAsString ?? this.networkSpeedAsString,
- isFailed: isFailed ?? this.isFailed,
- error: error ?? this.error,
- );
- }
-
- @override
- String toString() {
- return 'DriftUploadStatus(taskId: $taskId, filename: $filename, progress: $progress, fileSize: $fileSize, networkSpeedAsString: $networkSpeedAsString, isFailed: $isFailed, error: $error)';
- }
-
- @override
- bool operator ==(covariant DriftUploadStatus other) {
- if (identical(this, other)) {
- return true;
- }
-
- return other.taskId == taskId &&
- other.filename == filename &&
- other.progress == progress &&
- other.fileSize == fileSize &&
- other.networkSpeedAsString == networkSpeedAsString &&
- other.isFailed == isFailed &&
- other.error == error;
- }
-
- @override
- int get hashCode {
- return taskId.hashCode ^
- filename.hashCode ^
- progress.hashCode ^
- fileSize.hashCode ^
- networkSpeedAsString.hashCode ^
- isFailed.hashCode ^
- error.hashCode;
- }
+ }) = _DriftUploadStatus;
}
enum BackupError { none, syncFailed }
@@ -255,19 +200,25 @@ class DriftBackupNotifier extends StateNotifier {
state = state.copyWith(isSyncing: isSyncing);
}
- Future startForegroundBackup(String userId) {
+ Future startForegroundBackup(String userId) async {
// Cancel any existing backup before starting a new one
if (_cancelToken != null) {
- stopForegroundBackup();
+ stopForegroundBackup(reason: "restarting the backup");
}
state = state.copyWith(error: BackupError.none);
- _cancelToken = Completer();
+ // A pause during the recount below nulls _cancelToken, so the run keeps its own reference.
+ final cancelToken = Completer();
+ _cancelToken = cancelToken;
+
+ // Re-baseline the counters against the same DB read that feeds this run's candidate list,
+ // otherwise a resume counts duplicate successes against the old baseline (#26215).
+ await getBackupStatus(userId);
return _foregroundUploadService.uploadCandidates(
userId,
- _cancelToken!,
+ cancelToken,
callbacks: UploadCallbacks(
onProgress: _handleForegroundBackupProgress,
onSuccess: _handleForegroundBackupSuccess,
@@ -277,7 +228,10 @@ class DriftBackupNotifier extends StateNotifier {
);
}
- void stopForegroundBackup() {
+ void stopForegroundBackup({required String reason}) {
+ if (_cancelToken != null) {
+ _logger.info("Foreground backup cancelled: $reason");
+ }
_cancelToken?.complete();
_cancelToken = null;
_uploadSpeedManager.clear();
@@ -333,6 +287,10 @@ class DriftBackupNotifier extends StateNotifier {
}
void _handleForegroundBackupSuccess(String localAssetId, String remoteAssetId) {
+ if (!mounted) {
+ _logger.warning("Skip _handleForegroundBackupSuccess: notifier disposed");
+ return;
+ }
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1);
_uploadSpeedManager.removeTask(localAssetId);
diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart
index df62a1a39d..a8dc642771 100644
--- a/mobile/lib/providers/infrastructure/action.provider.dart
+++ b/mobile/lib/providers/infrastructure/action.provider.dart
@@ -1,3 +1,5 @@
+// ignore_for_file: use-ref-and-state-synchronously
+
import 'dart:async';
import 'package:auto_route/auto_route.dart';
diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart
index b392f5f401..4457b9f831 100644
--- a/mobile/lib/providers/infrastructure/remote_album.provider.dart
+++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart
@@ -1,3 +1,5 @@
+// ignore_for_file: use-ref-and-state-synchronously
+
import 'dart:async';
import 'package:collection/collection.dart';
diff --git a/mobile/lib/providers/infrastructure/setting.provider.dart b/mobile/lib/providers/infrastructure/setting.provider.dart
index 7d8be72cd0..9e33017cab 100644
--- a/mobile/lib/providers/infrastructure/setting.provider.dart
+++ b/mobile/lib/providers/infrastructure/setting.provider.dart
@@ -1,3 +1,5 @@
+// ignore_for_file: use-ref-and-state-synchronously
+
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
diff --git a/mobile/lib/providers/infrastructure/tag.provider.dart b/mobile/lib/providers/infrastructure/tag.provider.dart
index 2d527768c5..152582aa93 100644
--- a/mobile/lib/providers/infrastructure/tag.provider.dart
+++ b/mobile/lib/providers/infrastructure/tag.provider.dart
@@ -1,3 +1,5 @@
+// ignore_for_file: use-ref-and-state-synchronously
+
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
diff --git a/mobile/lib/providers/permission.provider.dart b/mobile/lib/providers/permission.provider.dart
index dc82285122..bd4dc7b61e 100644
--- a/mobile/lib/providers/permission.provider.dart
+++ b/mobile/lib/providers/permission.provider.dart
@@ -1,3 +1,5 @@
+// ignore_for_file: use-ref-and-state-synchronously
+
import 'dart:async';
import 'dart:io';
diff --git a/mobile/lib/providers/routes.provider.dart b/mobile/lib/providers/routes.provider.dart
index 6cc3d08c5d..776389b330 100644
--- a/mobile/lib/providers/routes.provider.dart
+++ b/mobile/lib/providers/routes.provider.dart
@@ -5,9 +5,6 @@ import 'package:immich_mobile/routing/router.dart';
@visibleForTesting
bool isRouteInStack(Ref ref, String routeName) {
final router = ref.watch(appRouterProvider);
- void onChange() => ref.invalidateSelf();
- router.addListener(onChange);
- ref.onDispose(() => router.removeListener(onChange));
return router.stackData.any((route) => route.name == routeName);
}
diff --git a/mobile/lib/providers/search/search_input_focus.provider.dart b/mobile/lib/providers/search/search_input_focus.provider.dart
index 4f6ed41ee0..4ebbfc6553 100644
--- a/mobile/lib/providers/search/search_input_focus.provider.dart
+++ b/mobile/lib/providers/search/search_input_focus.provider.dart
@@ -2,5 +2,7 @@ import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
final searchInputFocusProvider = Provider((ref) {
- return FocusNode();
+ final focusNode = FocusNode();
+ ref.onDispose(focusNode.dispose);
+ return focusNode;
});
diff --git a/mobile/lib/providers/server_info.provider.dart b/mobile/lib/providers/server_info.provider.dart
index c25e496a04..79593cce95 100644
--- a/mobile/lib/providers/server_info.provider.dart
+++ b/mobile/lib/providers/server_info.provider.dart
@@ -59,7 +59,7 @@ class ServerInfoNotifier extends StateNotifier {
}
Future _checkServerVersionMismatch(ServerVersion serverVersion, {ServerVersion? latestVersion}) async {
- state = state.copyWith(serverVersion: serverVersion, latestVersion: latestVersion);
+ state = state.copyWith(serverVersion: serverVersion, latestVersion: latestVersion ?? state.latestVersion);
final packageInfo = await PackageInfo.fromPlatform();
final SemVer clientVersion = SemVer.fromString(packageInfo.version);
diff --git a/mobile/lib/providers/sync_status.provider.dart b/mobile/lib/providers/sync_status.provider.dart
index b7d4f8bdc3..7a70514ca7 100644
--- a/mobile/lib/providers/sync_status.provider.dart
+++ b/mobile/lib/providers/sync_status.provider.dart
@@ -1,6 +1,10 @@
import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+part 'sync_status.provider.freezed.dart';
+
enum SyncStatus {
idle,
syncing,
@@ -17,58 +21,22 @@ enum SyncStatus {
}
}
-class SyncStatusState {
- final SyncStatus remoteSyncStatus;
- final SyncStatus localSyncStatus;
- final SyncStatus hashJobStatus;
- final SyncStatus cloudIdSyncStatus;
+@freezed
+abstract class SyncStatusState with _$SyncStatusState {
+ const SyncStatusState._();
- final String? errorMessage;
-
- const SyncStatusState({
- this.remoteSyncStatus = SyncStatus.idle,
- this.localSyncStatus = SyncStatus.idle,
- this.hashJobStatus = SyncStatus.idle,
- this.cloudIdSyncStatus = SyncStatus.idle,
- this.errorMessage,
- });
-
- SyncStatusState copyWith({
- SyncStatus? remoteSyncStatus,
- SyncStatus? localSyncStatus,
- SyncStatus? hashJobStatus,
- SyncStatus? cloudIdSyncStatus,
+ const factory SyncStatusState({
+ @Default(SyncStatus.idle) SyncStatus remoteSyncStatus,
+ @Default(SyncStatus.idle) SyncStatus localSyncStatus,
+ @Default(SyncStatus.idle) SyncStatus hashJobStatus,
+ @Default(SyncStatus.idle) SyncStatus cloudIdSyncStatus,
String? errorMessage,
- }) {
- return SyncStatusState(
- remoteSyncStatus: remoteSyncStatus ?? this.remoteSyncStatus,
- localSyncStatus: localSyncStatus ?? this.localSyncStatus,
- hashJobStatus: hashJobStatus ?? this.hashJobStatus,
- cloudIdSyncStatus: cloudIdSyncStatus ?? this.cloudIdSyncStatus,
- errorMessage: errorMessage ?? this.errorMessage,
- );
- }
+ }) = _SyncStatusState;
bool get isRemoteSyncing => remoteSyncStatus == SyncStatus.syncing;
bool get isLocalSyncing => localSyncStatus == SyncStatus.syncing;
bool get isHashing => hashJobStatus == SyncStatus.syncing;
bool get isCloudIdSyncing => cloudIdSyncStatus == SyncStatus.syncing;
-
- @override
- bool operator ==(Object other) {
- if (identical(this, other)) {
- return true;
- }
- return other is SyncStatusState &&
- other.remoteSyncStatus == remoteSyncStatus &&
- other.localSyncStatus == localSyncStatus &&
- other.hashJobStatus == hashJobStatus &&
- other.cloudIdSyncStatus == cloudIdSyncStatus &&
- other.errorMessage == errorMessage;
- }
-
- @override
- int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, cloudIdSyncStatus, errorMessage);
}
class SyncStatusNotifier extends Notifier {
@@ -88,7 +56,11 @@ class SyncStatusNotifier extends Notifier {
///
void setRemoteSyncStatus(SyncStatus status, [String? errorMessage]) {
- state = state.copyWith(remoteSyncStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null);
+ // TODO(agg23): These error messages probably should be cleared, not preserved on null
+ state = state.copyWith(
+ remoteSyncStatus: status,
+ errorMessage: (status == SyncStatus.error ? errorMessage : null) ?? state.errorMessage,
+ );
}
void startRemoteSync() => setRemoteSyncStatus(SyncStatus.syncing);
@@ -100,7 +72,11 @@ class SyncStatusNotifier extends Notifier {
///
void setLocalSyncStatus(SyncStatus status, [String? errorMessage]) {
- state = state.copyWith(localSyncStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null);
+ // TODO(agg23): These error messages probably should be cleared, not preserved on null
+ state = state.copyWith(
+ localSyncStatus: status,
+ errorMessage: (status == SyncStatus.error ? errorMessage : null) ?? state.errorMessage,
+ );
}
void startLocalSync() => setLocalSyncStatus(SyncStatus.syncing);
@@ -112,7 +88,11 @@ class SyncStatusNotifier extends Notifier {
///
void setHashJobStatus(SyncStatus status, [String? errorMessage]) {
- state = state.copyWith(hashJobStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null);
+ // TODO(agg23): These error messages probably should be cleared, not preserved on null
+ state = state.copyWith(
+ hashJobStatus: status,
+ errorMessage: (status == SyncStatus.error ? errorMessage : null) ?? state.errorMessage,
+ );
}
void startHashJob() => setHashJobStatus(SyncStatus.syncing);
@@ -124,7 +104,11 @@ class SyncStatusNotifier extends Notifier {
///
void setCloudIdSyncStatus(SyncStatus status, [String? errorMessage]) {
- state = state.copyWith(cloudIdSyncStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null);
+ // TODO(agg23): These error messages probably should be cleared, not preserved on null
+ state = state.copyWith(
+ cloudIdSyncStatus: status,
+ errorMessage: (status == SyncStatus.error ? errorMessage : null) ?? state.errorMessage,
+ );
}
void startCloudIdSync() => setCloudIdSyncStatus(SyncStatus.syncing);
diff --git a/mobile/lib/providers/timeline/multiselect.provider.dart b/mobile/lib/providers/timeline/multiselect.provider.dart
index 64edd4cd3f..36de8d5e4f 100644
--- a/mobile/lib/providers/timeline/multiselect.provider.dart
+++ b/mobile/lib/providers/timeline/multiselect.provider.dart
@@ -1,3 +1,5 @@
+// ignore_for_file: use-ref-and-state-synchronously
+
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart
index c6ed09360a..32531c354b 100644
--- a/mobile/lib/providers/websocket.provider.dart
+++ b/mobile/lib/providers/websocket.provider.dart
@@ -1,5 +1,7 @@
import 'dart:async';
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@@ -15,30 +17,11 @@ import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:socket_io_client/socket_io_client.dart';
-class WebsocketState {
- final Socket? socket;
- final bool isConnected;
+part 'websocket.provider.freezed.dart';
- const WebsocketState({this.socket, required this.isConnected});
-
- WebsocketState copyWith({Socket? socket, bool? isConnected}) {
- return WebsocketState(socket: socket ?? this.socket, isConnected: isConnected ?? this.isConnected);
- }
-
- @override
- String toString() => 'WebsocketState(socket: $socket, isConnected: $isConnected)';
-
- @override
- bool operator ==(Object other) {
- if (identical(this, other)) {
- return true;
- }
-
- return other is WebsocketState && other.socket == socket && other.isConnected == isConnected;
- }
-
- @override
- int get hashCode => socket.hashCode ^ isConnected.hashCode;
+@freezed
+abstract class WebsocketState with _$WebsocketState {
+ const factory WebsocketState({Socket? socket, required bool isConnected}) = _WebsocketState;
}
class WebsocketNotifier extends StateNotifier {
@@ -56,14 +39,16 @@ class WebsocketNotifier extends StateNotifier {
@override
void dispose() {
_batchDebouncer.dispose();
+ state.socket?.dispose();
super.dispose();
}
- /// Connects websocket to server unless already connected
+ /// Connects websocket to server unless an active socket already exists
void connect() {
- if (state.isConnected) {
+ if (state.socket?.active == true) {
return;
}
+ state.socket?.dispose();
final authenticationState = _ref.read(authProvider);
if (authenticationState.isAuthenticated) {
@@ -84,6 +69,8 @@ class WebsocketNotifier extends StateNotifier {
.build(),
);
+ state = WebsocketState(isConnected: false, socket: socket);
+
socket.onConnect((_) {
dPrint(() => "Established Websocket Connection");
state = WebsocketState(isConnected: true, socket: socket);
@@ -91,12 +78,12 @@ class WebsocketNotifier extends StateNotifier {
socket.onDisconnect((_) {
dPrint(() => "Disconnect to Websocket Connection");
- state = const WebsocketState(isConnected: false, socket: null);
+ state = WebsocketState(isConnected: false, socket: socket);
});
socket.on('error', (errorMessage) {
_log.severe("Websocket Error - $errorMessage");
- state = const WebsocketState(isConnected: false, socket: null);
+ state = WebsocketState(isConnected: false, socket: socket);
});
socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReadyV1);
@@ -105,6 +92,11 @@ class WebsocketNotifier extends StateNotifier {
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
socket.on('on_album_update', _handleRemoteChange);
socket.on('on_asset_stack_update', _handleRemoteChange);
+ socket.on('on_asset_delete', _handleRemoteChange);
+ socket.on('on_asset_trash', _handleRemoteChange);
+ socket.on('on_asset_restore', _handleRemoteChange);
+ socket.on('on_asset_hidden', _handleRemoteChange);
+ socket.on('on_asset_update', _handleRemoteChange);
socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_new_release', _handleReleaseUpdates);
} catch (e) {
@@ -187,7 +179,7 @@ class WebsocketNotifier extends StateNotifier {
}
void _handleRemoteChange(dynamic _) {
- unawaited(_ref.read(backgroundSyncProvider).syncRemote());
+ unawaited(_ref.read(backgroundSyncProvider).syncRemote(enqueue: true));
}
void _handleSyncAssetEditReadyV2(dynamic data) {
diff --git a/mobile/lib/repositories/download.repository.dart b/mobile/lib/repositories/download.repository.dart
index ed1ffd9e3d..855b8302a9 100644
--- a/mobile/lib/repositories/download.repository.dart
+++ b/mobile/lib/repositories/download.repository.dart
@@ -10,6 +10,7 @@ import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
+// ignore: dispose-provided-instances
final downloadRepositoryProvider = Provider((ref) => DownloadRepository());
class DownloadRepository {
diff --git a/mobile/lib/routing/app_navigation_observer.dart b/mobile/lib/routing/app_navigation_observer.dart
index f126788008..a7f6d532e9 100644
--- a/mobile/lib/routing/app_navigation_observer.dart
+++ b/mobile/lib/routing/app_navigation_observer.dart
@@ -13,6 +13,8 @@ class AppNavigationObserver extends AutoRouterObserver {
@override
void didPush(Route route, Route? previousRoute) {
+ ref.invalidate(inLockedViewProvider);
+ ref.invalidate(isAssetViewerOpenProvider);
unawaited(
Future(() {
ref.read(currentRouteNameProvider.notifier).state = route.settings.name;
@@ -21,4 +23,10 @@ class AppNavigationObserver extends AutoRouterObserver {
}),
);
}
+
+ @override
+ void didPop(Route route, Route? previousRoute) {
+ ref.invalidate(inLockedViewProvider);
+ ref.invalidate(isAssetViewerOpenProvider);
+ }
}
diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart
index ea33e4ba25..a4c5bab4fb 100644
--- a/mobile/lib/routing/router.dart
+++ b/mobile/lib/routing/router.dart
@@ -84,6 +84,7 @@ import 'package:maplibre_gl/maplibre_gl.dart';
part 'router.gr.dart';
final appRouterProvider = Provider(
+ // ignore: dispose-provided-instances
(ref) => AppRouter(
ref.watch(apiServiceProvider),
ref.watch(authServiceProvider),
diff --git a/mobile/lib/services/foreground_upload.service.dart b/mobile/lib/services/foreground_upload.service.dart
index 36d3975a26..11cbd03017 100644
--- a/mobile/lib/services/foreground_upload.service.dart
+++ b/mobile/lib/services/foreground_upload.service.dart
@@ -35,6 +35,7 @@ class UploadCallbacks {
}
final foregroundUploadServiceProvider = Provider((ref) {
+ // ignore: dispose-provided-instances
return ForegroundUploadService(
ref.watch(uploadRepositoryProvider),
ref.watch(storageRepositoryProvider),
@@ -383,7 +384,6 @@ class ForegroundUploadService {
if (result.isSuccess && result.remoteAssetId != null) {
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
} else if (result.isCancelled) {
- _logger.warning(() => "Backup was cancelled by the user");
shouldAbortUpload = true;
} else if (result.errorMessage != null) {
_logger.severe(
diff --git a/mobile/lib/widgets/activities/comment_bubble.dart b/mobile/lib/widgets/activities/comment_bubble.dart
index fcaff8bfc3..5431420741 100644
--- a/mobile/lib/widgets/activities/comment_bubble.dart
+++ b/mobile/lib/widgets/activities/comment_bubble.dart
@@ -28,7 +28,7 @@ class CommentBubble extends ConsumerWidget {
final isLike = activity.type == ActivityType.like;
final bgColor = isOwn ? context.colorScheme.primaryContainer : context.colorScheme.surfaceContainer;
- final activityNotifier = ref.read(
+ final activityNotifier = ref.watch(
albumActivityProvider((album.id, isAssetActivity ? activity.assetId : null)).notifier,
);
diff --git a/mobile/lib/widgets/asset_viewer/cast_dialog.dart b/mobile/lib/widgets/asset_viewer/cast_dialog.dart
index d406f29a22..f91d8f5b27 100644
--- a/mobile/lib/widgets/asset_viewer/cast_dialog.dart
+++ b/mobile/lib/widgets/asset_viewer/cast_dialog.dart
@@ -28,7 +28,7 @@ class CastDialog extends ConsumerWidget {
width: 250,
height: 250,
child: FutureBuilder>(
- future: ref.read(castProvider.notifier).getDevices(),
+ future: ref.watch(castProvider.notifier).getDevices(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Text('error_saving_image'.tr(args: [snapshot.error.toString()]));
diff --git a/mobile/lib/widgets/asset_viewer/video_controls.dart b/mobile/lib/widgets/asset_viewer/video_controls.dart
index f39077a522..14c4daf5d8 100644
--- a/mobile/lib/widgets/asset_viewer/video_controls.dart
+++ b/mobile/lib/widgets/asset_viewer/video_controls.dart
@@ -99,7 +99,7 @@ class _VideoControlsState extends ConsumerState {
});
ref.listen(_provider.select((v) => v.status), (_, __) => _hideTimer.reset());
- final notifier = ref.read(_provider.notifier);
+ final notifier = ref.watch(_provider.notifier);
final isLoaded = duration != Duration.zero;
return Padding(
diff --git a/mobile/lib/widgets/backup/drift_album_info_list_tile.dart b/mobile/lib/widgets/backup/drift_album_info_list_tile.dart
index 999b64e9de..41a215fb99 100644
--- a/mobile/lib/widgets/backup/drift_album_info_list_tile.dart
+++ b/mobile/lib/widgets/backup/drift_album_info_list_tile.dart
@@ -53,7 +53,7 @@ class DriftAlbumInfoListTile extends HookConsumerWidget {
return GestureDetector(
onDoubleTap: () {
- ref.watch(hapticFeedbackProvider.notifier).selectionClick();
+ ref.read(hapticFeedbackProvider.notifier).selectionClick();
if (isExcluded) {
unawaited(ref.read(backupAlbumProvider.notifier).deselectAlbum(album));
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 53c1eb1af9..b390d2a423 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
@@ -124,6 +124,9 @@ class ImmichAppBarDialog extends HookConsumerWidget {
onOk: () async {
isLoggingOut.value = true;
await ref.read(authProvider.notifier).logout().whenComplete(() => isLoggingOut.value = false);
+ if (!context.mounted) {
+ return;
+ }
ref.read(websocketProvider.notifier).disconnect();
if (!context.mounted) {
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 c963564c69..51cdd7c5a3 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
@@ -45,12 +45,12 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
Future pickUserProfileImage() async {
final XFile? image = await ImagePicker().pickImage(source: ImageSource.gallery, maxHeight: 1024, maxWidth: 1024);
- if (image != null) {
- final success = await ref.watch(uploadProfileImageProvider.notifier).upload(image);
+ if (image != null && context.mounted) {
+ final success = await ref.read(uploadProfileImageProvider.notifier).upload(image);
- if (success) {
+ if (success && context.mounted) {
final profileImagePath = ref.read(uploadProfileImageProvider).profileImagePath;
- ref.watch(authProvider.notifier).updateUserProfileImagePath(profileImagePath);
+ ref.read(authProvider.notifier).updateUserProfileImagePath(profileImagePath);
if (user != null) {
unawaited(ref.read(currentUserProvider.notifier).refresh());
}
@@ -61,7 +61,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
}
void toggleReadonlyMode() {
- final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
+ final isReadonlyModeEnabled = ref.read(readonlyModeProvider);
ref.read(readonlyModeProvider.notifier).toggleReadonlyMode();
context.scaffoldMessenger.showSnackBar(
diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart
index c5bce92cff..54495365db 100644
--- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart
+++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart
@@ -113,7 +113,7 @@ class _ProfileIndicator extends ConsumerWidget {
final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile;
void toggleReadonlyMode() {
- final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
+ final isReadonlyModeEnabled = ref.read(readonlyModeProvider);
ref.read(readonlyModeProvider.notifier).toggleReadonlyMode();
context.scaffoldMessenger.showSnackBar(
@@ -193,11 +193,11 @@ class _BackupIndicator extends ConsumerWidget {
}
Widget? _getBackupBadgeIcon(BuildContext context, WidgetRef ref) {
- final backupEnabled = ref.watch(appConfigProvider.select((c) => c.backup.enabled));
- final hasError = ref.watch(driftBackupProvider.select((state) => state.error != BackupError.none));
+ final backupEnabled = ref.read(appConfigProvider.select((c) => c.backup.enabled));
+ final hasError = ref.read(driftBackupProvider.select((state) => state.error != BackupError.none));
final isDarkTheme = context.isDarkTheme;
final iconColor = isDarkTheme ? Colors.white : Colors.black;
- final isUploading = ref.watch(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty));
+ final isUploading = ref.read(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty));
if (!backupEnabled) {
return _BadgeLabel(
diff --git a/mobile/lib/widgets/common/tag_picker.dart b/mobile/lib/widgets/common/tag_picker.dart
index a9a68fe044..82228f5b0b 100644
--- a/mobile/lib/widgets/common/tag_picker.dart
+++ b/mobile/lib/widgets/common/tag_picker.dart
@@ -17,13 +17,13 @@ Future<(Set, Set)?> showTagPickerModal({required BuildContext co
);
}
-class _TagPickerModal extends HookConsumerWidget {
+class _TagPickerModal extends HookWidget {
final Set? initialSelection;
const _TagPickerModal({this.initialSelection});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final selectedTagIds = useState>(initialSelection ?? {});
final newTagValues = useState>({});
diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart
index c6e4f4719e..11257da77b 100644
--- a/mobile/lib/widgets/common/user_circle_avatar.dart
+++ b/mobile/lib/widgets/common/user_circle_avatar.dart
@@ -1,21 +1,19 @@
import 'package:flutter/material.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
-// ignore: must_be_immutable
-class UserCircleAvatar extends ConsumerWidget {
+class UserCircleAvatar extends StatelessWidget {
final UserDto user;
- double size;
- bool hasBorder;
- double opacity;
+ final double size;
+ final bool hasBorder;
+ final double opacity;
- UserCircleAvatar({super.key, this.size = 44, this.hasBorder = false, this.opacity = 1, required this.user});
+ const UserCircleAvatar({super.key, this.size = 44, this.hasBorder = false, this.opacity = 1, required this.user});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final userAvatarColor = user.avatarColor.toColor().withValues(alpha: opacity);
final profileImageUrl =
'${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}';
diff --git a/mobile/lib/widgets/forms/change_password_form.dart b/mobile/lib/widgets/forms/change_password_form.dart
index dd307f4ede..229eaa7ec6 100644
--- a/mobile/lib/widgets/forms/change_password_form.dart
+++ b/mobile/lib/widgets/forms/change_password_form.dart
@@ -69,12 +69,16 @@ class ChangePasswordForm extends HookConsumerWidget {
return;
}
- await ref.read(authProvider.notifier).logout();
- ref.read(websocketProvider.notifier).disconnect();
if (!context.mounted) {
return;
}
+ await ref.read(authProvider.notifier).logout();
+ if (!context.mounted) {
+ return;
+ }
+
+ ref.read(websocketProvider.notifier).disconnect();
AutoRouter.of(context).back();
ImmichToast.show(
context: context,
@@ -149,13 +153,13 @@ class ConfirmPasswordInput extends StatelessWidget {
}
}
-class ChangePasswordButton extends ConsumerWidget {
+class ChangePasswordButton extends StatelessWidget {
final TextEditingController passwordController;
final VoidCallback onPressed;
const ChangePasswordButton({super.key, required this.passwordController, required this.onPressed});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
return ElevatedButton(
style: ElevatedButton.styleFrom(
visualDensity: VisualDensity.standard,
diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart
index 18963f0cfd..e4c7516c43 100644
--- a/mobile/lib/widgets/forms/login/login_form.dart
+++ b/mobile/lib/widgets/forms/login/login_form.dart
@@ -101,9 +101,15 @@ class LoginForm extends HookConsumerWidget {
try {
final endpoint = await ref.read(authProvider.notifier).validateServerUrl(serverUrl);
+ if (!context.mounted) {
+ return;
+ }
// Fetch and load server config and features
await ref.read(serverInfoProvider.notifier).getServerInfo();
+ if (!context.mounted) {
+ return;
+ }
final serverInfo = ref.read(serverInfoProvider);
final features = serverInfo.serverFeatures;
@@ -264,6 +270,10 @@ class LoginForm extends HookConsumerWidget {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
+ if (!context.mounted) {
+ return;
+ }
+
ref.read(websocketProvider.notifier).connect();
unawaited(ref.read(featureMessageServiceProvider).markSeen());
if (!context.mounted) {
@@ -312,7 +322,7 @@ class LoginForm extends HookConsumerWidget {
}
Future oAuthLogin() async {
- final oAuthService = ref.watch(oAuthServiceProvider);
+ final oAuthService = ref.read(oAuthServiceProvider);
String? oAuthServerUrl;
final state = generateRandomString(32);
@@ -349,27 +359,27 @@ class LoginForm extends HookConsumerWidget {
try {
final loginResponseDto = await oAuthService.oAuthLogin(oAuthServerUrl, state, codeVerifier);
- if (loginResponseDto == null) {
+ if (loginResponseDto == null || !context.mounted) {
return;
}
log.info("Finished OAuth login with response: ${loginResponseDto.userEmail}");
final isSuccess = await ref
- .watch(authProvider.notifier)
+ .read(authProvider.notifier)
.saveAuthInfo(accessToken: loginResponseDto.accessToken);
- if (isSuccess) {
+ if (isSuccess && context.mounted) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (isSyncRemoteDeletionsMode()) {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
- unawaited(ref.read(featureMessageServiceProvider).markSeen());
if (!context.mounted) {
return;
}
+ unawaited(ref.read(featureMessageServiceProvider).markSeen());
unawaited(context.router.replaceAll([const TabShellRoute()]));
return;
}
diff --git a/mobile/lib/widgets/map/map_thumbnail.dart b/mobile/lib/widgets/map/map_thumbnail.dart
index eb4653e17e..84c2ac0100 100644
--- a/mobile/lib/widgets/map/map_thumbnail.dart
+++ b/mobile/lib/widgets/map/map_thumbnail.dart
@@ -2,7 +2,6 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
@@ -15,7 +14,7 @@ import 'package:maplibre_gl/maplibre_gl.dart';
/// User can provide either a [assetMarkerRemoteId] to display the asset's thumbnail or set
/// [showMarkerPin] to true which would display a marker pin instead. If both are provided,
/// [assetMarkerRemoteId] will take precedence
-class MapThumbnail extends HookConsumerWidget {
+class MapThumbnail extends HookWidget {
final Function(Point, LatLng)? onTap;
final LatLng centre;
final String? assetMarkerRemoteId;
@@ -44,7 +43,7 @@ class MapThumbnail extends HookConsumerWidget {
});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final controller = useRef(null);
final styleLoaded = useState(false);
diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart
index bf8673ccc5..cb035a22e9 100644
--- a/mobile/lib/widgets/settings/advanced_settings.dart
+++ b/mobile/lib/widgets/settings/advanced_settings.dart
@@ -32,8 +32,8 @@ class AdvancedSettings extends HookConsumerWidget {
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final isManageMediaSupported = useState(false);
final manageMediaAndroidPermission = useState(false);
- final levelId = useState(ref.read(appConfigProvider).logLevel.index);
- final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote);
+ final levelId = useState(ref.watch(appConfigProvider).logLevel.index);
+ final preferRemote = useState(ref.watch(appConfigProvider).image.preferRemote);
useValueChanged(
preferRemote.value,
(_, __) => unawaited(ref.read(settingsProvider).write(.imagePreferRemote, preferRemote.value)),
@@ -60,7 +60,7 @@ class AdvancedSettings extends HookConsumerWidget {
useEffect(() {
unawaited(() async {
isManageMediaSupported.value = await checkAndroidVersion();
- if (isManageMediaSupported.value) {
+ if (isManageMediaSupported.value && context.mounted) {
manageMediaAndroidPermission.value = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
}
}());
diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart
index eda0d819d5..5b4c4bb89f 100644
--- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart
+++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart
@@ -15,7 +15,7 @@ class LayoutSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final tilesPerRow = useState(ref.read(appConfigProvider.select((s) => s.timeline.tilesPerRow)));
+ final tilesPerRow = useState(ref.watch(appConfigProvider.select((s) => s.timeline.tilesPerRow)));
useValueChanged(tilesPerRow.value, (_, __) {
unawaited(ref.read(settingsProvider).write(.timelineTilesPerRow, tilesPerRow.value));
});
diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart
index e3173dcdc7..82a2d804a3 100644
--- a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart
+++ b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart
@@ -14,7 +14,7 @@ class ImageViewerQualitySetting extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final isOriginal = useState(ref.read(appConfigProvider).image.loadOriginal);
+ final isOriginal = useState(ref.watch(appConfigProvider).image.loadOriginal);
useValueChanged(isOriginal.value, (_, __) {
unawaited(ref.read(settingsProvider).write(.imageLoadOriginal, isOriginal.value));
});
diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart
index c785e8fed4..fc3466c3c6 100644
--- a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart
+++ b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart
@@ -13,7 +13,7 @@ class ImageViewerTapToNavigateSetting extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final tapToNavigate = useState(ref.read(appConfigProvider).viewer.tapToNavigate);
+ final tapToNavigate = useState(ref.watch(appConfigProvider).viewer.tapToNavigate);
useValueChanged(tapToNavigate.value, (_, __) {
unawaited(ref.read(settingsProvider).write(.viewerTapToNavigate, tapToNavigate.value));
});
diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart
index ec52d23ca8..d8ccd4f0ed 100644
--- a/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart
+++ b/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart
@@ -17,7 +17,7 @@ class SlideshowSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final slideshow = ref.read(appConfigProvider).slideshow;
+ final slideshow = ref.watch(appConfigProvider).slideshow;
final useRepeat = useState(slideshow.repeat);
final useDuration = useState(slideshow.duration);
final useLook = useState(slideshow.look);
diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart
index 2b302e0427..7bea439f0c 100644
--- a/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart
+++ b/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart
@@ -13,7 +13,7 @@ class VideoViewerSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final viewer = ref.read(appConfigProvider).viewer;
+ final viewer = ref.watch(appConfigProvider).viewer;
final useAutoPlayVideo = useState(viewer.autoPlayVideo);
final useLoopVideo = useState(viewer.loopVideo);
final useOriginalVideo = useState(viewer.loadOriginalVideo);
diff --git a/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart b/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart
index f0f6435f90..e7d56ed66a 100644
--- a/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart
+++ b/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart
@@ -19,11 +19,11 @@ import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/setting_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
-class DriftBackupSettings extends ConsumerWidget {
+class DriftBackupSettings extends StatelessWidget {
const DriftBackupSettings({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
return SettingsSubPageScaffold(
settings: [
SettingGroupTitle(
@@ -68,8 +68,9 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton>
});
try {
- await ref.read(backgroundSyncProvider).syncLinkedAlbum();
- await ref.read(backgroundSyncProvider).syncRemote();
+ final backgroundSync = ref.read(backgroundSyncProvider);
+ await backgroundSync.syncLinkedAlbum();
+ await backgroundSync.syncRemote();
} catch (_) {
} finally {
Future.delayed(const Duration(seconds: 1), () {
@@ -225,7 +226,7 @@ class _BackupOnlyWhenChargingButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final fgService = ref.read(backgroundWorkerFgServiceProvider);
+ final fgService = ref.watch(backgroundWorkerFgServiceProvider);
return _BackupSwitchTile(
metadataKey: SettingsKey.backupRequireCharging,
selector: (c) => c.backup.requireCharging,
diff --git a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart
index 58a044cea4..097f72e1e1 100644
--- a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart
+++ b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart
@@ -163,7 +163,7 @@ class SyncStatusAndActions extends HookConsumerWidget {
leading: const Icon(Icons.cloud_circle_rounded),
subtitle: "tap_to_run_job".t(context: context),
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).cloudIdSyncStatus),
- onTap: ref.read(backgroundSyncProvider).syncCloudIds,
+ onTap: ref.watch(backgroundSyncProvider).syncCloudIds,
),
SettingListTile(
title: "hash_asset".t(context: context),
diff --git a/mobile/lib/widgets/settings/free_up_space_settings.dart b/mobile/lib/widgets/settings/free_up_space_settings.dart
index 72e85ac821..8d4785d664 100644
--- a/mobile/lib/widgets/settings/free_up_space_settings.dart
+++ b/mobile/lib/widgets/settings/free_up_space_settings.dart
@@ -39,11 +39,11 @@ class _FreeUpSpaceSettingsState extends ConsumerState {
}
Future _initializeAlbumDefaults() async {
+ final notifier = ref.read(cleanupProvider.notifier);
final albums = await ref.read(localAlbumProvider.future);
final existingAlbumIds = albums.map((a) => a.id).toSet();
final albumsWithNames = albums.map((a) => (a.id, a.name)).toList();
- final notifier = ref.read(cleanupProvider.notifier);
notifier.applyDefaultAlbumSelections(albumsWithNames);
notifier.cleanupStaleAlbumIds(existingAlbumIds);
}
@@ -105,7 +105,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState {
lastDate: DateTime.now(),
);
- if (picked != null) {
+ if (picked != null && context.mounted) {
ref.read(cleanupProvider.notifier).setSelectedDate(picked);
setState(() => _hasScanned = false);
}
@@ -122,6 +122,10 @@ class _FreeUpSpaceSettingsState extends ConsumerState {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
await ref.read(cleanupProvider.notifier).scanAssets();
+ if (!mounted) {
+ return;
+ }
+
final state = ref.read(cleanupProvider);
setState(() {
@@ -146,7 +150,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState {
_DeleteConfirmationDialog(assetCount: state.assetsToDelete.length, cutoffDate: state.selectedDate!),
);
- if (confirmed != true) {
+ if (confirmed != true || !context.mounted) {
return;
}
diff --git a/mobile/lib/widgets/settings/language_settings.dart b/mobile/lib/widgets/settings/language_settings.dart
index b12d1476f5..e8478da453 100644
--- a/mobile/lib/widgets/settings/language_settings.dart
+++ b/mobile/lib/widgets/settings/language_settings.dart
@@ -3,14 +3,13 @@ import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
-class LanguageSettings extends HookConsumerWidget {
+class LanguageSettings extends HookWidget {
const LanguageSettings({super.key});
Future _applyLanguageChange(
@@ -33,7 +32,7 @@ class LanguageSettings extends HookConsumerWidget {
}
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final localeEntries = useMemoized(() => locales.entries.toList(), const []);
final currentLocale = context.locale;
final filteredLocaleEntries = useState>>(localeEntries);
diff --git a/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart
index 68278e0e1d..81ffb4b19b 100644
--- a/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart
+++ b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart
@@ -36,7 +36,7 @@ class ExternalNetworkPreference extends HookConsumerWidget {
entries.value[index] = entries.value[index].copyWith(url: url, status: status);
await saveEndpointList();
- if (status == AuxCheckStatus.valid) {
+ if (status == AuxCheckStatus.valid && context.mounted) {
await ref.read(apiServiceProvider).updateHeaders();
}
}
diff --git a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart
index 513648f030..dad827230a 100644
--- a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart
+++ b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart
@@ -21,7 +21,7 @@ class NetworkingSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentEndpoint = getServerUrl();
- final featureEnabled = useState(ref.read(appConfigProvider).network.autoEndpointSwitching);
+ final featureEnabled = useState(ref.watch(appConfigProvider).network.autoEndpointSwitching);
useValueChanged(featureEnabled.value, (_, __) {
unawaited(ref.read(settingsProvider).write(.networkAutoEndpointSwitching, featureEnabled.value));
});
diff --git a/mobile/lib/widgets/settings/notification_setting.dart b/mobile/lib/widgets/settings/notification_setting.dart
index 8c858a231b..0bab4e051a 100644
--- a/mobile/lib/widgets/settings/notification_setting.dart
+++ b/mobile/lib/widgets/settings/notification_setting.dart
@@ -47,7 +47,7 @@ class NotificationSetting extends HookConsumerWidget {
subtileText: 'notification_permission_list_tile_content'.tr(),
buttonText: 'notification_permission_list_tile_enable_button'.tr(),
onButtonTap: () =>
- ref.watch(notificationPermissionProvider.notifier).requestNotificationPermission().then((permission) {
+ ref.read(notificationPermissionProvider.notifier).requestNotificationPermission().then((permission) {
if (permission == PermissionStatus.permanentlyDenied) {
showPermissionsDialog();
}
diff --git a/mobile/lib/widgets/settings/preference_settings/haptic_setting.dart b/mobile/lib/widgets/settings/preference_settings/haptic_setting.dart
index bfdb0f065c..c5003a9ced 100644
--- a/mobile/lib/widgets/settings/preference_settings/haptic_setting.dart
+++ b/mobile/lib/widgets/settings/preference_settings/haptic_setting.dart
@@ -1,17 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
-class HapticSetting extends HookConsumerWidget {
+class HapticSetting extends HookWidget {
const HapticSetting({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final hapticFeedbackSetting = useAppSettingsState(AppSettingsEnum.enableHapticFeedback);
final isHapticFeedbackEnabled = useValueNotifier(hapticFeedbackSetting.value);
diff --git a/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart b/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart
index b1564b4145..7964ec3861 100644
--- a/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart
+++ b/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart
@@ -16,7 +16,7 @@ class PrimaryColorSetting extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final themeProvider = ref.read(immichThemeProvider);
+ final themeProvider = ref.watch(immichThemeProvider);
final themeConfig = ref.watch(appConfigProvider.select((config) => config.theme));
const tileSize = 55.0;
diff --git a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart
index ec84d7be01..8281b2262a 100644
--- a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart
+++ b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart
@@ -15,7 +15,7 @@ class ThemeSetting extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final currentTheme = useState(ref.read(appConfigProvider.select((config) => config.theme.mode)));
+ final currentTheme = useState(ref.watch(appConfigProvider.select((config) => config.theme.mode)));
final isDarkTheme = useValueNotifier(currentTheme.value == ThemeMode.dark);
final isSystemTheme = useValueNotifier(currentTheme.value == ThemeMode.system);
final colorfulInterface = useValueNotifier(
diff --git a/mobile/lib/widgets/shared_link/shared_link_item.dart b/mobile/lib/widgets/shared_link/shared_link_item.dart
index d419d6ead0..d98925bdf8 100644
--- a/mobile/lib/widgets/shared_link/shared_link_item.dart
+++ b/mobile/lib/widgets/shared_link/shared_link_item.dart
@@ -179,7 +179,7 @@ class SharedLinkItem extends ConsumerWidget {
),
);
- if (confirmed == true) {
+ if (confirmed == true && context.mounted) {
await ref.read(sharedLinksStateProvider.notifier).deleteLink(sharedLink.id);
return true;
}
diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock
index e0198f2683..f02df0b622 100644
--- a/mobile/pubspec.lock
+++ b/mobile/pubspec.lock
@@ -667,6 +667,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.2.14"
+ freezed:
+ dependency: "direct dev"
+ description:
+ name: freezed
+ sha256: f23ea33b3863f119b58ed1b586e881a46bd28715ddcc4dbc33104524e3434131
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.2.5"
+ freezed_annotation:
+ dependency: "direct main"
+ description:
+ name: freezed_annotation
+ sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.0"
frontend_server_client:
dependency: transitive
description:
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index a55bcf515b..c2848e6dd8 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -91,6 +91,7 @@ dependencies:
url: https://github.com/mertalev/http
ref: '549c24b0a4d3881a9a44b70f4873450d43c1c4af' # https://github.com/dart-lang/http/pull/1877
path: pkgs/ok_http/
+ freezed_annotation: ^3.1.0
dev_dependencies:
auto_route_generator: ^10.5.0
@@ -104,6 +105,7 @@ dev_dependencies:
flutter_native_splash: ^2.4.7
flutter_test:
sdk: flutter
+ freezed: ^3.2.5
integration_test:
sdk: flutter
mocktail: ^1.0.5
diff --git a/mobile/test/presentation/widgets/images/local_image_provider_test.dart b/mobile/test/presentation/widgets/images/local_image_provider_test.dart
new file mode 100644
index 0000000000..5b556f8a41
--- /dev/null
+++ b/mobile/test/presentation/widgets/images/local_image_provider_test.dart
@@ -0,0 +1,141 @@
+import 'package:flutter/painting.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
+import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
+import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
+
+import '../../../unit/factories/local_asset_factory.dart';
+
+class _StubCompleter extends ImageStreamCompleter {}
+
+void main() {
+ late ImageCache cache;
+ late int loads;
+
+ ImageStreamCompleter load() {
+ loads++;
+ return _StubCompleter();
+ }
+
+ setUp(() {
+ cache = ImageCache();
+ loads = 0;
+ });
+
+ group('LocalFullImageProvider.previewTargetSize', () {
+ const box = Size(1179, 2556);
+ const cases = <(String, Size, int?, int?, bool, Size)>[
+ ('normal', box, 4032, 3024, true, box),
+ ('missing dimensions', box, null, null, true, box),
+ ('invalid dimensions', box, 1000, 0, true, box),
+ ('long final preview', box, 1000, 30000, true, Size(16384 / 30, 16384)),
+ ('long first preview', box, 30000, 1000, false, Size(2556, 2556 / 30)),
+ ('ultra-thin preview', box, 10, 50000, false, Size(1, 2556)),
+ ('small source', box, 50, 2000, true, Size(50, 2000)),
+ ('at limit', Size(16384, 100), 1000, 1000, true, Size(16384, 100)),
+ ('over limit', Size(16385, 100), 1000, 1000, true, Size(1000, 1000)),
+ ];
+
+ for (final (name, box, width, height, previewIsFinal, expected) in cases) {
+ test(name, () {
+ final actual = LocalFullImageProvider.previewTargetSize(
+ box.width,
+ box.height,
+ width,
+ height,
+ previewIsFinal: previewIsFinal,
+ );
+ expect(actual.width, closeTo(expected.width, 1e-6));
+ expect(actual.height, closeTo(expected.height, 1e-6));
+ });
+ }
+ });
+
+ group('LocalFullImageProvider equality', () {
+ LocalFullImageProvider make({int? width, int? height}) => LocalFullImageProvider(
+ id: 'a',
+ assetType: AssetType.image,
+ size: const Size(100, 200),
+ isAnimated: false,
+ width: width,
+ height: height,
+ );
+
+ test('uses dimensions in the cache key', () {
+ final a = make(width: 100, height: 200);
+ final b = make(width: 100, height: 200);
+ expect(a, b);
+ expect(a.hashCode, b.hashCode);
+ expect(a == make(width: 200, height: 100), isFalse);
+ });
+ });
+
+ group('LocalThumbProvider caching', () {
+ test('editing on device re-renders the thumbnail', () {
+ cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'before'), load);
+ cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'after'), load);
+
+ expect(loads, 2);
+ });
+
+ test('an unchanged thumbnail still comes from the cache', () {
+ cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'same'), load);
+ cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'same'), load);
+
+ expect(loads, 1);
+ });
+
+ // The rehash clears the checksum before writing the new one, so the tile has to
+ // follow that step too or it waits for the hash to land before showing the edit.
+ test('re-renders while the checksum is still being recomputed', () {
+ cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'before'), load);
+ cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image), load);
+
+ expect(loads, 2);
+ });
+
+ test('stays cached while the checksum is missing', () {
+ cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image), load);
+ cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image), load);
+
+ expect(loads, 1);
+ });
+ });
+
+ group('factories', () {
+ test('thumbnails are keyed by the asset checksum', () {
+ final asset = LocalAssetFactory.create().copyWith(checksum: 'abc');
+
+ final provider = getThumbnailImageProvider(asset)! as LocalThumbProvider;
+
+ expect(provider.checksum, 'abc');
+ });
+ });
+
+ group('LocalFullImageProvider caching', () {
+ test('editing on device re-renders the full image', () {
+ cache.putIfAbsent(
+ LocalFullImageProvider(
+ id: 'asset-1',
+ assetType: AssetType.image,
+ size: const Size(100, 100),
+ isAnimated: false,
+ checksum: 'before',
+ ),
+ load,
+ );
+ cache.putIfAbsent(
+ LocalFullImageProvider(
+ id: 'asset-1',
+ assetType: AssetType.image,
+ size: const Size(100, 100),
+ isAnimated: false,
+ checksum: 'after',
+ ),
+ load,
+ );
+
+ expect(loads, 2);
+ });
+ });
+}
diff --git a/mobile/test/providers/app_life_cycle_provider_test.dart b/mobile/test/providers/app_life_cycle_provider_test.dart
new file mode 100644
index 0000000000..d4489a58ba
--- /dev/null
+++ b/mobile/test/providers/app_life_cycle_provider_test.dart
@@ -0,0 +1,180 @@
+import 'dart:async';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/domain/models/config/app_config.dart';
+import 'package:immich_mobile/domain/models/log.model.dart';
+import 'package:immich_mobile/domain/services/log.service.dart';
+import 'package:immich_mobile/models/auth/auth_state.model.dart';
+import 'package:immich_mobile/models/server_info/server_version.model.dart';
+import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
+import 'package:immich_mobile/providers/auth.provider.dart';
+import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
+import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
+import 'package:immich_mobile/providers/server_info.provider.dart';
+import 'package:immich_mobile/providers/websocket.provider.dart';
+import 'package:immich_mobile/utils/upload_speed_calculator.dart';
+import 'package:mocktail/mocktail.dart';
+
+import '../infrastructure/repository.mock.dart';
+import '../service.mocks.dart';
+
+class FakeLogMessage extends Fake implements LogMessage {}
+
+class TestAuthNotifier extends AuthNotifier {
+ TestAuthNotifier(Ref ref)
+ : super(
+ MockAuthService(),
+ MockApiService(),
+ MockUserService(),
+ MockSecureStorageService(),
+ MockWidgetService(),
+ ref,
+ ) {
+ state = const AuthState(
+ deviceId: 'device-1',
+ userId: 'user-1',
+ userEmail: 'user@example.com',
+ name: 'User',
+ profileImagePath: '',
+ isAdmin: false,
+ isAuthenticated: true,
+ );
+ }
+
+ @override
+ Future setOpenApiServiceEndpoint() async => 'http://test-server.com';
+}
+
+class TestWebsocketNotifier extends WebsocketNotifier {
+ TestWebsocketNotifier(super.ref);
+
+ int connectCount = 0;
+ int disconnectCount = 0;
+ final connectCalled = Completer();
+
+ @override
+ void connect() {
+ connectCount++;
+ if (!connectCalled.isCompleted) {
+ connectCalled.complete();
+ }
+ throw StateError('unexpected websocket connection');
+ }
+
+ @override
+ void disconnect() => disconnectCount++;
+}
+
+class TestDriftBackupNotifier extends DriftBackupNotifier {
+ TestDriftBackupNotifier() : super(MockForegroundUploadService(), MockBackgroundUploadService(), UploadSpeedManager());
+}
+
+void main() {
+ late LogService logService;
+ late Completer serverVersion;
+ late MockServerInfoService serverInfoService;
+ late MockBackgroundWorkerLockService lockService;
+ late ProviderContainer container;
+ late TestWebsocketNotifier websocket;
+ late AppLifeCycleNotifier lifeCycle;
+ late int serverVersionCount;
+
+ setUpAll(() async {
+ final logRepository = MockLogRepository();
+ final settingsRepository = MockSettingsRepository();
+ registerFallbackValue(FakeLogMessage());
+ when(() => logRepository.truncate(limit: any(named: 'limit'))).thenAnswer((_) async {});
+ when(() => logRepository.insert(any())).thenAnswer((_) async => true);
+ when(() => settingsRepository.appConfig).thenReturn(const AppConfig(logLevel: LogLevel.info));
+ logService = await LogService.init(
+ logRepository: logRepository,
+ settingsRepository: settingsRepository,
+ shouldBuffer: false,
+ );
+ });
+
+ tearDownAll(() => logService.dispose());
+
+ setUp(() {
+ serverVersion = Completer();
+ serverInfoService = MockServerInfoService();
+ lockService = MockBackgroundWorkerLockService();
+ serverVersionCount = 0;
+
+ when(() => serverInfoService.getServerVersion()).thenAnswer((_) {
+ serverVersionCount++;
+ return serverVersionCount == 1 ? serverVersion.future : Future.value();
+ });
+ when(() => lockService.lock()).thenAnswer((_) async {});
+ when(() => lockService.unlock()).thenAnswer((_) async {});
+
+ container = ProviderContainer(
+ overrides: [
+ authProvider.overrideWith(TestAuthNotifier.new),
+ serverInfoProvider.overrideWith((_) => ServerInfoNotifier(serverInfoService)),
+ websocketProvider.overrideWith((ref) {
+ return websocket = TestWebsocketNotifier(ref);
+ }),
+ driftBackupProvider.overrideWith((_) => TestDriftBackupNotifier()),
+ backgroundWorkerLockServiceProvider.overrideWithValue(lockService),
+ ],
+ );
+ lifeCycle = container.read(appStateProvider.notifier);
+ });
+
+ tearDown(() => container.dispose());
+
+ Future startResume() async {
+ await lifeCycle.handleAppPause();
+ unawaited(lifeCycle.handleAppResume());
+ await untilCalled(() => serverInfoService.getServerVersion());
+ }
+
+ Future releaseResume() async {
+ serverVersion.complete();
+ await Future.delayed(Duration.zero);
+ }
+
+ test('pause during resume does not reconnect websocket', () async {
+ await startResume();
+ await lifeCycle.handleAppPause();
+ await releaseResume();
+
+ expect(lifeCycle.getAppState(), AppLifeCycleEnum.paused);
+ expect(serverVersionCount, 1);
+ expect(websocket.disconnectCount, 2);
+ expect(websocket.connectCount, 0);
+ });
+
+ test('inactive resume retries when the app resumes again', () async {
+ await startResume();
+ lifeCycle.handleAppInactivity();
+ await releaseResume();
+
+ unawaited(lifeCycle.handleAppResume());
+ await websocket.connectCalled.future;
+
+ expect(lifeCycle.getAppState(), AppLifeCycleEnum.resumed);
+ expect(serverVersionCount, 2);
+ expect(websocket.disconnectCount, 1);
+ expect(websocket.connectCount, 1);
+ });
+
+ test('pause after an inactive abort resumes once', () async {
+ await startResume();
+ lifeCycle.handleAppInactivity();
+ await releaseResume();
+ await lifeCycle.handleAppPause();
+
+ unawaited(lifeCycle.handleAppResume());
+ await websocket.connectCalled.future;
+ unawaited(lifeCycle.handleAppResume());
+ await Future.delayed(Duration.zero);
+
+ expect(lifeCycle.getAppState(), AppLifeCycleEnum.resumed);
+ expect(serverVersionCount, 2);
+ expect(websocket.disconnectCount, 2);
+ expect(websocket.connectCount, 1);
+ });
+}
diff --git a/mobile/test/providers/backup/drift_backup_provider_test.dart b/mobile/test/providers/backup/drift_backup_provider_test.dart
new file mode 100644
index 0000000000..205f564cd9
--- /dev/null
+++ b/mobile/test/providers/backup/drift_backup_provider_test.dart
@@ -0,0 +1,109 @@
+import 'dart:async';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
+import 'package:immich_mobile/services/background_upload.service.dart';
+import 'package:immich_mobile/services/foreground_upload.service.dart';
+import 'package:immich_mobile/utils/upload_speed_calculator.dart';
+import 'package:mocktail/mocktail.dart';
+
+class MockForegroundUploadService extends Mock implements ForegroundUploadService {}
+
+class MockBackgroundUploadService extends Mock implements BackgroundUploadService {}
+
+void main() {
+ late MockForegroundUploadService foregroundUploadService;
+ late MockBackgroundUploadService backgroundUploadService;
+ late DriftBackupNotifier notifier;
+
+ setUpAll(() {
+ registerFallbackValue(Completer());
+ registerFallbackValue(const UploadCallbacks());
+ });
+
+ setUp(() {
+ foregroundUploadService = MockForegroundUploadService();
+ backgroundUploadService = MockBackgroundUploadService();
+ notifier = DriftBackupNotifier(foregroundUploadService, backgroundUploadService, UploadSpeedManager());
+ addTearDown(() {
+ if (notifier.mounted) {
+ notifier.dispose();
+ }
+ });
+ });
+
+ void mockCounts({required int total, required int remainder, int processing = 0}) {
+ when(
+ () => foregroundUploadService.getBackupCounts('user-1'),
+ ).thenAnswer((_) async => (total: total, remainder: remainder, processing: processing));
+ }
+
+ // Drives a backup run so we can grab the onSuccess callback the notifier wires up.
+ Future startAndCaptureOnSuccess() async {
+ void Function(String, String)? onSuccess;
+ when(() => foregroundUploadService.uploadCandidates(any(), any(), callbacks: any(named: 'callbacks'))).thenAnswer((
+ invocation,
+ ) async {
+ onSuccess = (invocation.namedArguments[#callbacks] as UploadCallbacks).onSuccess;
+ });
+ await notifier.startForegroundBackup('user-1');
+ return onSuccess!;
+ }
+
+ group('foreground backup counts', () {
+ test('successes move one asset from remainder to backup', () async {
+ mockCounts(total: 25, remainder: 25);
+ final onSuccess = await startAndCaptureOnSuccess();
+
+ for (var i = 0; i < 10; i++) {
+ onSuccess('asset-$i', 'remote-$i');
+ }
+
+ expect(notifier.state.remainderCount, 15);
+ expect(notifier.state.backupCount, 10);
+ expect(notifier.state.backupCount + notifier.state.remainderCount, notifier.state.totalCount);
+ });
+
+ test('a duplicate success after pause and resume cannot go below zero', () async {
+ // #26215: app pauses mid-backup, sync has not recorded the upload yet, so the
+ // resumed run re-uploads the same asset and the server answers 200 duplicate.
+ // The start of each run re-baselines the counters from the DB, so the duplicate
+ // success is counted against a baseline that includes the asset again.
+ mockCounts(total: 1, remainder: 1);
+
+ final firstRun = await startAndCaptureOnSuccess();
+ expect(notifier.state.remainderCount, 1);
+ firstRun('asset-1', 'remote-1');
+ expect(notifier.state.remainderCount, 0);
+
+ notifier.stopForegroundBackup(reason: "test");
+
+ final resumedRun = await startAndCaptureOnSuccess();
+ expect(notifier.state.remainderCount, 1);
+ verify(() => foregroundUploadService.getBackupCounts('user-1')).called(2);
+
+ resumedRun('asset-1', 'remote-1');
+ expect(notifier.state.remainderCount, 0);
+ expect(notifier.state.backupCount, 1);
+ });
+
+ test('a drifted counter state heals at run start', () async {
+ mockCounts(total: 91, remainder: 7);
+ notifier.state = notifier.state.copyWith(totalCount: 91, backupCount: 103, remainderCount: -12);
+
+ await startAndCaptureOnSuccess();
+
+ expect(notifier.state.totalCount, 91);
+ expect(notifier.state.remainderCount, 7);
+ expect(notifier.state.backupCount, 84);
+ });
+
+ test('a late success after dispose does not throw', () async {
+ mockCounts(total: 2, remainder: 2);
+ final onSuccess = await startAndCaptureOnSuccess();
+ notifier.dispose();
+
+ onSuccess('asset-1', 'remote-1');
+ });
+ });
+}
diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart
index 785567de56..ecf9c771bc 100644
--- a/mobile/test/service.mocks.dart
+++ b/mobile/test/service.mocks.dart
@@ -1,4 +1,5 @@
import 'package:immich_mobile/domain/services/asset.service.dart';
+import 'package:immich_mobile/domain/services/background_worker.service.dart';
import 'package:immich_mobile/domain/services/partner.service.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
@@ -7,12 +8,16 @@ import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
+import 'package:immich_mobile/services/auth.service.dart';
+import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:immich_mobile/services/cleanup.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/gcast.service.dart';
import 'package:immich_mobile/services/network.service.dart';
+import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/services/server_info.service.dart';
import 'package:immich_mobile/services/toast.service.dart';
+import 'package:immich_mobile/services/widget.service.dart';
import 'package:mocktail/mocktail.dart';
class MockApiService extends Mock implements ApiService {}
@@ -46,3 +51,13 @@ class MockCleanupService extends Mock implements CleanupService {}
class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
class MockToastService extends Mock implements ToastService {}
+
+class MockAuthService extends Mock implements AuthService {}
+
+class MockSecureStorageService extends Mock implements SecureStorageService {}
+
+class MockWidgetService extends Mock implements WidgetService {}
+
+class MockBackgroundUploadService extends Mock implements BackgroundUploadService {}
+
+class MockBackgroundWorkerLockService extends Mock implements BackgroundWorkerLockService {}
diff --git a/server/Dockerfile b/server/Dockerfile
index df9d8ad7ba..802877421d 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -56,7 +56,7 @@ FROM builder AS plugins
ARG TARGETPLATFORM
-COPY --from=ghcr.io/jdx/mise:2026.7.15@sha256:e62097049bfc980de5d9a25fbe16431e24c1e7713b3f77b18c04f81c25239953 /usr/local/bin/mise /usr/local/bin/mise
+COPY --from=ghcr.io/jdx/mise:2026.8.1@sha256:b2297770273f71e685b8056e3b07bfda4ffc35f0fb62e0339b3cdc1e5766e2fe /usr/local/bin/mise /usr/local/bin/mise
WORKDIR /app
COPY ./mise.toml ./mise.toml
diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev
index 178f4440df..23a565b4bb 100644
--- a/server/Dockerfile.dev
+++ b/server/Dockerfile.dev
@@ -2,7 +2,7 @@
FROM ghcr.io/immich-app/base-server-dev:202607211135@sha256:83c9ff3f7390111596a2dcd24a746c3f8618d58259dbc7aae36618d153462f18 AS dev
-COPY --from=ghcr.io/jdx/mise:2026.7.15@sha256:e62097049bfc980de5d9a25fbe16431e24c1e7713b3f77b18c04f81c25239953 /usr/local/bin/mise /usr/local/bin/mise
+COPY --from=ghcr.io/jdx/mise:2026.8.1@sha256:b2297770273f71e685b8056e3b07bfda4ffc35f0fb62e0339b3cdc1e5766e2fe /usr/local/bin/mise /usr/local/bin/mise
RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \