Merge branch 'main' into fcast

This commit is contained in:
Will 2026-08-05 10:58:51 -05:00 committed by GitHub
commit bee1ff6210
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
119 changed files with 1120 additions and 387 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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}}'

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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

View file

@ -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 }}

View file

@ -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

View file

@ -4,7 +4,6 @@ import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import androidx.exifinterface.media.ExifInterface
import android.os.Build
import android.os.Bundle
import android.os.ext.SdkExtensions
@ -30,6 +29,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.security.MessageDigest
import kotlin.coroutines.cancellation.CancellationException
@ -377,7 +378,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
)?.use { cursor -> cursor.count.toLong() } ?: 0L
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result<List<PlatformAsset>>) -> Unit) {
fun getAssetsForAlbum(
albumId: String,
updatedTimeCond: Long?,
callback: (Result<List<PlatformAsset>>) -> 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))
}

View file

@ -20,6 +20,7 @@ class LocalImageApiImpl: LocalImageApi {
requestOptions.version = .current
return requestOptions
}()
private static let maxPixelSize: CGFloat = 16384
private static let registry = RequestRegistry<ImageRequest>()
@ -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)
}

View file

@ -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
)
}
}

View file

@ -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()
)

View file

@ -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<ImageEntry>) -> Void
) {
Task {
var entries: [ImageEntry] = []
let now = Date()
func timeline(
for configuration: MemoryConfigurationAppIntent,
in context: Context
) async
-> Timeline<ImageEntry>
{
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)

View file

@ -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

View file

@ -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);
}

View file

@ -28,6 +28,7 @@ class BackgroundSyncManager {
final SyncErrorCallback? onCloudIdSyncError;
Cancelable<bool?>? _syncTask;
bool _syncQueued = false;
Cancelable<void>? _syncWebsocketTask;
Cancelable<void>? _cloudIdSyncTask;
Cancelable<void>? _deviceAlbumSyncTask;
@ -50,6 +51,7 @@ class BackgroundSyncManager {
});
Future<void> cancel() async {
_syncQueued = false;
final tasks = [
_syncTask,
_syncWebsocketTask,
@ -133,8 +135,9 @@ class BackgroundSyncManager {
});
}
Future<bool> syncRemote() {
Future<bool> 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());
}
});
}

View file

@ -81,8 +81,8 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
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<void> startBackup() async {
final currentUser = Store.tryGet(StoreKey.currentUser);
@ -143,7 +143,7 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
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));

View file

@ -102,6 +102,9 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
onPopInvokedWithResult: (didPop, _) async {
if (!didPop) {
await _handlePagePopped();
if (!mounted) {
return;
}
final user = ref.read(currentUserProvider);
if (user == null) {
@ -110,6 +113,10 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
final isBackupEnabled = SettingsRepository.instance.appConfig.backup.enabled;
await ref.read(driftBackupProvider.notifier).getBackupStatus(user.id);
if (!mounted) {
return;
}
final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
final backupNotifier = ref.read(driftBackupProvider.notifier);
@ -119,7 +126,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
// Waits for hashing to be cancelled before starting a new one
unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets()));
if (isBackupEnabled) {
backupNotifier.stopForegroundBackup();
backupNotifier.stopForegroundBackup(reason: "backup albums updated");
unawaited(
backgroundSync.syncRemote().then((success) {
if (success) {

View file

@ -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) {

View file

@ -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]));

View file

@ -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),

View file

@ -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(

View file

@ -21,7 +21,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
final headers = useState<List<SettingsHeader>>([]);
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();
}
}

View file

@ -353,11 +353,11 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
},
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()]));
},
),

View file

@ -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());
}

View file

@ -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<void> getAppInfo() async {

View file

@ -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<LatLng>(initialLatLng);
final controller = useRef<MapLibreMapController?>(null);
final marker = useRef<Symbol?>(null);

View file

@ -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(

View file

@ -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();

View file

@ -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);

View file

@ -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<BaseAsset> lockedSelectionAssets;
const DriftAssetSelectionTimelinePage({super.key, this.lockedSelectionAssets = const {}});
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
multiSelectProvider.overrideWith(

View file

@ -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: [

View file

@ -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: [

View file

@ -76,6 +76,10 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
}
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<RemoteAlbumPage> {
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<bool>(
future: ref
.read(remoteAlbumServiceProvider)
.watch(remoteAlbumServiceProvider)
.getUserRole(album.id, user?.id ?? '')
.then((role) => role == AlbumUserRole.editor),
builder: (context, snapshot) {

View file

@ -300,7 +300,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> 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<DriftSlideshowPage> 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) {

View file

@ -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) {

View file

@ -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

View file

@ -70,6 +70,10 @@ class _ProfilePictureCropPageState extends ConsumerState<ProfilePictureCropPage>
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');

View file

@ -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;

View file

@ -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;

View file

@ -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,
);

View file

@ -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<void> onCreateAlbum() async {
final albumName = await showDialog<String?>(context: context, builder: (context) => const NewAlbumNameModal());
if (albumName == null) {
if (albumName == null || !context.mounted) {
return;
}

View file

@ -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(

View file

@ -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<String?>(
future: assetMediaRepository.getOriginalFilename(asset.id),
builder: (context, snapshot) {

View file

@ -401,7 +401,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_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 = _asset;

View file

@ -17,7 +17,7 @@ class AssetStackRow extends ConsumerWidget {
return const SizedBox.shrink();
}
final hideAssetStack = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
final hideAssetStack = ref.watch(timelineServiceProvider).origin == TimelineOrigin.trash;
if (hideAssetStack) {
return const SizedBox.shrink();
}

View file

@ -38,7 +38,7 @@ class ViewerBottomBar extends ConsumerWidget {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final isInLockedView = ref.watch(inLockedViewProvider);
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
final isInTrash = ref.watch(timelineServiceProvider).origin == TimelineOrigin.trash;
final originalTheme = context.themeData;

View file

@ -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,
),
),
),

View file

@ -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),

View file

@ -150,6 +150,10 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> 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;

View file

@ -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);

View file

@ -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,

View file

@ -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(

View file

@ -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;

View file

@ -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<LocalThumbProvider>
with CancellableImageProviderMixin<LocalThumbProvider> {
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<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
@ -44,13 +52,13 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
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<LocalFullImageProvider>
@ -59,8 +67,38 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final Size size;
final AssetType assetType;
final bool isAnimated;
final int? width;
final int? height;
final String? checksum;
LocalFullImageProvider({required this.id, required this.assetType, required this.size, required this.isAnimated});
LocalFullImageProvider({
required this.id,
required this.assetType,
required this.size,
required this.isAnimated,
this.width,
this.height,
this.checksum,
});
Size _previewTarget(double dpr, bool previewIsFinal) =>
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<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@ -73,7 +111,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
return AnimatedImageStreamCompleter(
stream: _animatedCodec(key, decode),
scale: 1.0,
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id),
@ -86,7 +124,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id),
@ -108,7 +146,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
var request = this.request = LocalImageRequest(
localId: key.id,
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
size: _previewTarget(devicePixelRatio, !loadOriginal),
assetType: key.assetType,
);
yield* loadRequest(request, decode, isFinal: !loadOriginal);
@ -136,7 +174,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
final previewRequest = request = LocalImageRequest(
localId: key.id,
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
size: _previewTarget(devicePixelRatio, false),
assetType: key.assetType,
);
yield* loadRequest(previewRequest, decode, isFinal: false);
@ -163,11 +201,16 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
return true;
}
if (other is LocalFullImageProvider) {
return id == other.id && size == other.size && isAnimated == other.isAnimated;
return id == other.id &&
size == other.size &&
isAnimated == other.isAnimated &&
width == other.width &&
height == other.height &&
checksum == other.checksum;
}
return false;
}
@override
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode;
int get hashCode => Object.hash(id, size, isAnimated, width, height, checksum);
}

View file

@ -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(

View file

@ -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(

View file

@ -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<Shadow>? iconShadows;
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final theme = context.themeData;
final menuChildren = <Widget>[];

View file

@ -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(

View file

@ -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();
}

View file

@ -81,6 +81,10 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
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<AppLifeCycleEnum> {
await _ref.read(galleryPermissionNotifier.notifier).getGalleryPermissionStatus();
}
Future<void> _safeRun(Future<void> action, String debugName) async {
Future<void> _safeRun(Future<void> 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<AppLifeCycleEnum> {
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<AppLifeCycleEnum> {
// _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<AppLifeCycleEnum> {
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<AppLifeCycleEnum> {
Future<void> _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();
}

View file

@ -9,6 +9,7 @@ final backgroundSyncProvider = Provider<BackgroundSyncManager>((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<BackgroundSyncManager>((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);

View file

@ -255,19 +255,25 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
state = state.copyWith(isSyncing: isSyncing);
}
Future<void> startForegroundBackup(String userId) {
Future<void> 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<void>();
// A pause during the recount below nulls _cancelToken, so the run keeps its own reference.
final cancelToken = Completer<void>();
_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 +283,10 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
);
}
void stopForegroundBackup() {
void stopForegroundBackup({required String reason}) {
if (_cancelToken != null) {
_logger.info("Foreground backup cancelled: $reason");
}
_cancelToken?.complete();
_cancelToken = null;
_uploadSpeedManager.clear();
@ -333,6 +342,10 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
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);

View file

@ -1,3 +1,5 @@
// ignore_for_file: use-ref-and-state-synchronously
import 'dart:async';
import 'package:auto_route/auto_route.dart';

View file

@ -1,3 +1,5 @@
// ignore_for_file: use-ref-and-state-synchronously
import 'dart:async';
import 'package:collection/collection.dart';

View file

@ -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';

View file

@ -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';

View file

@ -1,3 +1,5 @@
// ignore_for_file: use-ref-and-state-synchronously
import 'dart:async';
import 'dart:io';

View file

@ -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;
});

View file

@ -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';

View file

@ -56,14 +56,16 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
@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 +86,8 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
.build(),
);
state = WebsocketState(isConnected: false, socket: socket);
socket.onConnect((_) {
dPrint(() => "Established Websocket Connection");
state = WebsocketState(isConnected: true, socket: socket);
@ -91,12 +95,12 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
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);
@ -187,7 +191,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
}
void _handleRemoteChange(dynamic _) {
unawaited(_ref.read(backgroundSyncProvider).syncRemote());
unawaited(_ref.read(backgroundSyncProvider).syncRemote(enqueue: true));
}
void _handleSyncAssetEditReadyV2(dynamic data) {

View file

@ -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 {

View file

@ -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),

View file

@ -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(

View file

@ -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,
);

View file

@ -28,7 +28,7 @@ class CastDialog extends ConsumerWidget {
width: 250,
height: 250,
child: FutureBuilder<List<(String, CastDestinationType, dynamic)>>(
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()]));

View file

@ -99,7 +99,7 @@ class _VideoControlsState extends ConsumerState<VideoControls> {
});
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(

View file

@ -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));

View file

@ -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) {

View file

@ -45,12 +45,12 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
Future<void> 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(

View file

@ -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(

View file

@ -17,13 +17,13 @@ Future<(Set<String>, Set<String>)?> showTagPickerModal({required BuildContext co
);
}
class _TagPickerModal extends HookConsumerWidget {
class _TagPickerModal extends HookWidget {
final Set<String>? initialSelection;
const _TagPickerModal({this.initialSelection});
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final selectedTagIds = useState<Set<String>>(initialSelection ?? {});
final newTagValues = useState<Set<String>>({});

View file

@ -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}';

View file

@ -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,

View file

@ -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<void> 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;
}

View file

@ -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<double>, 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<MapLibreMapController?>(null);
final styleLoaded = useState(false);

View file

@ -32,8 +32,8 @@ class AdvancedSettings extends HookConsumerWidget {
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final isManageMediaSupported = useState(false);
final manageMediaAndroidPermission = useState(false);
final levelId = useState<int>(ref.read(appConfigProvider).logLevel.index);
final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote);
final levelId = useState<int>(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();
}
}());

View file

@ -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<int, void>(tilesPerRow.value, (_, __) {
unawaited(ref.read(settingsProvider).write(.timelineTilesPerRow, tilesPerRow.value));
});

View file

@ -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<bool, void>(isOriginal.value, (_, __) {
unawaited(ref.read(settingsProvider).write(.imageLoadOriginal, isOriginal.value));
});

View file

@ -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<bool, void>(tapToNavigate.value, (_, __) {
unawaited(ref.read(settingsProvider).write(.viewerTapToNavigate, tapToNavigate.value));
});

View file

@ -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);

View file

@ -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);

View file

@ -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,

View file

@ -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),

Some files were not shown because too many files have changed in this diff Show more