Merge remote-tracking branch 'origin/main' into fix/gallery-app-view-intent-refresh

# Conflicts:
#	mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart
#	mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart
This commit is contained in:
Peter Ombodi 2026-08-06 16:53:03 +03:00
commit 5ff5050889
154 changed files with 1621 additions and 2381 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

@ -8,7 +8,6 @@ permissions: {}
jobs:
validate-release-label:
if: ${{ !startsWith(github.event.pull_request.head.ref, 'mergify/merge-queue/') }}
runs-on: ubuntu-latest
permissions:
issues: write

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

1
mobile/.gitignore vendored
View file

@ -32,6 +32,7 @@
.pub/
/build/
lib/**/*.drift.dart
lib/**/*.freezed.dart
lib/routing/router.gr.dart
test/drift/main/generated/

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

@ -152,12 +152,6 @@
This is used by the Flutter tool to
generate GeneratedPluginRegistrant.java -->
<meta-data android:name="flutterEmbedding" android:value="2" />
<!-- Disables default WorkManager initialization to use our custom initialization -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
<!-- Widgets -->
<receiver

View file

@ -3,17 +3,12 @@ package app.alextran.immich
import android.app.Application
import android.os.Handler
import android.os.Looper
import androidx.work.Configuration
import androidx.work.WorkManager
import app.alextran.immich.background.BackgroundEngineLock
import app.alextran.immich.background.BackgroundWorkerApiImpl
class ImmichApp : Application() {
override fun onCreate() {
super.onCreate()
val config = Configuration.Builder().build()
WorkManager.initialize(this, config)
// always start BackupWorker after WorkManager init; this fixes the following bug:
// After the process is killed (by user or system), the first trigger (taking a new picture) is lost.
// Thus, the BackupWorker is not started. If the system kills the process after each initialization
// (because of low memory etc.), the backup is never performed.

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

@ -1,3 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'album.model.freezed.dart';
enum AlbumAssetOrder {
// do not change this order!
asc,
@ -12,118 +17,20 @@ enum AlbumUserRole {
}
// Model for an album stored in the server
class RemoteAlbum {
final String id;
final String name;
final String ownerId;
final String description;
final DateTime createdAt;
final DateTime updatedAt;
final String? thumbnailAssetId;
final bool isActivityEnabled;
final AlbumAssetOrder order;
final int assetCount;
final String ownerName;
final bool isShared;
const RemoteAlbum({
required this.id,
required this.name,
required this.ownerId,
required this.description,
required this.createdAt,
required this.updatedAt,
this.thumbnailAssetId,
required this.isActivityEnabled,
required this.order,
required this.assetCount,
required this.ownerName,
required this.isShared,
});
@override
String toString() {
return '''Album {
id: $id,
name: $name,
ownerId: $ownerId,
description: $description,
createdAt: $createdAt,
updatedAt: $updatedAt,
isActivityEnabled: $isActivityEnabled,
order: $order,
thumbnailAssetId: ${thumbnailAssetId ?? "<NA>"}
assetCount: $assetCount
ownerName: $ownerName
isShared: $isShared
}''';
}
@override
bool operator ==(Object other) {
if (other is! RemoteAlbum) {
return false;
}
if (identical(this, other)) {
return true;
}
return id == other.id &&
name == other.name &&
ownerId == other.ownerId &&
description == other.description &&
createdAt == other.createdAt &&
updatedAt == other.updatedAt &&
thumbnailAssetId == other.thumbnailAssetId &&
isActivityEnabled == other.isActivityEnabled &&
order == other.order &&
assetCount == other.assetCount &&
ownerName == other.ownerName &&
isShared == other.isShared;
}
@override
int get hashCode {
return id.hashCode ^
name.hashCode ^
ownerId.hashCode ^
description.hashCode ^
createdAt.hashCode ^
updatedAt.hashCode ^
thumbnailAssetId.hashCode ^
isActivityEnabled.hashCode ^
order.hashCode ^
assetCount.hashCode ^
ownerName.hashCode ^
isShared.hashCode;
}
RemoteAlbum copyWith({
String? id,
String? name,
String? ownerId,
String? description,
DateTime? createdAt,
DateTime? updatedAt,
@freezed
abstract class RemoteAlbum with _$RemoteAlbum {
const factory RemoteAlbum({
required String id,
required String name,
required String ownerId,
required String description,
required DateTime createdAt,
required DateTime updatedAt,
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
int? assetCount,
String? ownerName,
bool? isShared,
}) {
return RemoteAlbum(
id: id ?? this.id,
name: name ?? this.name,
ownerId: ownerId ?? this.ownerId,
description: description ?? this.description,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
thumbnailAssetId: thumbnailAssetId ?? this.thumbnailAssetId,
isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled,
order: order ?? this.order,
assetCount: assetCount ?? this.assetCount,
ownerName: ownerName ?? this.ownerName,
isShared: isShared ?? this.isShared,
);
}
required bool isActivityEnabled,
required AlbumAssetOrder order,
required int assetCount,
required String ownerName,
required bool isShared,
}) = _RemoteAlbum;
}

View file

@ -1,3 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'local_album.model.freezed.dart';
enum BackupSelection {
// Used to sort albums based on the backupSelection
// selected -> none -> excluded
@ -7,85 +12,15 @@ enum BackupSelection {
excluded,
}
class LocalAlbum {
final String id;
final String name;
final DateTime updatedAt;
final bool isIosSharedAlbum;
final int assetCount;
final BackupSelection backupSelection;
final String? linkedRemoteAlbumId;
const LocalAlbum({
required this.id,
required this.name,
required this.updatedAt,
this.assetCount = 0,
this.backupSelection = BackupSelection.none,
this.isIosSharedAlbum = false,
this.linkedRemoteAlbumId,
});
LocalAlbum copyWith({
String? id,
String? name,
DateTime? updatedAt,
int? assetCount,
BackupSelection? backupSelection,
bool? isIosSharedAlbum,
@freezed
abstract class LocalAlbum with _$LocalAlbum {
const factory LocalAlbum({
required String id,
required String name,
required DateTime updatedAt,
@Default(0) int assetCount,
@Default(BackupSelection.none) BackupSelection backupSelection,
@Default(false) bool isIosSharedAlbum,
String? linkedRemoteAlbumId,
}) {
return LocalAlbum(
id: id ?? this.id,
name: name ?? this.name,
updatedAt: updatedAt ?? this.updatedAt,
assetCount: assetCount ?? this.assetCount,
backupSelection: backupSelection ?? this.backupSelection,
isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum,
linkedRemoteAlbumId: linkedRemoteAlbumId ?? this.linkedRemoteAlbumId,
);
}
@override
bool operator ==(Object other) {
if (other is! LocalAlbum) {
return false;
}
if (identical(this, other)) {
return true;
}
return other.id == id &&
other.name == name &&
other.updatedAt == updatedAt &&
other.assetCount == assetCount &&
other.backupSelection == backupSelection &&
other.isIosSharedAlbum == isIosSharedAlbum &&
other.linkedRemoteAlbumId == linkedRemoteAlbumId;
}
@override
int get hashCode {
return id.hashCode ^
name.hashCode ^
updatedAt.hashCode ^
assetCount.hashCode ^
backupSelection.hashCode ^
isIosSharedAlbum.hashCode ^
linkedRemoteAlbumId.hashCode;
}
@override
String toString() {
return '''LocalAlbum: {
id: $id,
name: $name,
updatedAt: $updatedAt,
assetCount: $assetCount,
backupSelection: $backupSelection,
isIosSharedAlbum: $isIosSharedAlbum
linkedRemoteAlbumId: $linkedRemoteAlbumId,
}''';
}
}) = _LocalAlbum;
}

View file

@ -1,100 +1,21 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'asset_face.model.freezed.dart';
// Model for an asset face stored in the server
class AssetFace {
final String id;
final String assetId;
final String? personId;
final int imageWidth;
final int imageHeight;
final int boundingBoxX1;
final int boundingBoxY1;
final int boundingBoxX2;
final int boundingBoxY2;
final String sourceType;
const AssetFace({
required this.id,
required this.assetId,
this.personId,
required this.imageWidth,
required this.imageHeight,
required this.boundingBoxX1,
required this.boundingBoxY1,
required this.boundingBoxX2,
required this.boundingBoxY2,
required this.sourceType,
});
AssetFace copyWith({
String? id,
String? assetId,
@freezed
abstract class AssetFace with _$AssetFace {
const factory AssetFace({
required String id,
required String assetId,
String? personId,
int? imageWidth,
int? imageHeight,
int? boundingBoxX1,
int? boundingBoxY1,
int? boundingBoxX2,
int? boundingBoxY2,
String? sourceType,
}) {
return AssetFace(
id: id ?? this.id,
assetId: assetId ?? this.assetId,
personId: personId ?? this.personId,
imageWidth: imageWidth ?? this.imageWidth,
imageHeight: imageHeight ?? this.imageHeight,
boundingBoxX1: boundingBoxX1 ?? this.boundingBoxX1,
boundingBoxY1: boundingBoxY1 ?? this.boundingBoxY1,
boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2,
boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2,
sourceType: sourceType ?? this.sourceType,
);
}
@override
String toString() {
return '''AssetFace {
id: $id,
assetId: $assetId,
personId: ${personId ?? "<NA>"},
imageWidth: $imageWidth,
imageHeight: $imageHeight,
boundingBoxX1: $boundingBoxX1,
boundingBoxY1: $boundingBoxY1,
boundingBoxX2: $boundingBoxX2,
boundingBoxY2: $boundingBoxY2,
sourceType: $sourceType,
}''';
}
@override
bool operator ==(covariant AssetFace other) {
if (identical(this, other)) {
return true;
}
return other.id == id &&
other.assetId == assetId &&
other.personId == personId &&
other.imageWidth == imageWidth &&
other.imageHeight == imageHeight &&
other.boundingBoxX1 == boundingBoxX1 &&
other.boundingBoxY1 == boundingBoxY1 &&
other.boundingBoxX2 == boundingBoxX2 &&
other.boundingBoxY2 == boundingBoxY2 &&
other.sourceType == sourceType;
}
@override
int get hashCode {
return id.hashCode ^
assetId.hashCode ^
personId.hashCode ^
imageWidth.hashCode ^
imageHeight.hashCode ^
boundingBoxX1.hashCode ^
boundingBoxY1.hashCode ^
boundingBoxX2.hashCode ^
boundingBoxY2.hashCode ^
sourceType.hashCode;
}
required int imageWidth,
required int imageHeight,
required int boundingBoxX1,
required int boundingBoxY1,
required int boundingBoxX2,
required int boundingBoxY2,
required String sourceType,
}) = _AssetFace;
}

View file

@ -1,26 +1,14 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
class AlbumConfig {
final AlbumSortMode sortMode;
final bool isReverse;
final bool isGrid;
part 'album_config.freezed.dart';
const AlbumConfig({this.sortMode = AlbumSortMode.mostRecent, this.isReverse = true, this.isGrid = false});
AlbumConfig copyWith({AlbumSortMode? sortMode, bool? isReverse, bool? isGrid}) => AlbumConfig(
sortMode: sortMode ?? this.sortMode,
isReverse: isReverse ?? this.isReverse,
isGrid: isGrid ?? this.isGrid,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is AlbumConfig && other.sortMode == sortMode && other.isReverse == isReverse && other.isGrid == isGrid);
@override
int get hashCode => Object.hash(sortMode, isReverse, isGrid);
@override
String toString() => 'AlbumConfig(sortMode: $sortMode, isReverse: $isReverse, isGrid: $isGrid)';
@freezed
abstract class AlbumConfig with _$AlbumConfig {
const factory AlbumConfig({
@Default(AlbumSortMode.mostRecent) AlbumSortMode sortMode,
@Default(true) bool isReverse,
@Default(false) bool isGrid,
}) = _AlbumConfig;
}

View file

@ -1,4 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/config/album_config.dart';
@ -19,107 +21,29 @@ import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/utils/semver.dart';
part 'app_config.freezed.dart';
const defaultConfig = AppConfig();
class AppConfig {
final LogLevel logLevel;
final ThemeConfig theme;
final CleanupConfig cleanup;
final MapConfig map;
final TimelineConfig timeline;
final ImageConfig image;
final ViewerConfig viewer;
final SlideshowConfig slideshow;
final AlbumConfig album;
final BackupConfig backup;
final NetworkConfig network;
final ShareConfig share;
final FeatureMessageConfig featureMessage;
@freezed
abstract class AppConfig with _$AppConfig {
const AppConfig._();
const AppConfig({
this.logLevel = .info,
this.theme = const .new(),
this.cleanup = const .new(),
this.map = const .new(),
this.timeline = const .new(),
this.image = const .new(),
this.viewer = const .new(),
this.slideshow = const .new(),
this.album = const .new(),
this.backup = const .new(),
this.network = const .new(),
this.share = const .new(),
this.featureMessage = const .new(),
});
AppConfig copyWith({
LogLevel? logLevel,
ThemeConfig? theme,
CleanupConfig? cleanup,
MapConfig? map,
TimelineConfig? timeline,
ImageConfig? image,
ViewerConfig? viewer,
SlideshowConfig? slideshow,
AlbumConfig? album,
BackupConfig? backup,
NetworkConfig? network,
ShareConfig? share,
FeatureMessageConfig? featureMessage,
}) => .new(
logLevel: logLevel ?? this.logLevel,
theme: theme ?? this.theme,
cleanup: cleanup ?? this.cleanup,
map: map ?? this.map,
timeline: timeline ?? this.timeline,
image: image ?? this.image,
viewer: viewer ?? this.viewer,
slideshow: slideshow ?? this.slideshow,
album: album ?? this.album,
backup: backup ?? this.backup,
network: network ?? this.network,
share: share ?? this.share,
featureMessage: featureMessage ?? this.featureMessage,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is AppConfig &&
other.logLevel == logLevel &&
other.theme == theme &&
other.cleanup == cleanup &&
other.map == map &&
other.timeline == timeline &&
other.image == image &&
other.viewer == viewer &&
other.slideshow == slideshow &&
other.album == album &&
other.backup == backup &&
other.network == network &&
other.share == share &&
other.featureMessage == featureMessage);
@override
int get hashCode => Object.hash(
logLevel,
theme,
cleanup,
map,
timeline,
image,
viewer,
slideshow,
album,
backup,
network,
share,
featureMessage,
);
@override
String toString() =>
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share, featureMessage: $featureMessage)';
const factory AppConfig({
@Default(LogLevel.info) LogLevel logLevel,
@Default(ThemeConfig()) ThemeConfig theme,
@Default(CleanupConfig()) CleanupConfig cleanup,
@Default(MapConfig()) MapConfig map,
@Default(TimelineConfig()) TimelineConfig timeline,
@Default(ImageConfig()) ImageConfig image,
@Default(ViewerConfig()) ViewerConfig viewer,
@Default(SlideshowConfig()) SlideshowConfig slideshow,
@Default(AlbumConfig()) AlbumConfig album,
@Default(BackupConfig()) BackupConfig backup,
@Default(NetworkConfig()) NetworkConfig network,
@Default(ShareConfig()) ShareConfig share,
@Default(FeatureMessageConfig()) FeatureMessageConfig featureMessage,
}) = _AppConfig;
T read<T>(SettingsKey<T> key) =>
(switch (key) {

View file

@ -1,52 +1,16 @@
class BackupConfig {
final bool enabled;
final bool useCellularForVideos;
final bool useCellularForPhotos;
final bool requireCharging;
final int triggerDelay;
final bool syncAlbums;
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
const BackupConfig({
this.enabled = false,
this.useCellularForVideos = false,
this.useCellularForPhotos = false,
this.requireCharging = false,
this.triggerDelay = 30,
this.syncAlbums = false,
});
part 'backup_config.freezed.dart';
BackupConfig copyWith({
bool? enabled,
bool? useCellularForVideos,
bool? useCellularForPhotos,
bool? requireCharging,
int? triggerDelay,
bool? syncAlbums,
}) => BackupConfig(
enabled: enabled ?? this.enabled,
useCellularForVideos: useCellularForVideos ?? this.useCellularForVideos,
useCellularForPhotos: useCellularForPhotos ?? this.useCellularForPhotos,
requireCharging: requireCharging ?? this.requireCharging,
triggerDelay: triggerDelay ?? this.triggerDelay,
syncAlbums: syncAlbums ?? this.syncAlbums,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is BackupConfig &&
other.enabled == enabled &&
other.useCellularForVideos == useCellularForVideos &&
other.useCellularForPhotos == useCellularForPhotos &&
other.requireCharging == requireCharging &&
other.triggerDelay == triggerDelay &&
other.syncAlbums == syncAlbums);
@override
int get hashCode =>
Object.hash(enabled, useCellularForVideos, useCellularForPhotos, requireCharging, triggerDelay, syncAlbums);
@override
String toString() =>
'BackupConfig(enabled: $enabled, useCellularForVideos: $useCellularForVideos, useCellularForPhotos: $useCellularForPhotos, requireCharging: $requireCharging, triggerDelay: $triggerDelay, syncAlbums: $syncAlbums)';
@freezed
abstract class BackupConfig with _$BackupConfig {
const factory BackupConfig({
@Default(false) bool enabled,
@Default(false) bool useCellularForVideos,
@Default(false) bool useCellularForPhotos,
@Default(false) bool requireCharging,
@Default(30) int triggerDelay,
@Default(false) bool syncAlbums,
}) = _BackupConfig;
}

View file

@ -1,20 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/utils/semver.dart';
class FeatureMessageConfig {
final SemVer seenRelease;
part 'feature_message_config.freezed.dart';
const FeatureMessageConfig({this.seenRelease = const SemVer(major: 0, minor: 0, patch: 0)});
FeatureMessageConfig copyWith({SemVer? seenRelease}) =>
FeatureMessageConfig(seenRelease: seenRelease ?? this.seenRelease);
@override
bool operator ==(Object other) =>
identical(this, other) || (other is FeatureMessageConfig && other.seenRelease == seenRelease);
@override
int get hashCode => seenRelease.hashCode;
@override
String toString() => 'FeatureMessageConfig(seenRelease: $seenRelease)';
@freezed
abstract class FeatureMessageConfig with _$FeatureMessageConfig {
const factory FeatureMessageConfig({@Default(SemVer(major: 0, minor: 0, patch: 0)) SemVer seenRelease}) =
_FeatureMessageConfig;
}

View file

@ -1,20 +1,9 @@
class ImageConfig {
final bool preferRemote;
final bool loadOriginal;
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
const ImageConfig({this.preferRemote = false, this.loadOriginal = false});
part 'image_config.freezed.dart';
ImageConfig copyWith({bool? preferRemote, bool? loadOriginal}) =>
ImageConfig(preferRemote: preferRemote ?? this.preferRemote, loadOriginal: loadOriginal ?? this.loadOriginal);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is ImageConfig && other.preferRemote == preferRemote && other.loadOriginal == loadOriginal);
@override
int get hashCode => Object.hash(preferRemote, loadOriginal);
@override
String toString() => 'ImageConfig(preferRemoteImage: $preferRemote, loadOriginal: $loadOriginal)';
@freezed
abstract class ImageConfig with _$ImageConfig {
const factory ImageConfig({@Default(false) bool preferRemote, @Default(false) bool loadOriginal}) = _ImageConfig;
}

View file

@ -1,18 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/constants/enums.dart';
class ShareConfig {
final ShareAssetType fileType;
part 'share_config.freezed.dart';
const ShareConfig({this.fileType = ShareAssetType.original});
ShareConfig copyWith({ShareAssetType? fileType}) => ShareConfig(fileType: fileType ?? this.fileType);
@override
bool operator ==(Object other) => identical(this, other) || (other is ShareConfig && other.fileType == fileType);
@override
int get hashCode => fileType.hashCode;
@override
String toString() => 'ShareConfig(fileType: $fileType)';
@freezed
abstract class ShareConfig with _$ShareConfig {
const factory ShareConfig({@Default(ShareAssetType.original) ShareAssetType fileType}) = _ShareConfig;
}

View file

@ -1,38 +1,15 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/constants/enums.dart';
class SlideshowConfig {
final bool repeat;
final int duration;
final SlideshowLook look;
final SlideshowDirection direction;
part 'slideshow_config.freezed.dart';
const SlideshowConfig({
this.repeat = true,
this.duration = 5,
this.look = SlideshowLook.blurredBackground,
this.direction = SlideshowDirection.forward,
});
SlideshowConfig copyWith({bool? repeat, int? duration, SlideshowLook? look, SlideshowDirection? direction}) =>
SlideshowConfig(
repeat: repeat ?? this.repeat,
duration: duration ?? this.duration,
look: look ?? this.look,
direction: direction ?? this.direction,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is SlideshowConfig &&
other.repeat == repeat &&
other.duration == duration &&
other.look == look &&
other.direction == direction);
@override
int get hashCode => Object.hash(repeat, duration, look, direction);
@override
String toString() => 'SlideshowConfig(repeat: $repeat, duration: $duration, look: $look, direction: $direction)';
@freezed
abstract class SlideshowConfig with _$SlideshowConfig {
const factory SlideshowConfig({
@Default(true) bool repeat,
@Default(5) int duration,
@Default(SlideshowLook.blurredBackground) SlideshowLook look,
@Default(SlideshowDirection.forward) SlideshowDirection direction,
}) = _SlideshowConfig;
}

View file

@ -1,44 +1,16 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/constants/colors.dart';
class ThemeConfig {
final ThemeMode mode;
final ImmichColorPreset primaryColor;
final bool dynamicTheme;
final bool colorfulInterface;
part 'theme_config.freezed.dart';
const ThemeConfig({
this.mode = .system,
this.primaryColor = .indigo,
this.dynamicTheme = false,
this.colorfulInterface = true,
});
ThemeConfig copyWith({
ThemeMode? mode,
ImmichColorPreset? primaryColor,
bool? dynamicTheme,
bool? colorfulInterface,
}) => .new(
mode: mode ?? this.mode,
primaryColor: primaryColor ?? this.primaryColor,
dynamicTheme: dynamicTheme ?? this.dynamicTheme,
colorfulInterface: colorfulInterface ?? this.colorfulInterface,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is ThemeConfig &&
other.mode == mode &&
other.primaryColor == primaryColor &&
other.dynamicTheme == dynamicTheme &&
other.colorfulInterface == colorfulInterface);
@override
int get hashCode => Object.hash(mode, primaryColor, dynamicTheme, colorfulInterface);
@override
String toString() =>
'ThemeConfig(mode: $mode, primaryColor: $primaryColor, dynamicTheme: $dynamicTheme, colorfulInterface: $colorfulInterface)';
@freezed
abstract class ThemeConfig with _$ThemeConfig {
const factory ThemeConfig({
@Default(ThemeMode.system) ThemeMode mode,
@Default(ImmichColorPreset.indigo) ImmichColorPreset primaryColor,
@Default(false) bool dynamicTheme,
@Default(true) bool colorfulInterface,
}) = _ThemeConfig;
}

View file

@ -1,30 +1,14 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
class TimelineConfig {
final int tilesPerRow;
final GroupAssetsBy groupAssetsBy;
final bool storageIndicator;
part 'timeline_config.freezed.dart';
const TimelineConfig({this.tilesPerRow = 4, this.groupAssetsBy = GroupAssetsBy.day, this.storageIndicator = true});
TimelineConfig copyWith({int? tilesPerRow, GroupAssetsBy? groupAssetsBy, bool? storageIndicator}) => TimelineConfig(
tilesPerRow: tilesPerRow ?? this.tilesPerRow,
groupAssetsBy: groupAssetsBy ?? this.groupAssetsBy,
storageIndicator: storageIndicator ?? this.storageIndicator,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is TimelineConfig &&
other.tilesPerRow == tilesPerRow &&
other.groupAssetsBy == groupAssetsBy &&
other.storageIndicator == storageIndicator);
@override
int get hashCode => Object.hash(tilesPerRow, groupAssetsBy, storageIndicator);
@override
String toString() =>
'TimelineConfig(tilesPerRow: $tilesPerRow, groupAssetsBy: $groupAssetsBy, storageIndicator: $storageIndicator)';
@freezed
abstract class TimelineConfig with _$TimelineConfig {
const factory TimelineConfig({
@Default(4) int tilesPerRow,
@Default(GroupAssetsBy.day) GroupAssetsBy groupAssetsBy,
@Default(true) bool storageIndicator,
}) = _TimelineConfig;
}

View file

@ -1,37 +1,14 @@
class ViewerConfig {
final bool loopVideo;
final bool loadOriginalVideo;
final bool autoPlayVideo;
final bool tapToNavigate;
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
const ViewerConfig({
this.loopVideo = true,
this.loadOriginalVideo = false,
this.autoPlayVideo = true,
this.tapToNavigate = false,
});
part 'viewer_config.freezed.dart';
ViewerConfig copyWith({bool? loopVideo, bool? loadOriginalVideo, bool? autoPlayVideo, bool? tapToNavigate}) =>
ViewerConfig(
loopVideo: loopVideo ?? this.loopVideo,
loadOriginalVideo: loadOriginalVideo ?? this.loadOriginalVideo,
autoPlayVideo: autoPlayVideo ?? this.autoPlayVideo,
tapToNavigate: tapToNavigate ?? this.tapToNavigate,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is ViewerConfig &&
other.loopVideo == loopVideo &&
other.loadOriginalVideo == loadOriginalVideo &&
other.autoPlayVideo == autoPlayVideo &&
other.tapToNavigate == tapToNavigate);
@override
int get hashCode => Object.hash(loopVideo, loadOriginalVideo, autoPlayVideo, tapToNavigate);
@override
String toString() =>
'ViewerConfig(loopVideo: $loopVideo, loadOriginalVideo: $loadOriginalVideo, autoPlayVideo: $autoPlayVideo, tapToNavigate: $tapToNavigate)';
@freezed
abstract class ViewerConfig with _$ViewerConfig {
const factory ViewerConfig({
@Default(true) bool loopVideo,
@Default(false) bool loadOriginalVideo,
@Default(true) bool autoPlayVideo,
@Default(false) bool tapToNavigate,
}) = _ViewerConfig;
}

View file

@ -1,30 +1,40 @@
class ExifInfo {
final int? assetId;
final int? fileSize;
final String? description;
final bool isFlipped;
final String? orientation;
final String? timeZone;
final DateTime? dateTimeOriginal;
final int? rating;
final int? width;
final int? height;
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
// GPS
final double? latitude;
final double? longitude;
final String? city;
final String? state;
final String? country;
part 'exif.model.freezed.dart';
// Camera related
final String? make;
final String? model;
final String? lens;
final double? f;
final double? mm;
final int? iso;
final double? exposureSeconds;
@freezed
abstract class ExifInfo with _$ExifInfo {
const ExifInfo._();
const factory ExifInfo({
int? assetId,
int? fileSize,
String? description,
@Default(false) bool isFlipped,
String? orientation,
String? timeZone,
DateTime? dateTimeOriginal,
int? rating,
int? width,
int? height,
// GPS
double? latitude,
double? longitude,
String? city,
String? state,
String? country,
// Camera related
String? make,
String? model,
String? lens,
double? f,
double? mm,
int? iso,
double? exposureSeconds,
}) = _ExifInfo;
bool get hasCoordinates => latitude != null && longitude != null && latitude != 0 && longitude != 0;
@ -41,162 +51,4 @@ class ExifInfo {
String get fNumber => f == null ? "" : f!.toStringAsFixed(1);
String get focalLength => mm == null ? "" : mm!.toStringAsFixed(3);
const ExifInfo({
this.assetId,
this.fileSize,
this.description,
this.orientation,
this.timeZone,
this.dateTimeOriginal,
this.rating,
this.width,
this.height,
this.isFlipped = false,
this.latitude,
this.longitude,
this.city,
this.state,
this.country,
this.make,
this.model,
this.lens,
this.f,
this.mm,
this.iso,
this.exposureSeconds,
});
@override
bool operator ==(covariant ExifInfo other) {
if (identical(this, other)) {
return true;
}
return other.fileSize == fileSize &&
other.description == description &&
other.isFlipped == isFlipped &&
other.orientation == orientation &&
other.timeZone == timeZone &&
other.dateTimeOriginal == dateTimeOriginal &&
other.rating == rating &&
other.width == width &&
other.height == height &&
other.latitude == latitude &&
other.longitude == longitude &&
other.city == city &&
other.state == state &&
other.country == country &&
other.make == make &&
other.model == model &&
other.lens == lens &&
other.f == f &&
other.mm == mm &&
other.iso == iso &&
other.exposureSeconds == exposureSeconds &&
other.assetId == assetId;
}
@override
int get hashCode {
return fileSize.hashCode ^
description.hashCode ^
orientation.hashCode ^
isFlipped.hashCode ^
timeZone.hashCode ^
dateTimeOriginal.hashCode ^
rating.hashCode ^
width.hashCode ^
height.hashCode ^
latitude.hashCode ^
longitude.hashCode ^
city.hashCode ^
state.hashCode ^
country.hashCode ^
make.hashCode ^
model.hashCode ^
lens.hashCode ^
f.hashCode ^
mm.hashCode ^
iso.hashCode ^
exposureSeconds.hashCode ^
assetId.hashCode;
}
@override
String toString() {
return '''{
fileSize: ${fileSize ?? 'NA'},
description: ${description ?? 'NA'},
orientation: ${orientation ?? 'NA'},
isFlipped: $isFlipped,
timeZone: ${timeZone ?? 'NA'},
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},
rating: ${rating ?? 'NA'},
width: ${width ?? 'NA'},
height: ${height ?? 'NA'},
latitude: ${latitude ?? 'NA'},
longitude: ${longitude ?? 'NA'},
city: ${city ?? 'NA'},
state: ${state ?? 'NA'},
country: ${country ?? '<NA>'},
make: ${make ?? 'NA'},
model: ${model ?? 'NA'},
lens: ${lens ?? 'NA'},
f: ${f ?? 'NA'},
mm: ${mm ?? '<NA>'},
iso: ${iso ?? 'NA'},
exposureSeconds: ${exposureSeconds ?? 'NA'},
}''';
}
ExifInfo copyWith({
int? assetId,
int? fileSize,
String? description,
String? orientation,
String? timeZone,
DateTime? dateTimeOriginal,
int? rating,
int? width,
int? height,
double? latitude,
double? longitude,
String? city,
String? state,
String? country,
bool? isFlipped,
String? make,
String? model,
String? lens,
double? f,
double? mm,
int? iso,
double? exposureSeconds,
}) {
return ExifInfo(
assetId: assetId ?? this.assetId,
fileSize: fileSize ?? this.fileSize,
description: description ?? this.description,
orientation: orientation ?? this.orientation,
timeZone: timeZone ?? this.timeZone,
dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
rating: rating ?? this.rating,
width: width ?? this.width,
height: height ?? this.height,
isFlipped: isFlipped ?? this.isFlipped,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
city: city ?? this.city,
state: state ?? this.state,
country: country ?? this.country,
make: make ?? this.make,
model: model ?? this.model,
lens: lens ?? this.lens,
f: f ?? this.f,
mm: mm ?? this.mm,
iso: iso ?? this.iso,
exposureSeconds: exposureSeconds ?? this.exposureSeconds,
);
}
}

View file

@ -1,128 +1,24 @@
class Ocr {
final String id;
final String assetId;
final double x1;
final double y1;
final double x2;
final double y2;
final double x3;
final double y3;
final double x4;
final double y4;
final double boxScore;
final double textScore;
final String text;
final bool isVisible;
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
const Ocr({
required this.id,
required this.assetId,
required this.x1,
required this.y1,
required this.x2,
required this.y2,
required this.x3,
required this.y3,
required this.x4,
required this.y4,
required this.boxScore,
required this.textScore,
required this.text,
required this.isVisible,
});
part 'ocr.model.freezed.dart';
Ocr copyWith({
String? id,
String? assetId,
double? x1,
double? y1,
double? x2,
double? y2,
double? x3,
double? y3,
double? x4,
double? y4,
double? boxScore,
double? textScore,
String? text,
bool? isVisible,
}) {
return Ocr(
id: id ?? this.id,
assetId: assetId ?? this.assetId,
x1: x1 ?? this.x1,
y1: y1 ?? this.y1,
x2: x2 ?? this.x2,
y2: y2 ?? this.y2,
x3: x3 ?? this.x3,
y3: y3 ?? this.y3,
x4: x4 ?? this.x4,
y4: y4 ?? this.y4,
boxScore: boxScore ?? this.boxScore,
textScore: textScore ?? this.textScore,
text: text ?? this.text,
isVisible: isVisible ?? this.isVisible,
);
}
@override
String toString() {
return '''Ocr {
id: $id,
assetId: $assetId,
x1: $x1,
y1: $y1,
x2: $x2,
y2: $y2,
x3: $x3,
y3: $y3,
x4: $x4,
y4: $y4,
boxScore: $boxScore,
textScore: $textScore,
text: $text,
isVisible: $isVisible
}''';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is Ocr &&
other.id == id &&
other.assetId == assetId &&
other.x1 == x1 &&
other.y1 == y1 &&
other.x2 == x2 &&
other.y2 == y2 &&
other.x3 == x3 &&
other.y3 == y3 &&
other.x4 == x4 &&
other.y4 == y4 &&
other.boxScore == boxScore &&
other.textScore == textScore &&
other.text == text &&
other.isVisible == isVisible;
}
@override
int get hashCode {
return id.hashCode ^
assetId.hashCode ^
x1.hashCode ^
y1.hashCode ^
x2.hashCode ^
y2.hashCode ^
x3.hashCode ^
y3.hashCode ^
x4.hashCode ^
y4.hashCode ^
boxScore.hashCode ^
textScore.hashCode ^
text.hashCode ^
isVisible.hashCode;
}
@freezed
abstract class Ocr with _$Ocr {
const factory Ocr({
required String id,
required String assetId,
required double x1,
required double y1,
required double x2,
required double y2,
required double x3,
required double y3,
required double x4,
required double y4,
required double boxScore,
required double textScore,
required String text,
required bool isVisible,
}) = _Ocr;
}

View file

@ -1,5 +1,10 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'person.model.freezed.dart';
// TODO: Remove PersonDto once Isar is removed
class PersonDto {
const PersonDto({
@ -93,102 +98,18 @@ class PersonDto {
}
// Model for a person stored in the server
class DriftPerson {
final String id;
final DateTime createdAt;
final DateTime updatedAt;
final String ownerId;
final String name;
final String? faceAssetId;
final bool isFavorite;
final bool isHidden;
final String? color;
final DateTime? birthDate;
const DriftPerson({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.ownerId,
required this.name,
this.faceAssetId,
required this.isFavorite,
required this.isHidden,
required this.color,
this.birthDate,
});
DriftPerson copyWith({
String? id,
DateTime? createdAt,
DateTime? updatedAt,
String? ownerId,
String? name,
@freezed
abstract class DriftPerson with _$DriftPerson {
const factory DriftPerson({
required String id,
required DateTime createdAt,
required DateTime updatedAt,
required String ownerId,
required String name,
String? faceAssetId,
bool? isFavorite,
bool? isHidden,
String? color,
required bool isFavorite,
required bool isHidden,
required String? color,
DateTime? birthDate,
}) {
return DriftPerson(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
ownerId: ownerId ?? this.ownerId,
name: name ?? this.name,
faceAssetId: faceAssetId ?? this.faceAssetId,
isFavorite: isFavorite ?? this.isFavorite,
isHidden: isHidden ?? this.isHidden,
color: color ?? this.color,
birthDate: birthDate ?? this.birthDate,
);
}
@override
String toString() {
return '''Person {
id: $id,
createdAt: $createdAt,
updatedAt: $updatedAt,
ownerId: $ownerId,
name: $name,
faceAssetId: ${faceAssetId ?? "<NA>"},
isFavorite: $isFavorite,
isHidden: $isHidden,
color: ${color ?? "<NA>"},
birthDate: ${birthDate ?? "<NA>"}
}''';
}
@override
bool operator ==(covariant DriftPerson other) {
if (identical(this, other)) {
return true;
}
return other.id == id &&
other.createdAt == createdAt &&
other.updatedAt == updatedAt &&
other.ownerId == ownerId &&
other.name == name &&
other.faceAssetId == faceAssetId &&
other.isFavorite == isFavorite &&
other.isHidden == isHidden &&
other.color == color &&
other.birthDate == birthDate;
}
@override
int get hashCode {
return id.hashCode ^
createdAt.hashCode ^
updatedAt.hashCode ^
ownerId.hashCode ^
name.hashCode ^
faceAssetId.hashCode ^
isFavorite.hashCode ^
isHidden.hashCode ^
color.hashCode ^
birthDate.hashCode;
}
}) = _DriftPerson;
}

View file

@ -1,57 +1,18 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'stack.model.freezed.dart';
// Model for a stack stored in the server
class Stack {
final String id;
final DateTime createdAt;
final DateTime updatedAt;
final String ownerId;
final String primaryAssetId;
const Stack({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.ownerId,
required this.primaryAssetId,
});
Stack copyWith({String? id, DateTime? createdAt, DateTime? updatedAt, String? ownerId, String? primaryAssetId}) {
return Stack(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
ownerId: ownerId ?? this.ownerId,
primaryAssetId: primaryAssetId ?? this.primaryAssetId,
);
}
@override
String toString() {
return '''Stack {
id: $id,
createdAt: $createdAt,
updatedAt: $updatedAt,
ownerId: $ownerId,
primaryAssetId: $primaryAssetId
}''';
}
@override
bool operator ==(covariant Stack other) {
if (identical(this, other)) {
return true;
}
return other.id == id &&
other.createdAt == createdAt &&
other.updatedAt == updatedAt &&
other.ownerId == ownerId &&
other.primaryAssetId == primaryAssetId;
}
@override
int get hashCode {
return id.hashCode ^ createdAt.hashCode ^ updatedAt.hashCode ^ ownerId.hashCode ^ primaryAssetId.hashCode;
}
@freezed
abstract class Stack with _$Stack {
const factory Stack({
required String id,
required DateTime createdAt,
required DateTime updatedAt,
required String ownerId,
required String primaryAssetId,
}) = _Stack;
}
class StackResponse {

View file

@ -1,5 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
part 'user_metadata.model.freezed.dart';
enum UserMetadataKey {
// do not change this order!
onboarding,
@ -224,61 +228,17 @@ licenseKey: $licenseKey,
}
// Model for a user metadata stored in the server
class UserMetadata {
final String userId;
final UserMetadataKey key;
final Onboarding? onboarding;
final Preferences? preferences;
final License? license;
const UserMetadata({required this.userId, required this.key, this.onboarding, this.preferences, this.license})
: assert(
onboarding != null || preferences != null || license != null,
'One of onboarding, preferences and license must be provided',
);
UserMetadata copyWith({
String? userId,
UserMetadataKey? key,
@freezed
abstract class UserMetadata with _$UserMetadata {
@Assert(
'onboarding != null || preferences != null || license != null',
'One of onboarding, preferences and license must be provided',
)
const factory UserMetadata({
required String userId,
required UserMetadataKey key,
Onboarding? onboarding,
Preferences? preferences,
License? license,
}) {
return UserMetadata(
userId: userId ?? this.userId,
key: key ?? this.key,
onboarding: onboarding ?? this.onboarding,
preferences: preferences ?? this.preferences,
license: license ?? this.license,
);
}
@override
String toString() {
return '''UserMetadata: {
userId: $userId,
key: $key,
onboarding: ${onboarding ?? "<NA>"},
preferences: ${preferences ?? "<NA>"},
license: ${license ?? "<NA>"},
}''';
}
@override
bool operator ==(covariant UserMetadata other) {
if (identical(this, other)) {
return true;
}
return other.userId == userId &&
other.key == key &&
other.onboarding == onboarding &&
other.preferences == preferences &&
other.license == license;
}
@override
int get hashCode {
return userId.hashCode ^ key.hashCode ^ onboarding.hashCode ^ preferences.hashCode ^ license.hashCode;
}
}) = _UserMetadata;
}

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

@ -1,65 +1,21 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
part 'activity.model.freezed.dart';
enum ActivityType { comment, like }
class Activity {
final String id;
final String? assetId;
final String? comment;
final DateTime createdAt;
final ActivityType type;
final UserDto user;
const Activity({
required this.id,
this.assetId,
this.comment,
required this.createdAt,
required this.type,
required this.user,
});
Activity copyWith({
String? id,
@freezed
abstract class Activity with _$Activity {
const factory Activity({
required String id,
String? assetId,
String? comment,
DateTime? createdAt,
ActivityType? type,
UserDto? user,
}) {
return Activity(
id: id ?? this.id,
assetId: assetId ?? this.assetId,
comment: comment ?? this.comment,
createdAt: createdAt ?? this.createdAt,
type: type ?? this.type,
user: user ?? this.user,
);
}
@override
String toString() {
return 'Activity(id: $id, assetId: $assetId, comment: $comment, createdAt: $createdAt, type: $type, user: $user)';
}
@override
bool operator ==(covariant Activity other) {
if (identical(this, other)) {
return true;
}
return other.id == id &&
other.assetId == assetId &&
other.comment == comment &&
other.createdAt == createdAt &&
other.type == type &&
other.user == user;
}
@override
int get hashCode {
return id.hashCode ^ assetId.hashCode ^ comment.hashCode ^ createdAt.hashCode ^ type.hashCode ^ user.hashCode;
}
required DateTime createdAt,
required ActivityType type,
required UserDto user,
}) = _Activity;
}
class ActivityStats {

View file

@ -1,71 +1,17 @@
class AuthState {
final String deviceId;
final String userId;
final String userEmail;
final bool isAuthenticated;
final String name;
final bool isAdmin;
final String profileImagePath;
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
const AuthState({
required this.deviceId,
required this.userId,
required this.userEmail,
required this.isAuthenticated,
required this.name,
required this.isAdmin,
required this.profileImagePath,
});
part 'auth_state.model.freezed.dart';
AuthState copyWith({
String? deviceId,
String? userId,
String? userEmail,
bool? isAuthenticated,
String? name,
bool? isAdmin,
String? profileImagePath,
}) {
return AuthState(
deviceId: deviceId ?? this.deviceId,
userId: userId ?? this.userId,
userEmail: userEmail ?? this.userEmail,
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
name: name ?? this.name,
isAdmin: isAdmin ?? this.isAdmin,
profileImagePath: profileImagePath ?? this.profileImagePath,
);
}
@override
String toString() {
return 'AuthenticationState(deviceId: $deviceId, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, name: $name, isAdmin: $isAdmin, profileImagePath: $profileImagePath)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is AuthState &&
other.deviceId == deviceId &&
other.userId == userId &&
other.userEmail == userEmail &&
other.isAuthenticated == isAuthenticated &&
other.name == name &&
other.isAdmin == isAdmin &&
other.profileImagePath == profileImagePath;
}
@override
int get hashCode {
return deviceId.hashCode ^
userId.hashCode ^
userEmail.hashCode ^
isAuthenticated.hashCode ^
name.hashCode ^
isAdmin.hashCode ^
profileImagePath.hashCode;
}
@freezed
abstract class AuthState with _$AuthState {
const factory AuthState({
required String deviceId,
required String userId,
required String userEmail,
required bool isAuthenticated,
required String name,
required bool isAdmin,
required String profileImagePath,
}) = _AuthState;
}

View file

@ -1,29 +1,14 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:openapi/api.dart';
class MapMarker {
final LatLng latLng;
final String assetRemoteId;
const MapMarker({required this.latLng, required this.assetRemoteId});
part 'map_marker.model.freezed.dart';
MapMarker copyWith({LatLng? latLng, String? assetRemoteId}) {
return MapMarker(latLng: latLng ?? this.latLng, assetRemoteId: assetRemoteId ?? this.assetRemoteId);
}
@freezed
abstract class MapMarker with _$MapMarker {
const factory MapMarker({required LatLng latLng, required String assetRemoteId}) = _MapMarker;
MapMarker.fromDto(MapMarkerResponseDto dto) : latLng = LatLng(dto.lat, dto.lon), assetRemoteId = dto.id;
@override
String toString() => 'MapMarker(latLng: $latLng, assetRemoteId: $assetRemoteId)';
@override
bool operator ==(covariant MapMarker other) {
if (identical(this, other)) {
return true;
}
return other.latLng == latLng && other.assetRemoteId == assetRemoteId;
}
@override
int get hashCode => latLng.hashCode ^ assetRemoteId.hashCode;
factory MapMarker.fromDto(MapMarkerResponseDto dto) =>
MapMarker(latLng: LatLng(dto.lat, dto.lon), assetRemoteId: dto.id);
}

View file

@ -1,79 +1,20 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class MapState {
final ThemeMode themeMode;
final bool showFavoriteOnly;
final bool includeArchived;
final bool withPartners;
final int relativeTime;
final bool shouldRefetchMarkers;
final AsyncValue<String> lightStyleFetched;
final AsyncValue<String> darkStyleFetched;
part 'map_state.model.freezed.dart';
const MapState({
this.themeMode = ThemeMode.system,
this.showFavoriteOnly = false,
this.includeArchived = false,
this.withPartners = false,
this.relativeTime = 0,
this.shouldRefetchMarkers = false,
this.lightStyleFetched = const AsyncLoading(),
this.darkStyleFetched = const AsyncLoading(),
});
MapState copyWith({
ThemeMode? themeMode,
bool? showFavoriteOnly,
bool? includeArchived,
bool? withPartners,
int? relativeTime,
bool? shouldRefetchMarkers,
AsyncValue<String>? lightStyleFetched,
AsyncValue<String>? darkStyleFetched,
}) {
return MapState(
themeMode: themeMode ?? this.themeMode,
showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly,
includeArchived: includeArchived ?? this.includeArchived,
withPartners: withPartners ?? this.withPartners,
relativeTime: relativeTime ?? this.relativeTime,
shouldRefetchMarkers: shouldRefetchMarkers ?? this.shouldRefetchMarkers,
lightStyleFetched: lightStyleFetched ?? this.lightStyleFetched,
darkStyleFetched: darkStyleFetched ?? this.darkStyleFetched,
);
}
@override
String toString() {
return 'MapState(themeMode: $themeMode, showFavoriteOnly: $showFavoriteOnly, includeArchived: $includeArchived, withPartners: $withPartners, relativeTime: $relativeTime, shouldRefetchMarkers: $shouldRefetchMarkers, lightStyleFetched: $lightStyleFetched, darkStyleFetched: $darkStyleFetched)';
}
@override
bool operator ==(covariant MapState other) {
if (identical(this, other)) {
return true;
}
return other.themeMode == themeMode &&
other.showFavoriteOnly == showFavoriteOnly &&
other.includeArchived == includeArchived &&
other.withPartners == withPartners &&
other.relativeTime == relativeTime &&
other.shouldRefetchMarkers == shouldRefetchMarkers &&
other.lightStyleFetched == lightStyleFetched &&
other.darkStyleFetched == darkStyleFetched;
}
@override
int get hashCode {
return themeMode.hashCode ^
showFavoriteOnly.hashCode ^
includeArchived.hashCode ^
withPartners.hashCode ^
relativeTime.hashCode ^
shouldRefetchMarkers.hashCode ^
lightStyleFetched.hashCode ^
darkStyleFetched.hashCode;
}
@freezed
abstract class MapState with _$MapState {
const factory MapState({
@Default(ThemeMode.system) ThemeMode themeMode,
@Default(false) bool showFavoriteOnly,
@Default(false) bool includeArchived,
@Default(false) bool withPartners,
@Default(0) int relativeTime,
@Default(false) bool shouldRefetchMarkers,
@Default(AsyncLoading<String>()) AsyncValue<String> lightStyleFetched,
@Default(AsyncLoading<String>()) AsyncValue<String> darkStyleFetched,
}) = _MapState;
}

View file

@ -1,53 +1,22 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:openapi/api.dart';
class ServerDiskInfo {
final String diskAvailable;
final String diskSize;
final String diskUse;
final double diskUsagePercentage;
part 'server_disk_info.model.freezed.dart';
const ServerDiskInfo({
required this.diskAvailable,
required this.diskSize,
required this.diskUse,
required this.diskUsagePercentage,
});
@freezed
abstract class ServerDiskInfo with _$ServerDiskInfo {
const factory ServerDiskInfo({
required String diskAvailable,
required String diskSize,
required String diskUse,
required double diskUsagePercentage,
}) = _ServerDiskInfo;
ServerDiskInfo copyWith({String? diskAvailable, String? diskSize, String? diskUse, double? diskUsagePercentage}) {
return ServerDiskInfo(
diskAvailable: diskAvailable ?? this.diskAvailable,
diskSize: diskSize ?? this.diskSize,
diskUse: diskUse ?? this.diskUse,
diskUsagePercentage: diskUsagePercentage ?? this.diskUsagePercentage,
);
}
@override
String toString() {
return 'ServerDiskInfo(diskAvailable: $diskAvailable, diskSize: $diskSize, diskUse: $diskUse, diskUsagePercentage: $diskUsagePercentage)';
}
ServerDiskInfo.fromDto(ServerStorageResponseDto dto)
: diskAvailable = dto.diskAvailable,
diskSize = dto.diskSize,
diskUse = dto.diskUse,
diskUsagePercentage = dto.diskUsagePercentage;
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is ServerDiskInfo &&
other.diskAvailable == diskAvailable &&
other.diskSize == diskSize &&
other.diskUse == diskUse &&
other.diskUsagePercentage == diskUsagePercentage;
}
@override
int get hashCode {
return diskAvailable.hashCode ^ diskSize.hashCode ^ diskUse.hashCode ^ diskUsagePercentage.hashCode;
}
factory ServerDiskInfo.fromDto(ServerStorageResponseDto dto) => ServerDiskInfo(
diskAvailable: dto.diskAvailable,
diskSize: dto.diskSize,
diskUse: dto.diskUse,
diskUsagePercentage: dto.diskUsagePercentage,
);
}

View file

@ -1,74 +1,26 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:openapi/api.dart';
class ServerFeatures {
final bool trash;
final bool map;
final bool oauthEnabled;
final bool passwordLogin;
final bool ocr;
final bool smartSearch;
part 'server_features.model.freezed.dart';
const ServerFeatures({
required this.trash,
required this.map,
required this.oauthEnabled,
required this.passwordLogin,
this.ocr = false,
this.smartSearch = false,
});
@freezed
abstract class ServerFeatures with _$ServerFeatures {
const factory ServerFeatures({
required bool trash,
required bool map,
required bool oauthEnabled,
required bool passwordLogin,
@Default(false) bool ocr,
@Default(false) bool smartSearch,
}) = _ServerFeatures;
ServerFeatures copyWith({
bool? trash,
bool? map,
bool? oauthEnabled,
bool? passwordLogin,
bool? ocr,
bool? smartSearch,
}) {
return ServerFeatures(
trash: trash ?? this.trash,
map: map ?? this.map,
oauthEnabled: oauthEnabled ?? this.oauthEnabled,
passwordLogin: passwordLogin ?? this.passwordLogin,
ocr: ocr ?? this.ocr,
smartSearch: smartSearch ?? this.smartSearch,
);
}
@override
String toString() {
return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr, smartSearch: $smartSearch)';
}
ServerFeatures.fromDto(ServerFeaturesDto dto)
: trash = dto.trash,
map = dto.map,
oauthEnabled = dto.oauth,
passwordLogin = dto.passwordLogin,
ocr = dto.ocr,
smartSearch = dto.smartSearch;
@override
bool operator ==(covariant ServerFeatures other) {
if (identical(this, other)) {
return true;
}
return other.trash == trash &&
other.map == map &&
other.oauthEnabled == oauthEnabled &&
other.passwordLogin == passwordLogin &&
other.ocr == ocr &&
other.smartSearch == smartSearch;
}
@override
int get hashCode {
return trash.hashCode ^
map.hashCode ^
oauthEnabled.hashCode ^
passwordLogin.hashCode ^
ocr.hashCode ^
smartSearch.hashCode;
}
factory ServerFeatures.fromDto(ServerFeaturesDto dto) => ServerFeatures(
trash: dto.trash,
map: dto.map,
oauthEnabled: dto.oauth,
passwordLogin: dto.passwordLogin,
ocr: dto.ocr,
smartSearch: dto.smartSearch,
);
}

View file

@ -1,9 +1,13 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/models/server_info/server_config.model.dart';
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
import 'package:immich_mobile/models/server_info/server_features.model.dart';
import 'package:immich_mobile/models/server_info/server_version.model.dart';
part 'server_info.model.freezed.dart';
enum VersionStatus {
upToDate,
clientOutOfDate,
@ -18,68 +22,14 @@ enum VersionStatus {
};
}
class ServerInfo {
final ServerVersion serverVersion;
final ServerVersion? latestVersion;
final ServerFeatures serverFeatures;
final ServerConfig serverConfig;
final ServerDiskInfo serverDiskInfo;
final VersionStatus versionStatus;
const ServerInfo({
required this.serverVersion,
this.latestVersion,
required this.serverFeatures,
required this.serverConfig,
required this.serverDiskInfo,
required this.versionStatus,
});
ServerInfo copyWith({
ServerVersion? serverVersion,
@freezed
abstract class ServerInfo with _$ServerInfo {
const factory ServerInfo({
required ServerVersion serverVersion,
ServerVersion? latestVersion,
ServerFeatures? serverFeatures,
ServerConfig? serverConfig,
ServerDiskInfo? serverDiskInfo,
VersionStatus? versionStatus,
}) {
return ServerInfo(
serverVersion: serverVersion ?? this.serverVersion,
latestVersion: latestVersion ?? this.latestVersion,
serverFeatures: serverFeatures ?? this.serverFeatures,
serverConfig: serverConfig ?? this.serverConfig,
serverDiskInfo: serverDiskInfo ?? this.serverDiskInfo,
versionStatus: versionStatus ?? this.versionStatus,
);
}
@override
String toString() {
return 'ServerInfo(serverVersion: $serverVersion, latestVersion: $latestVersion, serverFeatures: $serverFeatures, serverConfig: $serverConfig, serverDiskInfo: $serverDiskInfo, versionStatus: $versionStatus)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is ServerInfo &&
other.serverVersion == serverVersion &&
other.latestVersion == latestVersion &&
other.serverFeatures == serverFeatures &&
other.serverConfig == serverConfig &&
other.serverDiskInfo == serverDiskInfo &&
other.versionStatus == versionStatus;
}
@override
int get hashCode {
return serverVersion.hashCode ^
latestVersion.hashCode ^
serverFeatures.hashCode ^
serverConfig.hashCode ^
serverDiskInfo.hashCode ^
versionStatus.hashCode;
}
required ServerFeatures serverFeatures,
required ServerConfig serverConfig,
required ServerDiskInfo serverDiskInfo,
required VersionStatus versionStatus,
}) = _ServerInfo;
}

View file

@ -1,119 +1,47 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:openapi/api.dart';
part 'shared_link.model.freezed.dart';
enum SharedLinkSource { album, individual }
class SharedLink {
final String id;
final String title;
final bool allowDownload;
final bool allowUpload;
final String? thumbAssetId;
final String? description;
final String? password;
final DateTime? expiresAt;
final String key;
final bool showMetadata;
final SharedLinkSource type;
final String? slug;
@freezed
abstract class SharedLink with _$SharedLink {
const factory SharedLink({
required String id,
required String title,
required bool allowDownload,
required bool allowUpload,
required String? thumbAssetId,
required String? description,
required String? password,
required DateTime? expiresAt,
required String key,
required bool showMetadata,
required SharedLinkSource type,
required String? slug,
}) = _SharedLink;
const SharedLink({
required this.id,
required this.title,
required this.allowDownload,
required this.allowUpload,
required this.thumbAssetId,
required this.description,
required this.password,
required this.expiresAt,
required this.key,
required this.showMetadata,
required this.type,
required this.slug,
});
SharedLink copyWith({
String? id,
String? title,
String? thumbAssetId,
bool? allowDownload,
bool? allowUpload,
String? description,
String? password,
DateTime? expiresAt,
String? key,
bool? showMetadata,
SharedLinkSource? type,
String? slug,
}) {
factory SharedLink.fromDto(SharedLinkResponseDto dto) {
final isAlbum = dto.type == SharedLinkType.ALBUM;
return SharedLink(
id: id ?? this.id,
title: title ?? this.title,
thumbAssetId: thumbAssetId ?? this.thumbAssetId,
allowDownload: allowDownload ?? this.allowDownload,
allowUpload: allowUpload ?? this.allowUpload,
description: description ?? this.description,
password: password ?? this.password,
expiresAt: expiresAt ?? this.expiresAt,
key: key ?? this.key,
showMetadata: showMetadata ?? this.showMetadata,
type: type ?? this.type,
slug: slug ?? this.slug,
);
}
SharedLink.fromDto(SharedLinkResponseDto dto)
: id = dto.id,
allowDownload = dto.allowDownload,
allowUpload = dto.allowUpload,
description = dto.description,
password = dto.password,
expiresAt = dto.expiresAt,
key = dto.key,
showMetadata = dto.showMetadata,
slug = dto.slug,
type = dto.type == SharedLinkType.ALBUM ? SharedLinkSource.album : SharedLinkSource.individual,
title = dto.type == SharedLinkType.ALBUM
? dto.album.orElse(null)?.albumName.toUpperCase() ?? "UNKNOWN SHARE"
: "INDIVIDUAL SHARE",
thumbAssetId = dto.type == SharedLinkType.ALBUM
id: dto.id,
allowDownload: dto.allowDownload,
allowUpload: dto.allowUpload,
description: dto.description,
password: dto.password,
expiresAt: dto.expiresAt,
key: dto.key,
showMetadata: dto.showMetadata,
slug: dto.slug,
type: isAlbum ? SharedLinkSource.album : SharedLinkSource.individual,
title: isAlbum ? dto.album.orElse(null)?.albumName.toUpperCase() ?? "UNKNOWN SHARE" : "INDIVIDUAL SHARE",
thumbAssetId: isAlbum
? dto.album.orElse(null)?.albumThumbnailAssetId
: dto.assets.isNotEmpty
? dto.assets[0].id
: null;
@override
String toString() =>
'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type, slug=$slug)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SharedLink &&
other.id == id &&
other.title == title &&
other.thumbAssetId == thumbAssetId &&
other.allowDownload == allowDownload &&
other.allowUpload == allowUpload &&
other.description == description &&
other.password == password &&
other.expiresAt == expiresAt &&
other.key == key &&
other.showMetadata == showMetadata &&
other.type == type &&
other.slug == slug;
@override
int get hashCode =>
id.hashCode ^
title.hashCode ^
thumbAssetId.hashCode ^
allowDownload.hashCode ^
allowUpload.hashCode ^
description.hashCode ^
password.hashCode ^
expiresAt.hashCode ^
key.hashCode ^
showMetadata.hashCode ^
type.hashCode ^
slug.hashCode;
: null,
);
}
}

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

@ -1,10 +1,14 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/aspect_ratios.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/utils/editor.utils.dart';
part 'editor.provider.freezed.dart';
final editorStateProvider = NotifierProvider<EditorProvider, EditorState>(EditorProvider.new);
class EditorProvider extends Notifier<EditorState> {
@ -32,8 +36,8 @@ class EditorProvider extends Notifier<EditorState> {
final transform = normalizeTransformEdits(edits);
state = state.copyWith(
originalWidth: originalWidth,
originalHeight: originalHeight,
originalWidth: originalWidth ?? state.originalWidth,
originalHeight: originalHeight ?? state.originalHeight,
crop: crop,
flipHorizontal: transform.mirrorHorizontal,
flipVertical: transform.mirrorVertical,
@ -106,104 +110,24 @@ class EditorProvider extends Notifier<EditorState> {
}
}
class EditorState {
final bool isApplyingEdits;
@freezed
abstract class EditorState with _$EditorState {
const EditorState._();
final int rotationAngle;
final bool flipHorizontal;
final bool flipVertical;
final Rect crop;
final CropAspectRatio aspectRatio;
final int originalWidth;
final int originalHeight;
final Duration animationDuration;
final bool hasUnsavedEdits;
const EditorState({
bool? isApplyingEdits,
int? rotationAngle,
bool? flipHorizontal,
bool? flipVertical,
Rect? crop,
CropAspectRatio? aspectRatio,
int? originalWidth,
int? originalHeight,
Duration? animationDuration,
bool? hasUnsavedEdits,
}) : isApplyingEdits = isApplyingEdits ?? false,
rotationAngle = rotationAngle ?? 0,
flipHorizontal = flipHorizontal ?? false,
flipVertical = flipVertical ?? false,
animationDuration = animationDuration ?? Duration.zero,
originalWidth = originalWidth ?? 0,
originalHeight = originalHeight ?? 0,
crop = crop ?? const Rect.fromLTRB(0, 0, 1, 1),
aspectRatio = aspectRatio ?? CropAspectRatio.free,
hasUnsavedEdits = hasUnsavedEdits ?? false;
EditorState copyWith({
bool? isApplyingEdits,
int? rotationAngle,
bool? flipHorizontal,
bool? flipVertical,
CropAspectRatio? aspectRatio,
int? originalWidth,
int? originalHeight,
Duration? animationDuration,
Rect? crop,
bool? hasUnsavedEdits,
}) {
return EditorState(
isApplyingEdits: isApplyingEdits ?? this.isApplyingEdits,
rotationAngle: rotationAngle ?? this.rotationAngle,
flipHorizontal: flipHorizontal ?? this.flipHorizontal,
flipVertical: flipVertical ?? this.flipVertical,
aspectRatio: aspectRatio ?? this.aspectRatio,
animationDuration: animationDuration ?? this.animationDuration,
originalWidth: originalWidth ?? this.originalWidth,
originalHeight: originalHeight ?? this.originalHeight,
crop: crop ?? this.crop,
hasUnsavedEdits: hasUnsavedEdits ?? this.hasUnsavedEdits,
);
}
const factory EditorState({
@Default(false) bool isApplyingEdits,
@Default(0) int rotationAngle,
@Default(false) bool flipHorizontal,
@Default(false) bool flipVertical,
@Default(Rect.fromLTRB(0, 0, 1, 1)) Rect crop,
@Default(CropAspectRatio.free) CropAspectRatio aspectRatio,
@Default(0) int originalWidth,
@Default(0) int originalHeight,
@Default(Duration.zero) Duration animationDuration,
@Default(false) bool hasUnsavedEdits,
}) = _EditorState;
bool get hasEdits {
return rotationAngle != 0 || flipHorizontal || flipVertical || crop != const Rect.fromLTRB(0, 0, 1, 1);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is EditorState &&
other.isApplyingEdits == isApplyingEdits &&
other.rotationAngle == rotationAngle &&
other.flipHorizontal == flipHorizontal &&
other.flipVertical == flipVertical &&
other.crop == crop &&
other.aspectRatio == aspectRatio &&
other.originalWidth == originalWidth &&
other.originalHeight == originalHeight &&
other.animationDuration == animationDuration &&
other.hasUnsavedEdits == hasUnsavedEdits;
}
@override
int get hashCode {
return isApplyingEdits.hashCode ^
rotationAngle.hashCode ^
flipHorizontal.hashCode ^
flipVertical.hashCode ^
crop.hashCode ^
aspectRatio.hashCode ^
originalWidth.hashCode ^
originalHeight.hashCode ^
animationDuration.hashCode ^
hasUnsavedEdits.hashCode;
}
}

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 = timelineOrigin.isDeepLink && currentAsset != null ? currentAsset : _asset;

View file

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

View file

@ -22,7 +22,9 @@ import 'package:immich_ui/immich_ui.dart';
List<Widget> _actionColumnButtons(BuildContext context, WidgetRef ref, List<ActionBuilder> actions) => actions
.map((a) => a.create(context, ref))
.nonNulls
.map((item) => ImmichColumnButton(icon: item.icon, label: item.label, onPressed: item.onAction))
.map<ImmichColumnButton>(
(item) => .new(icon: item.icon, label: item.label, onPressed: item.onAction, onLongPress: item.onSecondaryAction),
)
.toList(growable: false);
class ViewerBottomBar extends ConsumerWidget {
@ -51,7 +53,7 @@ class ViewerBottomBar extends ConsumerWidget {
UploadAction(source: .viewer, showProgress: true),
EditAssetAction(source: .viewer),
]),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
if (asset.hasRemote) ImmichColorOverride(color: null, child: AddActionButton(originalTheme: originalTheme)),
],
..._actionColumnButtons(context, ref, const [DeleteAction(source: .viewer)]),
],
@ -94,7 +96,10 @@ class ViewerBottomBar extends ConsumerWidget {
if (asset.isImage) OcrToggleButton(asset: asset),
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
ImmichColorOverride(
color: Colors.white,
child: Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
),
],
),
),

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

@ -1,5 +1,7 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
@ -8,6 +10,8 @@ import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
part 'timeline.state.freezed.dart';
class TimelineArgs {
final double maxWidth;
final double maxHeight;
@ -49,25 +53,13 @@ class TimelineArgs {
groupBy.hashCode;
}
class TimelineState {
final bool isScrubbing;
final bool isScrolling;
@freezed
abstract class TimelineState with _$TimelineState {
const TimelineState._();
const TimelineState({this.isScrubbing = false, this.isScrolling = false});
const factory TimelineState({@Default(false) bool isScrubbing, @Default(false) bool isScrolling}) = _TimelineState;
bool get isInteracting => isScrubbing || isScrolling;
@override
bool operator ==(covariant TimelineState other) {
return isScrubbing == other.isScrubbing && isScrolling == other.isScrolling;
}
@override
int get hashCode => isScrubbing.hashCode ^ isScrolling.hashCode;
TimelineState copyWith({bool? isScrubbing, bool? isScrolling}) {
return TimelineState(isScrubbing: isScrubbing ?? this.isScrubbing, isScrolling: isScrolling ?? this.isScrolling);
}
}
class TimelineStateNotifier extends Notifier<TimelineState> {

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

@ -1,81 +1,25 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
class AssetViewerState {
final double backgroundOpacity;
final bool showingDetails;
final bool showingControls;
final bool isZoomed;
final bool showingOcr;
final BaseAsset? currentAsset;
final int stackIndex;
part 'asset_viewer.provider.freezed.dart';
const AssetViewerState({
this.backgroundOpacity = 1.0,
this.showingDetails = false,
this.showingControls = true,
this.isZoomed = false,
this.showingOcr = false,
this.currentAsset,
this.stackIndex = 0,
});
AssetViewerState copyWith({
double? backgroundOpacity,
bool? showingDetails,
bool? showingControls,
bool? isZoomed,
bool? showingOcr,
@freezed
abstract class AssetViewerState with _$AssetViewerState {
const factory AssetViewerState({
@Default(1.0) double backgroundOpacity,
@Default(false) bool showingDetails,
@Default(true) bool showingControls,
@Default(false) bool isZoomed,
@Default(false) bool showingOcr,
BaseAsset? currentAsset,
int? stackIndex,
}) {
return AssetViewerState(
backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity,
showingDetails: showingDetails ?? this.showingDetails,
showingControls: showingControls ?? this.showingControls,
isZoomed: isZoomed ?? this.isZoomed,
showingOcr: showingOcr ?? this.showingOcr,
currentAsset: currentAsset ?? this.currentAsset,
stackIndex: stackIndex ?? this.stackIndex,
);
}
@override
String toString() {
return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed, showingOcr: $showingOcr)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other.runtimeType != runtimeType) {
return false;
}
return other is AssetViewerState &&
other.backgroundOpacity == backgroundOpacity &&
other.showingDetails == showingDetails &&
other.showingControls == showingControls &&
other.isZoomed == isZoomed &&
other.showingOcr == showingOcr &&
other.currentAsset == currentAsset &&
other.stackIndex == stackIndex;
}
@override
int get hashCode =>
backgroundOpacity.hashCode ^
showingDetails.hashCode ^
showingControls.hashCode ^
isZoomed.hashCode ^
showingOcr.hashCode ^
currentAsset.hashCode ^
stackIndex.hashCode;
@Default(0) int stackIndex,
}) = _AssetViewerState;
}
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {

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