Merge branch 'main' into feat/search-filter-album/web

This commit is contained in:
CJPeckover
2025-07-22 01:55:17 -04:00
436 changed files with 33565 additions and 4184 deletions
+1
View File
@@ -106,6 +106,7 @@ custom_lint:
- lib/utils/{image_url_builder,openapi_patching}.dart # utils are fine
- test/modules/utils/openapi_patching_test.dart # filename is self-explanatory...
- lib/domain/services/sync_stream.service.dart # Making sure to comply with the type from database
- lib/domain/services/search.service.dart
# refactor
- lib/models/map/map_marker.model.dart
+19
View File
@@ -3,6 +3,8 @@ plugins {
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
id 'com.google.devtools.ksp'
id 'org.jetbrains.kotlin.plugin.compose' version '2.0.20' // this version matches your Kotlin version
}
def localProperties = new Properties()
@@ -45,6 +47,10 @@ android {
main.java.srcDirs += 'src/main/kotlin'
}
buildFeatures {
compose true
}
defaultConfig {
applicationId "app.alextran.immich"
minSdkVersion 26
@@ -105,6 +111,8 @@ dependencies {
def guava_version = '33.3.1-android'
def glide_version = '4.16.0'
def serialization_version = '1.8.1'
def compose_version = '1.1.1'
def gson_version = '2.10.1'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
@@ -116,6 +124,17 @@ dependencies {
ksp "com.github.bumptech.glide:ksp:$glide_version"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
//Glance Widget
implementation "androidx.glance:glance-appwidget:$compose_version"
implementation "com.google.code.gson:gson:$gson_version"
// Glance Configure
implementation "androidx.activity:activity-compose:1.8.2"
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.ui:ui-tooling:$compose_version"
implementation "androidx.compose.material3:material3:1.2.1"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2"
}
// This is uncommented in F-Droid build script
+8 -1
View File
@@ -25,8 +25,15 @@
@com.google.gson.annotations.SerializedName <fields>;
}
# TypeToken preventions
-keep class com.google.gson.reflect.TypeToken { *; }
-keep class * extends com.google.gson.reflect.TypeToken
# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher.
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken
##---------------End: proguard configuration for Gson ----------
##---------------End: proguard configuration for Gson ----------
# Keep all widget model classes and their fields for Gson
-keep class app.alextran.immich.widget.model.** { *; }
@@ -141,6 +141,41 @@
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
<!-- Widgets -->
<receiver
android:name=".widget.RandomReceiver"
android:exported="true"
android:label="@string/random_widget_title">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/random_widget" />
</receiver>
<receiver
android:name=".widget.MemoryReceiver"
android:exported="true"
android:label="@string/memory_widget_title">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/memory_widget" />
</receiver>
<activity android:name=".widget.configure.RandomConfigure"
android:exported="true"
android:theme="@style/Theme.Material3.DayNight.NoActionBar">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE"/>
</intent-filter>
</activity>
</application>
@@ -154,4 +189,4 @@
<data android:scheme="geo" />
</intent>
</queries>
</manifest>
</manifest>
@@ -83,6 +83,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
runDart()
}
return resolvableFuture
@@ -0,0 +1,33 @@
package app.alextran.immich.widget
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.File
fun loadScaledBitmap(file: File, reqWidth: Int, reqHeight: Int): Bitmap? {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(file.absolutePath, options)
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
options.inJustDecodeBounds = false
return BitmapFactory.decodeFile(file.absolutePath, options)
}
fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
val (height: Int, width: Int) = options.run { outHeight to outWidth }
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight: Int = height / 2
val halfWidth: Int = width / 2
while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}
@@ -0,0 +1,244 @@
package app.alextran.immich.widget
import android.content.Context
import android.graphics.Bitmap
import android.util.Log
import androidx.datastore.preferences.core.Preferences
import androidx.glance.*
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.work.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
import java.util.concurrent.TimeUnit
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import app.alextran.immich.widget.model.*
import java.time.LocalDate
class ImageDownloadWorker(
private val context: Context,
workerParameters: WorkerParameters
) : CoroutineWorker(context, workerParameters) {
companion object {
private val uniqueWorkName = ImageDownloadWorker::class.java.simpleName
private fun buildConstraints(): Constraints {
return Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
}
private fun buildInputData(appWidgetId: Int, widgetType: WidgetType): Data {
return Data.Builder()
.putString(kWorkerWidgetType, widgetType.toString())
.putInt(kWorkerWidgetID, appWidgetId)
.build()
}
fun enqueuePeriodic(context: Context, appWidgetId: Int, widgetType: WidgetType) {
val manager = WorkManager.getInstance(context)
val workRequest = PeriodicWorkRequestBuilder<ImageDownloadWorker>(
20, TimeUnit.MINUTES
)
.setConstraints(buildConstraints())
.setInputData(buildInputData(appWidgetId, widgetType))
.addTag(appWidgetId.toString())
.build()
manager.enqueueUniquePeriodicWork(
"$uniqueWorkName-$appWidgetId",
ExistingPeriodicWorkPolicy.UPDATE,
workRequest
)
}
fun singleShot(context: Context, appWidgetId: Int, widgetType: WidgetType) {
val manager = WorkManager.getInstance(context)
val workRequest = OneTimeWorkRequestBuilder<ImageDownloadWorker>()
.setConstraints(buildConstraints())
.setInputData(buildInputData(appWidgetId, widgetType))
.addTag(appWidgetId.toString())
.build()
manager.enqueueUniqueWork(
"$uniqueWorkName-$appWidgetId",
ExistingWorkPolicy.REPLACE,
workRequest
)
}
suspend fun cancel(context: Context, appWidgetId: Int) {
WorkManager.getInstance(context).cancelAllWorkByTag("$uniqueWorkName-$appWidgetId")
// delete cached image
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentImgUUID = widgetConfig[kImageUUID]
if (!currentImgUUID.isNullOrEmpty()) {
val file = File(context.cacheDir, imageFilename(currentImgUUID))
file.delete()
}
}
}
override suspend fun doWork(): Result {
return try {
val widgetType = WidgetType.valueOf(inputData.getString(kWorkerWidgetType) ?: "")
val widgetId = inputData.getInt(kWorkerWidgetID, -1)
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(widgetId)
val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentImgUUID = widgetConfig[kImageUUID]
val serverConfig = ImmichAPI.getServerConfig(context)
// clear any image caches and go to "login" state if no credentials
if (serverConfig == null) {
if (!currentImgUUID.isNullOrEmpty()) {
deleteImage(currentImgUUID)
updateWidget(
glanceId,
"",
"",
"immich://",
WidgetState.LOG_IN
)
}
return Result.success()
}
// fetch new image
val entry = when (widgetType) {
WidgetType.RANDOM -> fetchRandom(serverConfig, widgetConfig)
WidgetType.MEMORIES -> fetchMemory(serverConfig)
}
// clear current image if it exists
if (!currentImgUUID.isNullOrEmpty()) {
deleteImage(currentImgUUID)
}
// save a new image
val imgUUID = UUID.randomUUID().toString()
saveImage(entry.image, imgUUID)
// trigger the update routine with new image uuid
updateWidget(glanceId, imgUUID, entry.subtitle, entry.deeplink)
Result.success()
} catch (e: Exception) {
Log.e(uniqueWorkName, "Error while loading image", e)
if (runAttemptCount < 10) {
Result.retry()
} else {
Result.failure()
}
}
}
private suspend fun updateWidget(
glanceId: GlanceId,
imageUUID: String,
subtitle: String?,
deeplink: String?,
widgetState: WidgetState = WidgetState.SUCCESS
) {
updateAppWidgetState(context, glanceId) { prefs ->
prefs[kNow] = System.currentTimeMillis()
prefs[kImageUUID] = imageUUID
prefs[kWidgetState] = widgetState.toString()
prefs[kSubtitleText] = subtitle ?: ""
prefs[kDeeplinkURL] = deeplink ?: ""
}
PhotoWidget().update(context,glanceId)
}
private suspend fun fetchRandom(
serverConfig: ServerConfig,
widgetConfig: Preferences
): WidgetEntry {
val api = ImmichAPI(serverConfig)
val filters = SearchFilters()
val albumId = widgetConfig[kSelectedAlbum]
val showSubtitle = widgetConfig[kShowAlbumName]
val albumName = widgetConfig[kSelectedAlbumName]
var subtitle: String? = if (showSubtitle == true) albumName else ""
if (albumId == "FAVORITES") {
filters.isFavorite = true
} else if (albumId != null) {
filters.albumIds = listOf(albumId)
}
var randomSearch = api.fetchSearchResults(filters)
// handle an empty album, fallback to random
if (randomSearch.isEmpty() && albumId != null) {
randomSearch = api.fetchSearchResults(SearchFilters())
subtitle = ""
}
val random = randomSearch.first()
val image = api.fetchImage(random)
return WidgetEntry(
image,
subtitle,
assetDeeplink(random)
)
}
private suspend fun fetchMemory(
serverConfig: ServerConfig
): WidgetEntry {
val api = ImmichAPI(serverConfig)
val today = LocalDate.now()
val memories = api.fetchMemory(today)
val asset: Asset
var subtitle: String? = null
if (memories.isNotEmpty()) {
// pick a random asset from a random memory
val memory = memories.random()
asset = memory.assets.random()
val yearDiff = today.year - memory.data.year
subtitle = "$yearDiff ${if (yearDiff == 1) "year" else "years"} ago"
} else {
val filters = SearchFilters(size=1)
asset = api.fetchSearchResults(filters).first()
}
val image = api.fetchImage(asset)
return WidgetEntry(
image,
subtitle,
assetDeeplink(asset)
)
}
private suspend fun deleteImage(uuid: String) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, imageFilename(uuid))
file.delete()
}
private suspend fun saveImage(bitmap: Bitmap, uuid: String) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, imageFilename(uuid))
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
}
}
}
@@ -0,0 +1,103 @@
package app.alextran.immich.widget
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import app.alextran.immich.widget.model.*
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import es.antonborri.home_widget.HomeWidgetPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.time.LocalDate
import java.time.format.DateTimeFormatter
class ImmichAPI(cfg: ServerConfig) {
companion object {
fun getServerConfig(context: Context): ServerConfig? {
val prefs = HomeWidgetPlugin.getData(context)
val serverURL = prefs.getString("widget_server_url", "") ?: ""
val sessionKey = prefs.getString("widget_auth_token", "") ?: ""
if (serverURL.isBlank() || sessionKey.isBlank()) {
return null
}
return ServerConfig(
serverURL,
sessionKey
)
}
}
private val gson = Gson()
private val serverConfig = cfg
private fun buildRequestURL(endpoint: String, params: List<Pair<String, String>> = emptyList()): URL {
val urlString = StringBuilder("${serverConfig.serverEndpoint}$endpoint?sessionKey=${serverConfig.sessionKey}")
for ((key, value) in params) {
urlString.append("&${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}")
}
return URL(urlString.toString())
}
suspend fun fetchSearchResults(filters: SearchFilters): List<Asset> = withContext(Dispatchers.IO) {
val url = buildRequestURL("/search/random")
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
setRequestProperty("Content-Type", "application/json")
doOutput = true
}
connection.outputStream.use {
OutputStreamWriter(it).use { writer ->
writer.write(gson.toJson(filters))
writer.flush()
}
}
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<Asset>>() {}.type
gson.fromJson(response, type)
}
suspend fun fetchMemory(date: LocalDate): List<MemoryResult> = withContext(Dispatchers.IO) {
val iso8601 = date.format(DateTimeFormatter.ISO_LOCAL_DATE)
val url = buildRequestURL("/memories", listOf("for" to iso8601))
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
}
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<MemoryResult>>() {}.type
gson.fromJson(response, type)
}
suspend fun fetchImage(asset: Asset): Bitmap = withContext(Dispatchers.IO) {
val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview"))
val connection = url.openConnection()
val data = connection.getInputStream().readBytes()
BitmapFactory.decodeByteArray(data, 0, data.size)
?: throw Exception("Invalid image data")
}
suspend fun fetchAlbums(): List<Album> = withContext(Dispatchers.IO) {
val url = buildRequestURL("/albums")
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
}
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<Album>>() {}.type
gson.fromJson(response, type)
}
}
@@ -0,0 +1,56 @@
package app.alextran.immich.widget
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import app.alextran.immich.widget.model.*
import es.antonborri.home_widget.HomeWidgetPlugin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MemoryReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = PhotoWidget()
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
appWidgetIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.MEMORIES)
}
}
override fun onReceive(context: Context, intent: Intent) {
val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false)
// Launch coroutine to setup a single shot if the app requested the update
if (fromMainApp) {
CoroutineScope(Dispatchers.Default).launch {
val provider = ComponentName(context, MemoryReceiver::class.java)
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
glanceIds.forEach { widgetID ->
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.MEMORIES)
}
}
}
super.onReceive(context, intent)
}
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
CoroutineScope(Dispatchers.Default).launch {
appWidgetIds.forEach { id ->
ImageDownloadWorker.cancel(context, id)
}
}
}
}
@@ -0,0 +1,124 @@
package app.alextran.immich.widget
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.*
import androidx.core.net.toUri
import androidx.datastore.preferences.core.MutablePreferences
import androidx.glance.appwidget.*
import androidx.glance.*
import androidx.glance.action.clickable
import androidx.glance.layout.*
import androidx.glance.state.GlanceStateDefinition
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import app.alextran.immich.R
import app.alextran.immich.widget.model.*
import java.io.File
class PhotoWidget : GlanceAppWidget() {
override var stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val prefs = currentState<MutablePreferences>()
val imageUUID = prefs[kImageUUID]
val subtitle = prefs[kSubtitleText]
val deeplinkURL = prefs[kDeeplinkURL]?.toUri()
val widgetState = prefs[kWidgetState]
var bitmap: Bitmap? = null
if (imageUUID != null) {
// fetch a random photo from server
val file = File(context.cacheDir, imageFilename(imageUUID))
if (file.exists()) {
bitmap = loadScaledBitmap(file, 500, 500)
}
}
// WIDGET CONTENT
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.background)
.clickable {
val intent = Intent(Intent.ACTION_VIEW, deeplinkURL ?: "immich://".toUri())
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
}
) {
if (bitmap != null) {
Image(
provider = ImageProvider(bitmap),
contentDescription = "Widget Image",
contentScale = ContentScale.Crop,
modifier = GlanceModifier.fillMaxSize()
)
if (!subtitle.isNullOrBlank()) {
Column(
verticalAlignment = Alignment.Bottom,
horizontalAlignment = Alignment.Start,
modifier = GlanceModifier
.fillMaxSize()
.padding(12.dp)
) {
Text(
text = subtitle,
style = TextStyle(
color = ColorProvider(Color.White),
fontSize = 16.sp
),
modifier = GlanceModifier
.background(ColorProvider(Color(0x99000000))) // 60% black
.padding(8.dp)
.cornerRadius(8.dp)
)
}
}
} else {
Column(
modifier = GlanceModifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
provider = ImageProvider(R.drawable.splash),
contentDescription = null,
)
if (widgetState == WidgetState.LOG_IN.toString()) {
Box(
modifier = GlanceModifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center
) {
Text("Log in to your Immich server", style = TextStyle(textAlign = TextAlign.Center, color = GlanceTheme.colors.primary))
}
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = GlanceModifier.fillMaxWidth().padding(16.dp)
) {
CircularProgressIndicator(
modifier = GlanceModifier.size(12.dp)
)
Spacer(modifier = GlanceModifier.width(8.dp))
Text("Loading widget...", style = TextStyle(textAlign = TextAlign.Center, color = GlanceTheme.colors.primary))
}
}
}
}
}
}
}
}
@@ -0,0 +1,55 @@
package app.alextran.immich.widget
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import es.antonborri.home_widget.HomeWidgetPlugin
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import app.alextran.immich.widget.model.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class RandomReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = PhotoWidget()
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
appWidgetIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM)
}
}
override fun onReceive(context: Context, intent: Intent) {
val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false)
// Launch coroutine to setup a single shot if the app requested the update
if (fromMainApp) {
CoroutineScope(Dispatchers.Default).launch {
val provider = ComponentName(context, RandomReceiver::class.java)
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
glanceIds.forEach { widgetID ->
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.RANDOM)
}
}
}
super.onReceive(context, intent)
}
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
CoroutineScope(Dispatchers.Default).launch {
appWidgetIds.forEach { id ->
ImageDownloadWorker.cancel(context, id)
}
}
}
}
@@ -0,0 +1,64 @@
package app.alextran.immich.widget.configure
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
data class DropdownItem (
val label: String,
val id: String,
)
// Creating a composable to display a drop down menu
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Dropdown(items: List<DropdownItem>,
selectedItem: DropdownItem?,
onItemSelected: (DropdownItem) -> Unit,
enabled: Boolean = true
) {
var expanded by remember { mutableStateOf(false) }
var selectedOption by remember { mutableStateOf(selectedItem?.label ?: items[0].label) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded && enabled },
) {
TextField(
value = selectedOption,
onValueChange = {},
readOnly = true,
enabled = enabled,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
colors = ExposedDropdownMenuDefaults.textFieldColors(),
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
items.forEach { option ->
DropdownMenuItem(
text = { Text(option.label, color = MaterialTheme.colorScheme.onSurface) },
onClick = {
selectedOption = option.label
onItemSelected(option)
expanded = false
},
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding
)
}
}
}
}
@@ -0,0 +1,28 @@
package app.alextran.immich.widget.configure
import android.os.Build
import androidx.compose.foundation.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
@Composable
fun LightDarkTheme(
content: @Composable () -> Unit
) {
val context = LocalContext.current
val isDarkTheme = isSystemInDarkTheme()
val colorScheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && isDarkTheme ->
dynamicDarkColorScheme(context)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !isDarkTheme ->
dynamicLightColorScheme(context)
isDarkTheme -> darkColorScheme()
else -> lightColorScheme()
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
@@ -0,0 +1,210 @@
package app.alextran.immich.widget.configure
import android.appwidget.AppWidgetManager
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import app.alextran.immich.widget.ImageDownloadWorker
import app.alextran.immich.widget.ImmichAPI
import app.alextran.immich.widget.model.*
import kotlinx.coroutines.launch
import java.io.FileNotFoundException
class RandomConfigure : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Get widget ID from intent
val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID)
?: AppWidgetManager.INVALID_APPWIDGET_ID
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
val glanceId = GlanceAppWidgetManager(applicationContext)
.getGlanceIdBy(appWidgetId)
setContent {
LightDarkTheme {
RandomConfiguration(applicationContext, appWidgetId, glanceId, onDone = {
finish()
Log.w("WIDGET_ACTIVITY", "SAVING")
})
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RandomConfiguration(context: Context, appWidgetId: Int, glanceId: GlanceId, onDone: () -> Unit) {
var selectedAlbum by remember { mutableStateOf<DropdownItem?>(null) }
var showAlbumName by remember { mutableStateOf(false) }
var availableAlbums by remember { mutableStateOf<List<DropdownItem>>(listOf()) }
var state by remember { mutableStateOf(WidgetConfigState.LOADING) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
// get albums from server
val serverCfg = ImmichAPI.getServerConfig(context)
if (serverCfg == null) {
state = WidgetConfigState.LOG_IN
return@LaunchedEffect
}
val api = ImmichAPI(serverCfg)
val currentState = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentAlbumId = currentState[kSelectedAlbum] ?: "NONE"
val currentAlbumName = currentState[kSelectedAlbumName] ?: "None"
var albumItems: List<DropdownItem>
try {
albumItems = api.fetchAlbums().map {
DropdownItem(it.albumName, it.id)
}
state = WidgetConfigState.SUCCESS
} catch (e: FileNotFoundException) {
Log.e("WidgetWorker", "Error fetching albums: ${e.message}")
state = WidgetConfigState.NO_CONNECTION
albumItems = listOf(DropdownItem(currentAlbumName, currentAlbumId))
}
availableAlbums = listOf(DropdownItem("None", "NONE"), DropdownItem("Favorites", "FAVORITES")) + albumItems
// load selected configuration
val albumEntity = availableAlbums.firstOrNull { it.id == currentAlbumId }
selectedAlbum = albumEntity ?: availableAlbums.first()
// load showAlbumName
showAlbumName = currentState[kShowAlbumName] == true
}
suspend fun saveConfiguration() {
updateAppWidgetState(context, glanceId) { prefs ->
prefs[kSelectedAlbum] = selectedAlbum?.id ?: ""
prefs[kSelectedAlbumName] = selectedAlbum?.label ?: ""
prefs[kShowAlbumName] = showAlbumName
}
ImageDownloadWorker.singleShot(context, appWidgetId, WidgetType.RANDOM)
}
Scaffold(
topBar = {
TopAppBar (
title = { Text("Widget Configuration") },
actions = {
IconButton(onClick = {
scope.launch {
saveConfiguration()
onDone()
}
}) {
Icon(Icons.Default.Check, contentDescription = "Close", tint = MaterialTheme.colorScheme.primary)
}
}
)
}
) { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding), // Respect the top bar
color = MaterialTheme.colorScheme.background
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
when (state) {
WidgetConfigState.LOADING -> CircularProgressIndicator(modifier = Modifier.size(48.dp))
WidgetConfigState.LOG_IN -> Text("You must log in inside the Immich App to configure this widget.")
else -> {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("View a random image from your library or a specific album.", style = MaterialTheme.typography.bodyMedium)
// no connection warning
if (state == WidgetConfigState.NO_CONNECTION) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.errorContainer)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = "Warning",
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "No connection to the server is available. Please try again later.",
style = MaterialTheme.typography.bodyMedium
)
}
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Album")
Dropdown(
items = availableAlbums,
selectedItem = selectedAlbum,
onItemSelected = { selectedAlbum = it },
enabled = (state != WidgetConfigState.NO_CONNECTION)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(text = "Show Album Name")
Switch(
checked = showAlbumName,
onCheckedChange = { showAlbumName = it },
enabled = (state != WidgetConfigState.NO_CONNECTION)
)
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,80 @@
package app.alextran.immich.widget.model
import android.graphics.Bitmap
import androidx.datastore.preferences.core.*
// MARK: Immich Entities
enum class AssetType {
IMAGE, VIDEO, AUDIO, OTHER
}
data class Asset(
val id: String,
val type: AssetType,
)
data class SearchFilters(
var type: AssetType = AssetType.IMAGE,
val size: Int = 1,
var albumIds: List<String> = listOf(),
var isFavorite: Boolean? = null
)
data class MemoryResult(
val id: String,
var assets: List<Asset>,
val type: String,
val data: MemoryData
) {
data class MemoryData(val year: Int)
}
data class Album(
val id: String,
val albumName: String
)
// MARK: Widget Specific
enum class WidgetType {
RANDOM, MEMORIES;
}
enum class WidgetState {
LOADING, SUCCESS, LOG_IN;
}
enum class WidgetConfigState {
LOADING, SUCCESS, LOG_IN, NO_CONNECTION
}
data class WidgetEntry (
val image: Bitmap,
val subtitle: String?,
val deeplink: String?
)
data class ServerConfig(val serverEndpoint: String, val sessionKey: String)
// MARK: Widget State Keys
val kImageUUID = stringPreferencesKey("uuid")
val kSubtitleText = stringPreferencesKey("subtitle")
val kNow = longPreferencesKey("now")
val kWidgetState = stringPreferencesKey("state")
val kSelectedAlbum = stringPreferencesKey("albumID")
val kSelectedAlbumName = stringPreferencesKey("albumName")
val kShowAlbumName = booleanPreferencesKey("showAlbumName")
val kDeeplinkURL = stringPreferencesKey("deeplink")
const val kWorkerWidgetType = "widgetType"
const val kWorkerWidgetID = "widgetId"
const val kTriggeredFromApp = "triggeredFromApp"
fun imageFilename(id: String): String {
return "widget_image_$id.jpg"
}
fun assetDeeplink(asset: Asset): String {
return "immich://asset?id=${asset.id}"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="memory_widget_title">Memories</string>
<string name="random_widget_title">Random</string>
<string name="memory_widget_description">See memories from Immich.</string>
<string name="random_widget_description">View a random image from your library or a specific album.</string>
</resources>
@@ -0,0 +1,9 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="110dp"
android:minHeight="110dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1200000"
android:description="@string/memory_widget_description"
android:previewImage="@drawable/memory_preview"
/>
@@ -0,0 +1,13 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="110dp"
android:minHeight="110dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1200000"
android:configure="app.alextran.immich.widget.configure.RandomConfigure"
android:widgetFeatures="reconfigurable|configuration_optional"
tools:targetApi="28"
android:description="@string/random_widget_description"
android:previewImage="@drawable/random_preview"
/>
+1 -1
View File
@@ -1 +1 @@
version: '>=1.29.0 <1.30.0'
version: '>=1.29.0 <=1.30.0'
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -23,7 +23,7 @@ class ImmichLinter extends PluginBase {
return rules;
}
static makeCode(String name, LintOptions options) => LintCode(
static LintCode makeCode(String name, LintOptions options) => LintCode(
name: name,
problemMessage: options.json["message"] as String,
errorSeverity: ErrorSeverity.WARNING,
+13 -26
View File
@@ -14,19 +14,6 @@ struct ImageEntry: TimelineEntry {
var deepLink: URL? = nil
}
// Resizes the stored image to a maximum width of 450 pixels
mutating func resize() {
if image == nil || image!.size.height < 450 || image!.size.width < 450 {
return
}
image = image?.resized(toWidth: 450)
if image == nil {
metadata.error = .unableToResize
}
}
static func build(
api: ImmichAPI,
asset: Asset,
@@ -83,7 +70,10 @@ struct ImageEntry: TimelineEntry {
guard let imageData = try? Data(contentsOf: imageURL),
let metadataJSON = try? Data(contentsOf: metadataURL),
let decodedMetadata = try? JSONDecoder().decode(Metadata.self, from: metadataJSON)
let decodedMetadata = try? JSONDecoder().decode(
Metadata.self,
from: metadataJSON
)
else {
return nil
}
@@ -98,7 +88,7 @@ struct ImageEntry: TimelineEntry {
return nil
}
static func handleCacheFallback(
static func handleError(
for key: String,
error: WidgetError = .fetchFailed
) -> Timeline<ImageEntry> {
@@ -108,35 +98,32 @@ struct ImageEntry: TimelineEntry {
metadata: EntryMetadata(error: error)
)
// skip cache if album not found or no login
// we want to show these errors to the user since without intervention,
// use cache if generic failed error
// we want to show the other errors to the user since without intervention,
// it will never succeed
if error != .noLogin && error != .albumNotFound {
if let cachedEntry = ImageEntry.loadCached(for: key) {
timelineEntry = cachedEntry
}
if error == .fetchFailed, let cachedEntry = ImageEntry.loadCached(for: key)
{
timelineEntry = cachedEntry
}
return Timeline(entries: [timelineEntry], policy: .atEnd)
}
}
func generateRandomEntries(
api: ImmichAPI,
now: Date,
count: Int,
albumId: String? = nil,
filter: SearchFilter = Album.NONE.filter,
subtitle: String? = nil
)
async throws -> [ImageEntry]
{
var entries: [ImageEntry] = []
let albumIds = albumId != nil ? [albumId!] : []
let randomAssets = try await api.fetchSearchResults(
with: SearchFilters(size: count, albumIds: albumIds)
)
let randomAssets = try await api.fetchSearchResults(with: filter)
await withTaskGroup(of: ImageEntry?.self) { group in
for (dateOffset, asset) in randomAssets.enumerated() {
@@ -1,6 +1,18 @@
import SwiftUI
import WidgetKit
extension Image {
@ViewBuilder
func tintedWidgetImageModifier() -> some View {
if #available(iOS 18.0, *) {
self
.widgetAccentedRenderingMode(.accentedDesaturated)
} else {
self
}
}
}
struct ImmichWidgetView: View {
var entry: ImageEntry
@@ -8,6 +20,7 @@ struct ImmichWidgetView: View {
if entry.image == nil {
VStack {
Image("LaunchImage")
.tintedWidgetImageModifier()
Text(entry.metadata.error?.errorDescription ?? "")
.minimumScaleFactor(0.25)
.multilineTextAlignment(.center)
@@ -19,7 +32,9 @@ struct ImmichWidgetView: View {
Color.clear.overlay(
Image(uiImage: entry.image!)
.resizable()
.tintedWidgetImageModifier()
.scaledToFill()
)
VStack {
Spacer()
+43 -18
View File
@@ -2,14 +2,20 @@ import Foundation
import SwiftUI
import WidgetKit
let IMMICH_SHARE_GROUP = "group.app.immich.share"
enum WidgetError: Error, Codable {
case noLogin
case fetchFailed
case unknown
case albumNotFound
case noAssetsAvailable
}
enum FetchError: Error {
case unableToResize
case invalidImage
case invalidURL
case fetchFailed
}
extension WidgetError: LocalizedError {
@@ -24,14 +30,8 @@ extension WidgetError: LocalizedError {
case .albumNotFound:
return "Album not found"
case .invalidURL:
return "An invalid URL was used"
case .invalidImage:
return "An invalid image was received"
default:
return "An unknown error occured"
case .noAssetsAvailable:
return "No assets available"
}
}
}
@@ -52,10 +52,11 @@ struct Asset: Codable {
}
}
struct SearchFilters: Codable {
var type: AssetType = .image
let size: Int
struct SearchFilter: Codable {
var type = AssetType.image
var size = 1
var albumIds: [String] = []
var isFavorite: Bool? = nil
}
struct MemoryResult: Codable {
@@ -70,12 +71,35 @@ struct MemoryResult: Codable {
let data: MemoryData
}
struct Album: Codable {
struct Album: Codable, Equatable {
let id: String
let albumName: String
}
let IMMICH_SHARE_GROUP = "group.app.immich.share"
static let NONE = Album(id: "NONE", albumName: "None")
static let FAVORITES = Album(id: "FAVORITES", albumName: "Favorites")
var filter: SearchFilter {
switch self {
case Album.NONE:
return SearchFilter()
case Album.FAVORITES:
return SearchFilter(isFavorite: true)
// regular album
default:
return SearchFilter(albumIds: [id])
}
}
var isVirtual: Bool {
switch self {
case Album.NONE, Album.FAVORITES:
return true
default:
return false
}
}
}
// MARK: API
@@ -132,7 +156,8 @@ class ImmichAPI {
return components?.url
}
func fetchSearchResults(with filters: SearchFilters) async throws
func fetchSearchResults(with filters: SearchFilter = Album.NONE.filter)
async throws
-> [Asset]
{
// get URL
@@ -178,7 +203,7 @@ class ImmichAPI {
return try JSONDecoder().decode([MemoryResult].self, from: data)
}
func fetchImage(asset: Asset) async throws(WidgetError) -> UIImage {
func fetchImage(asset: Asset) async throws(FetchError) -> UIImage {
let thumbnailParams = [URLQueryItem(name: "size", value: "preview")]
let assetEndpoint = "/assets/" + asset.id + "/thumbnail"
@@ -199,7 +224,7 @@ class ImmichAPI {
let decodeOptions: [NSString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: 400,
kCGImageSourceThumbnailMaxPixelSize: 512,
kCGImageSourceCreateThumbnailWithTransform: true,
]
@@ -23,26 +23,27 @@ struct ImmichMemoryProvider: TimelineProvider {
Task {
guard let api = try? await ImmichAPI() else {
completion(ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin).entries.first!)
completion(
ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first!
)
return
}
guard let memories = try? await api.fetchMemory(for: Date.now)
else {
completion(ImageEntry.handleCacheFallback(for: cacheKey).entries.first!)
completion(ImageEntry.handleError(for: cacheKey).entries.first!)
return
}
for memory in memories {
if let asset = memory.assets.first(where: { $0.type == .image }),
var entry = try? await ImageEntry.build(
let entry = try? await ImageEntry.build(
api: api,
asset: asset,
dateOffset: 0,
subtitle: getYearDifferenceSubtitle(assetYear: memory.data.year)
)
{
entry.resize()
completion(entry)
return
}
@@ -50,20 +51,17 @@ struct ImmichMemoryProvider: TimelineProvider {
// fallback to random image
guard
let randomImage = try? await api.fetchSearchResults(
with: SearchFilters(size: 1)
).first,
var imageEntry = try? await ImageEntry.build(
let randomImage = try? await api.fetchSearchResults().first,
let imageEntry = try? await ImageEntry.build(
api: api,
asset: randomImage,
dateOffset: 0
)
else {
completion(ImageEntry.handleCacheFallback(for: cacheKey).entries.first!)
completion(ImageEntry.handleError(for: cacheKey).entries.first!)
return
}
imageEntry.resize()
completion(imageEntry)
}
}
@@ -80,7 +78,7 @@ struct ImmichMemoryProvider: TimelineProvider {
guard let api = try? await ImmichAPI() else {
completion(
ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin)
ImageEntry.handleError(for: cacheKey, error: .noLogin)
)
return
}
@@ -119,25 +117,28 @@ struct ImmichMemoryProvider: TimelineProvider {
// If we didnt add any memory images (some failure occured or no images in memory),
// default to 12 hours of random photos
if entries.count == 0 {
entries.append(
contentsOf: (try? await generateRandomEntries(
// 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
)) ?? []
)
}
)
// If we fail to fetch images, we still want to add an entry
// with a nil image and an error
if entries.count == 0 {
completion(ImageEntry.handleCacheFallback(for: cacheKey))
return
}
// Load or save a cached asset for when network conditions are bad
if search.count == 0 {
completion(
ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
)
return
}
// Resize all images to something that can be stored by iOS
for i in entries.indices {
entries[i].resize()
entries.append(contentsOf: search)
} catch {
completion(ImageEntry.handleError(for: cacheKey))
return
}
}
// cache the last image
@@ -8,20 +8,21 @@ extension Album: @unchecked Sendable, AppEntity, Identifiable {
struct AlbumQuery: EntityQuery {
func entities(for identifiers: [Album.ID]) async throws -> [Album] {
// use cached albums to search
var albums = (try? await AlbumCache.shared.getAlbums()) ?? []
albums.insert(NO_ALBUM, at: 0)
return albums.filter {
return await suggestedEntities().filter {
identifiers.contains($0.id)
}
}
func suggestedEntities() async throws -> [Album] {
var albums = (try? await AlbumCache.shared.getAlbums(refresh: true)) ?? []
albums.insert(NO_ALBUM, at: 0)
func suggestedEntities() async -> [Album] {
let albums = (try? await AlbumCache.shared.getAlbums()) ?? []
return albums
let options =
[
NONE,
FAVORITES,
] + albums
return options
}
}
@@ -35,8 +36,6 @@ extension Album: @unchecked Sendable, AppEntity, Identifiable {
}
}
let NO_ALBUM = Album(id: "NONE", albumName: "None")
struct RandomConfigurationAppIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource { "Select Album" }
static var description: IntentDescription {
@@ -64,26 +63,25 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
-> ImageEntry
{
let cacheKey = "random_none_\(context.family.rawValue)"
guard let api = try? await ImmichAPI() else {
return ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin).entries.first!
return ImageEntry.handleError(for: cacheKey, error: .noLogin).entries
.first!
}
guard
let randomImage = try? await api.fetchSearchResults(
with: SearchFilters(size: 1)
with: Album.NONE.filter
).first,
var entry = try? await ImageEntry.build(
let entry = try? await ImageEntry.build(
api: api,
asset: randomImage,
dateOffset: 0
)
else {
return ImageEntry.handleCacheFallback(for: cacheKey).entries.first!
return ImageEntry.handleError(for: cacheKey).entries.first!
}
entry.resize()
return entry
}
@@ -97,52 +95,36 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
let now = Date()
// nil if album is NONE or nil
let albumId =
configuration.album?.id != "NONE" ? configuration.album?.id : nil
let albumName: String? =
albumId != nil ? configuration.album?.albumName : nil
let album = configuration.album ?? Album.NONE
let albumName = album.isVirtual ? nil : album.albumName
let cacheKey = "random_\(albumId ?? "none")_\(context.family.rawValue)"
let cacheKey = "random_\(album.id)_\(context.family.rawValue)"
// If we don't have a server config, return an entry with an error
guard let api = try? await ImmichAPI() else {
return ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin)
}
if albumId != nil {
// make sure the album exists on server, otherwise show error
guard let albums = try? await api.fetchAlbums() else {
return ImageEntry.handleCacheFallback(for: cacheKey)
}
if !albums.contains(where: { $0.id == albumId }) {
return ImageEntry.handleCacheFallback(
for: cacheKey,
error: .albumNotFound
)
}
return ImageEntry.handleError(for: cacheKey, error: .noLogin)
}
// build entries
entries.append(
contentsOf: (try? await generateRandomEntries(
// 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,
albumId: albumId,
filter: album.filter,
subtitle: configuration.showAlbumName ? albumName : nil
))
?? []
)
)
// Load or save a cached asset for when network conditions are bad
if entries.count == 0 {
return ImageEntry.handleCacheFallback(for: cacheKey)
}
// Load or save a cached asset for when network conditions are bad
if search.count == 0 {
return ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
}
// Resize all images to something that can be stored by iOS
for i in entries.indices {
entries[i].resize()
entries.append(contentsOf: search)
} catch {
return ImageEntry.handleError(for: cacheKey)
}
// cache the last image
@@ -1 +0,0 @@
{"client":{"name":"basic","version":0,"file-system":"device-agnostic","perform-ownership-analysis":"no"},"targets":{"":["<all>"]},"commands":{"<all>":{"tool":"phony","inputs":["<WorkspaceHeaderMapVFSFilesWritten>"],"outputs":["<all>"]},"P0:::Gate WorkspaceHeaderMapVFSFilesWritten":{"tool":"phony","inputs":[],"outputs":["<WorkspaceHeaderMapVFSFilesWritten>"]}}}
+12 -3
View File
@@ -16,6 +16,11 @@ const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB
// Secure storage keys
const String kSecuredPinCode = "secured_pin_code";
// background_downloader task groups
const String kManualUploadGroup = 'manual_upload_group';
const String kBackupGroup = 'backup_group';
const String kBackupLivePhotoGroup = 'backup_live_photo_group';
// Timeline constants
const int kTimelineNoneSegmentSize = 120;
const int kTimelineAssetLoadBatchSize = 256;
@@ -28,7 +33,11 @@ const String appShareGroupId = "group.app.immich.share";
// add widget identifiers here for new widgets
// these are used to force a widget refresh
const List<String> kWidgetNames = [
'com.immich.widget.random',
'com.immich.widget.memory',
// (iOSName, androidFQDN)
const List<(String, String)> kWidgetNames = [
('com.immich.widget.random', 'app.alextran.immich.widget.RandomReceiver'),
('com.immich.widget.memory', 'app.alextran.immich.widget.MemoryReceiver'),
];
const double kUploadStatusFailed = -1.0;
const double kUploadStatusCanceled = -2.0;
@@ -86,4 +86,32 @@ class RemoteAlbum {
assetCount.hashCode ^
ownerName.hashCode;
}
RemoteAlbum copyWith({
String? id,
String? name,
String? ownerId,
String? description,
DateTime? createdAt,
DateTime? updatedAt,
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
int? assetCount,
String? ownerName,
}) {
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,
);
}
}
@@ -45,6 +45,7 @@ class LocalAsset extends BaseAsset {
}''';
}
// Not checking for remoteId here
@override
bool operator ==(Object other) {
if (other is! LocalAsset) return false;
@@ -14,6 +14,8 @@ class RemoteAsset extends BaseAsset {
final String? thumbHash;
final AssetVisibility visibility;
final String ownerId;
final String? stackId;
final int stackCount;
const RemoteAsset({
required this.id,
@@ -31,6 +33,8 @@ class RemoteAsset extends BaseAsset {
this.thumbHash,
this.visibility = AssetVisibility.timeline,
super.livePhotoVideoId,
this.stackId,
this.stackCount = 0,
});
@override
@@ -56,9 +60,14 @@ class RemoteAsset extends BaseAsset {
isFavorite: $isFavorite,
thumbHash: ${thumbHash ?? "<NA>"},
visibility: $visibility,
stackId: ${stackId ?? "<NA>"},
stackCount: $stackCount,
checksum: $checksum,
livePhotoVideoId: ${livePhotoVideoId ?? "<NA>"},
}''';
}
// Not checking for localId here
@override
bool operator ==(Object other) {
if (other is! RemoteAsset) return false;
@@ -67,7 +76,9 @@ class RemoteAsset extends BaseAsset {
id == other.id &&
ownerId == other.ownerId &&
thumbHash == other.thumbHash &&
visibility == other.visibility;
visibility == other.visibility &&
stackId == other.stackId &&
stackCount == other.stackCount;
}
@override
@@ -77,7 +88,9 @@ class RemoteAsset extends BaseAsset {
ownerId.hashCode ^
localId.hashCode ^
thumbHash.hashCode ^
visibility.hashCode;
visibility.hashCode ^
stackId.hashCode ^
stackCount.hashCode;
RemoteAsset copyWith({
String? id,
@@ -95,6 +108,8 @@ class RemoteAsset extends BaseAsset {
String? thumbHash,
AssetVisibility? visibility,
String? livePhotoVideoId,
String? stackId,
int? stackCount,
}) {
return RemoteAsset(
id: id ?? this.id,
@@ -112,6 +127,8 @@ class RemoteAsset extends BaseAsset {
thumbHash: thumbHash ?? this.thumbHash,
visibility: visibility ?? this.visibility,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId,
stackCount: stackCount ?? this.stackCount,
);
}
}
+15 -1
View File
@@ -124,7 +124,21 @@ class DriftMemory {
@override
String toString() {
return 'Memory(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, ownerId: $ownerId, type: $type, data: $data, isSaved: $isSaved, memoryAt: $memoryAt, seenAt: $seenAt, showAt: $showAt, hideAt: $hideAt, assets: $assets)';
return '''Memory {
id: $id,
createdAt: $createdAt,
updatedAt: $updatedAt,
deletedAt: ${deletedAt ?? "<NA>"},
ownerId: $ownerId,
type: $type,
data: $data,
isSaved: $isSaved,
memoryAt: $memoryAt,
seenAt: ${seenAt ?? "<NA>"},
showAt: ${showAt ?? "<NA>"},
hideAt: ${hideAt ?? "<NA>"},
assets: $assets
}''';
}
@override
+116 -9
View File
@@ -1,7 +1,8 @@
import 'dart:convert';
class Person {
const Person({
// TODO: Remove PersonDto once Isar is removed
class PersonDto {
const PersonDto({
required this.id,
this.birthDate,
required this.isHidden,
@@ -22,7 +23,7 @@ class Person {
return 'Person(id: $id, birthDate: $birthDate, isHidden: $isHidden, name: $name, thumbnailPath: $thumbnailPath, updatedAt: $updatedAt)';
}
Person copyWith({
PersonDto copyWith({
String? id,
DateTime? birthDate,
bool? isHidden,
@@ -30,7 +31,7 @@ class Person {
String? thumbnailPath,
DateTime? updatedAt,
}) {
return Person(
return PersonDto(
id: id ?? this.id,
birthDate: birthDate ?? this.birthDate,
isHidden: isHidden ?? this.isHidden,
@@ -51,8 +52,8 @@ class Person {
};
}
factory Person.fromMap(Map<String, dynamic> map) {
return Person(
factory PersonDto.fromMap(Map<String, dynamic> map) {
return PersonDto(
id: map['id'] as String,
birthDate: map['birthDate'] != null
? DateTime.fromMillisecondsSinceEpoch(map['birthDate'] as int)
@@ -68,11 +69,11 @@ class Person {
String toJson() => json.encode(toMap());
factory Person.fromJson(String source) =>
Person.fromMap(json.decode(source) as Map<String, dynamic>);
factory PersonDto.fromJson(String source) =>
PersonDto.fromMap(json.decode(source) as Map<String, dynamic>);
@override
bool operator ==(covariant Person other) {
bool operator ==(covariant PersonDto other) {
if (identical(this, other)) return true;
return other.id == id &&
@@ -93,3 +94,109 @@ class Person {
updatedAt.hashCode;
}
}
// Model for a person stored in the server
class Person {
final String id;
final DateTime createdAt;
final DateTime updatedAt;
final String ownerId;
final String name;
final String? faceAssetId;
final String thumbnailPath;
final bool isFavorite;
final bool isHidden;
final String? color;
final DateTime? birthDate;
const Person({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.ownerId,
required this.name,
this.faceAssetId,
required this.thumbnailPath,
required this.isFavorite,
required this.isHidden,
required this.color,
this.birthDate,
});
Person copyWith({
String? id,
DateTime? createdAt,
DateTime? updatedAt,
String? ownerId,
String? name,
String? faceAssetId,
String? thumbnailPath,
bool? isFavorite,
bool? isHidden,
String? color,
DateTime? birthDate,
}) {
return Person(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
ownerId: ownerId ?? this.ownerId,
name: name ?? this.name,
faceAssetId: faceAssetId ?? this.faceAssetId,
thumbnailPath: thumbnailPath ?? this.thumbnailPath,
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>"},
thumbnailPath: $thumbnailPath,
isFavorite: $isFavorite,
isHidden: $isHidden,
color: ${color ?? "<NA>"},
birthDate: ${birthDate ?? "<NA>"}
}''';
}
@override
bool operator ==(covariant Person 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.thumbnailPath == thumbnailPath &&
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 ^
thumbnailPath.hashCode ^
isFavorite.hashCode ^
isHidden.hashCode ^
color.hashCode ^
birthDate.hashCode;
}
}
@@ -0,0 +1,38 @@
import 'package:collection/collection.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class SearchResult {
final List<BaseAsset> assets;
final int? nextPage;
const SearchResult({
required this.assets,
this.nextPage,
});
int get totalAssets => assets.length;
SearchResult copyWith({
List<BaseAsset>? assets,
int? nextPage,
}) {
return SearchResult(
assets: assets ?? this.assets,
nextPage: nextPage ?? this.nextPage,
);
}
@override
String toString() => 'SearchResult(assets: $assets, nextPage: $nextPage)';
@override
bool operator ==(covariant SearchResult other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.assets, assets) && other.nextPage == nextPage;
}
@override
int get hashCode => assets.hashCode ^ nextPage.hashCode;
}
+31 -28
View File
@@ -1,5 +1,3 @@
import 'dart:convert';
// Model for a stack stored in the server
class Stack {
final String id;
@@ -32,34 +30,15 @@ class Stack {
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'id': id,
'createdAt': createdAt.millisecondsSinceEpoch,
'updatedAt': updatedAt.millisecondsSinceEpoch,
'ownerId': ownerId,
'primaryAssetId': primaryAssetId,
};
}
factory Stack.fromMap(Map<String, dynamic> map) {
return Stack(
id: map['id'] as String,
createdAt: DateTime.fromMillisecondsSinceEpoch(map['createdAt'] as int),
updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updatedAt'] as int),
ownerId: map['ownerId'] as String,
primaryAssetId: map['primaryAssetId'] as String,
);
}
String toJson() => json.encode(toMap());
factory Stack.fromJson(String source) =>
Stack.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() {
return 'Stack(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, ownerId: $ownerId, primaryAssetId: $primaryAssetId)';
return '''Stack {
id: $id,
createdAt: $createdAt,
updatedAt: $updatedAt,
ownerId: $ownerId,
primaryAssetId: $primaryAssetId
}''';
}
@override
@@ -82,3 +61,27 @@ class Stack {
primaryAssetId.hashCode;
}
}
class StackResponse {
final String id;
final String primaryAssetId;
final List<String> assetIds;
const StackResponse({
required this.id,
required this.primaryAssetId,
required this.assetIds,
});
@override
bool operator ==(covariant StackResponse other) {
if (identical(this, other)) return true;
return other.id == id &&
other.primaryAssetId == primaryAssetId &&
other.assetIds == assetIds;
}
@override
int get hashCode => id.hashCode ^ primaryAssetId.hashCode ^ assetIds.hashCode;
}
+4 -1
View File
@@ -68,7 +68,10 @@ enum StoreKey<T> {
manageLocalMediaAndroid<bool>._(137),
// Experimental stuff
photoManagerCustomFilter<bool>._(1000);
photoManagerCustomFilter<bool>._(1000),
betaPromptShown<bool>._(1001),
betaTimeline<bool>._(1002),
enableBackup<bool>._(1003);
const StoreKey._(this.id);
final int id;
@@ -1,3 +1,5 @@
import 'package:immich_mobile/domain/utils/event_stream.dart';
enum GroupAssetsBy {
day,
month,
@@ -38,3 +40,7 @@ class TimeBucket extends Bucket {
@override
int get hashCode => super.hashCode ^ date.hashCode;
}
class TimelineReloadEvent extends Event {
const TimelineReloadEvent();
}
+88
View File
@@ -1,3 +1,6 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
// TODO: Rename to User once Isar is removed
@@ -123,3 +126,88 @@ quotaSizeInBytes: $quotaSizeInBytes,
quotaUsageInBytes.hashCode ^
quotaSizeInBytes.hashCode;
}
class PartnerUserDto {
final String id;
final String email;
final String name;
final bool inTimeline;
final String? profileImagePath;
const PartnerUserDto({
required this.id,
required this.email,
required this.name,
required this.inTimeline,
this.profileImagePath,
});
PartnerUserDto copyWith({
String? id,
String? email,
String? name,
bool? inTimeline,
String? profileImagePath,
}) {
return PartnerUserDto(
id: id ?? this.id,
email: email ?? this.email,
name: name ?? this.name,
inTimeline: inTimeline ?? this.inTimeline,
profileImagePath: profileImagePath ?? this.profileImagePath,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'id': id,
'email': email,
'name': name,
'inTimeline': inTimeline,
'profileImagePath': profileImagePath,
};
}
factory PartnerUserDto.fromMap(Map<String, dynamic> map) {
return PartnerUserDto(
id: map['id'] as String,
email: map['email'] as String,
name: map['name'] as String,
inTimeline: map['inTimeline'] as bool,
profileImagePath: map['profileImagePath'] != null
? map['profileImagePath'] as String
: null,
);
}
String toJson() => json.encode(toMap());
factory PartnerUserDto.fromJson(String source) =>
PartnerUserDto.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() {
return 'PartnerUserDto(id: $id, email: $email, name: $name, inTimeline: $inTimeline, profileImagePath: $profileImagePath)';
}
@override
bool operator ==(covariant PartnerUserDto other) {
if (identical(this, other)) return true;
return other.id == id &&
other.email == email &&
other.name == name &&
other.inTimeline == inTimeline &&
other.profileImagePath == profileImagePath;
}
@override
int get hashCode {
return id.hashCode ^
email.hashCode ^
name.hashCode ^
inTimeline.hashCode ^
profileImagePath.hashCode;
}
}
@@ -24,6 +24,17 @@ class AssetService {
: _remoteAssetRepository.watchAsset(id);
}
Future<List<RemoteAsset>> getStack(RemoteAsset asset) async {
if (asset.stackId == null) {
return [];
}
return _remoteAssetRepository.getStackChildren(asset).then((assets) {
// Include the primary asset in the stack as the first item
return [asset, ...assets];
});
}
Future<ExifInfo?> getExif(BaseAsset asset) async {
if (!asset.hasRemote) {
return null;
@@ -7,11 +7,15 @@ class LocalAlbumService {
const LocalAlbumService(this._repository);
Future<List<LocalAlbum>> getAll() {
return _repository.getAll();
Future<List<LocalAlbum>> getAll({Set<SortLocalAlbumsBy> sortBy = const {}}) {
return _repository.getAll(sortBy: sortBy);
}
Future<LocalAsset?> getThumbnail(String albumId) {
return _repository.getThumbnail(albumId);
}
Future<void> update(LocalAlbum album) {
return _repository.upsert(album);
}
}
@@ -0,0 +1,61 @@
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
class DriftPartnerService {
final DriftPartnerRepository _driftPartnerRepository;
final PartnerApiRepository _partnerApiRepository;
const DriftPartnerService(
this._driftPartnerRepository,
this._partnerApiRepository,
);
Future<List<PartnerUserDto>> getSharedWith(String userId) {
return _driftPartnerRepository.getSharedWith(userId);
}
Future<List<PartnerUserDto>> getSharedBy(String userId) {
return _driftPartnerRepository.getSharedBy(userId);
}
Future<List<PartnerUserDto>> getAvailablePartners(
String currentUserId,
) async {
final otherUsers =
await _driftPartnerRepository.getAvailablePartners(currentUserId);
final currentPartners =
await _driftPartnerRepository.getSharedBy(currentUserId);
final available = otherUsers.where((user) {
return !currentPartners.any((partner) => partner.id == user.id);
}).toList();
return available;
}
Future<void> toggleShowInTimeline(String partnerId, String userId) async {
final partner = await _driftPartnerRepository.getPartner(partnerId, userId);
if (partner == null) {
debugPrint("Partner not found: $partnerId for user: $userId");
return;
}
await _partnerApiRepository.update(
partnerId,
inTimeline: !partner.inTimeline,
);
await _driftPartnerRepository.toggleShowInTimeline(partner, userId);
}
Future<void> addPartner(String partnerId, String userId) async {
await _partnerApiRepository.create(partnerId);
await _driftPartnerRepository.create(partnerId, userId);
}
Future<void> removePartner(String partnerId, String userId) async {
await _partnerApiRepository.delete(partnerId);
await _driftPartnerRepository.delete(partnerId, userId);
}
}
@@ -1,4 +1,8 @@
import 'dart:async';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
@@ -10,6 +14,10 @@ class RemoteAlbumService {
const RemoteAlbumService(this._repository, this._albumApiRepository);
Stream<RemoteAlbum?> watchAlbum(String albumId) {
return _repository.watchAlbum(albumId);
}
Future<List<RemoteAlbum>> getAll() {
return _repository.getAll();
}
@@ -75,4 +83,68 @@ class RemoteAlbumService {
return album;
}
Future<RemoteAlbum> updateAlbum(
String albumId, {
String? name,
String? description,
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
}) async {
final updatedAlbum = await _albumApiRepository.updateAlbum(
albumId,
name: name,
description: description,
thumbnailAssetId: thumbnailAssetId,
isActivityEnabled: isActivityEnabled,
order: order,
);
// Update the local database
await _repository.update(updatedAlbum);
return updatedAlbum;
}
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
return _repository.getDateRange(albumId);
}
Future<List<UserDto>> getSharedUsers(String albumId) {
return _repository.getSharedUsers(albumId);
}
Future<List<RemoteAsset>> getAssets(String albumId) {
return _repository.getAssets(albumId);
}
Future<int> addAssets({
required String albumId,
required List<String> assetIds,
}) async {
final album = await _albumApiRepository.addAssets(
albumId,
assetIds,
);
await _repository.addAssets(albumId, album.added);
return album.added.length;
}
Future<void> deleteAlbum(String albumId) async {
await _albumApiRepository.deleteAlbum(albumId);
await _repository.deleteAlbum(albumId);
}
Future<void> addUsers({
required String albumId,
required List<String> userIds,
}) async {
await _albumApiRepository.addUsers(albumId, userIds);
return _repository.addUsers(albumId, userIds);
}
}
@@ -0,0 +1,92 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/search_result.model.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api show AssetVisibility;
import 'package:openapi/api.dart' hide AssetVisibility;
class SearchService {
final _log = Logger("SearchService");
final SearchApiRepository _searchApiRepository;
SearchService(this._searchApiRepository);
Future<List<String>?> getSearchSuggestions(
SearchSuggestionType type, {
String? country,
String? state,
String? make,
String? model,
}) async {
try {
return await _searchApiRepository.getSearchSuggestions(
type,
country: country,
state: state,
make: make,
model: model,
);
} catch (e) {
_log.warning("Failed to get search suggestions", e);
}
return [];
}
Future<SearchResult?> search(SearchFilter filter, int page) async {
try {
final response = await _searchApiRepository.search(filter, page);
if (response == null || response.assets.items.isEmpty) {
return null;
}
return SearchResult(
assets: response.assets.items.map((e) => e.toDto()).toList(),
nextPage: response.assets.nextPage?.toInt(),
);
} catch (error, stackTrace) {
_log.severe("Failed to search for assets", error, stackTrace);
}
return null;
}
}
extension on AssetResponseDto {
RemoteAsset toDto() {
return RemoteAsset(
id: id,
name: originalFileName,
checksum: checksum,
createdAt: fileCreatedAt,
updatedAt: updatedAt,
ownerId: ownerId,
visibility: switch (visibility) {
api.AssetVisibility.timeline => AssetVisibility.timeline,
api.AssetVisibility.hidden => AssetVisibility.hidden,
api.AssetVisibility.archive => AssetVisibility.archive,
api.AssetVisibility.locked => AssetVisibility.locked,
_ => AssetVisibility.timeline,
},
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
height: exifInfo?.exifImageHeight?.toInt(),
width: exifInfo?.exifImageWidth?.toInt(),
isFavorite: isFavorite,
livePhotoVideoId: livePhotoVideoId,
thumbHash: thumbhash,
localId: null,
type: type.toAssetType(),
);
}
}
extension on AssetTypeEnum {
AssetType toAssetType() => switch (this) {
AssetTypeEnum.IMAGE => AssetType.image,
AssetTypeEnum.VIDEO => AssetType.video,
AssetTypeEnum.AUDIO => AssetType.audio,
AssetTypeEnum.OTHER => AssetType.other,
_ => throw Exception('Unknown AssetType value: $this'),
};
}
@@ -93,6 +93,8 @@ class StoreService {
await _storeRepository.deleteAll();
_cache.clear();
}
bool get isBetaTimelineEnabled => tryGet(StoreKey.betaTimeline) ?? false;
}
class StoreKeyNotFoundException implements Exception {
@@ -240,6 +240,10 @@ class SyncStreamService {
return _syncStreamRepository.deleteUserMetadatasV1(
data.cast(),
);
case SyncEntityType.personV1:
return _syncStreamRepository.updatePeopleV1(data.cast());
case SyncEntityType.personDeleteV1:
return _syncStreamRepository.deletePeopleV1(data.cast());
default:
_logger.warning("Unknown sync data type: $type");
}
@@ -65,6 +65,9 @@ class TimelineFactory {
TimelineService place(String place) =>
TimelineService(_timelineRepository.place(place, groupBy));
TimelineService fromAssets(List<BaseAsset> assets) =>
TimelineService(_timelineRepository.fromAssets(assets));
}
class TimelineService {
+28 -7
View File
@@ -4,13 +4,24 @@ import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/utils/isolate.dart';
import 'package:worker_manager/worker_manager.dart';
typedef SyncCallback = void Function();
typedef SyncErrorCallback = void Function(String error);
class BackgroundSyncManager {
final SyncCallback? onRemoteSyncStart;
final SyncCallback? onRemoteSyncComplete;
final SyncErrorCallback? onRemoteSyncError;
Cancelable<void>? _syncTask;
Cancelable<void>? _syncWebsocketTask;
Cancelable<void>? _deviceAlbumSyncTask;
Cancelable<void>? _hashTask;
BackgroundSyncManager();
BackgroundSyncManager({
this.onRemoteSyncStart,
this.onRemoteSyncComplete,
this.onRemoteSyncError,
});
Future<void> cancel() {
final futures = <Future>[];
@@ -72,10 +83,16 @@ class BackgroundSyncManager {
return _syncTask!.future;
}
onRemoteSyncStart?.call();
_syncTask = runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).sync(),
);
return _syncTask!.whenComplete(() {
onRemoteSyncComplete?.call();
_syncTask = null;
}).catchError((error) {
onRemoteSyncError?.call(error.toString());
_syncTask = null;
});
}
@@ -84,14 +101,18 @@ class BackgroundSyncManager {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
}
_syncWebsocketTask = runInIsolateGentle(
computation: (ref) => ref
.read(syncStreamServiceProvider)
.handleWsAssetUploadReadyV1Batch(batchData),
);
_syncWebsocketTask = _handleWsAssetUploadReadyV1Batch(batchData);
return _syncWebsocketTask!.whenComplete(() {
_syncWebsocketTask = null;
});
}
}
Cancelable<void> _handleWsAssetUploadReadyV1Batch(
List<dynamic> batchData,
) =>
runInIsolateGentle(
computation: (ref) => ref
.read(syncStreamServiceProvider)
.handleWsAssetUploadReadyV1Batch(batchData),
);
+1 -9
View File
@@ -1,17 +1,9 @@
import 'dart:async';
sealed class Event {
class Event {
const Event();
}
class TimelineReloadEvent extends Event {
const TimelineReloadEvent();
}
class ViewerOpenBottomSheetEvent extends Event {
const ViewerOpenBottomSheetEvent();
}
class EventStream {
EventStream._();
@@ -13,6 +13,18 @@ class BackupAlbum {
BackupAlbum(this.id, this.lastBackup, this.selection);
Id get isarId => fastHash(id);
BackupAlbum copyWith({
String? id,
DateTime? lastBackup,
BackupSelection? selection,
}) {
return BackupAlbum(
id ?? this.id,
lastBackup ?? this.lastBackup,
selection ?? this.selection,
);
}
}
enum BackupSelection {
@@ -33,6 +33,10 @@ extension ContextHelper on BuildContext {
// Returns the current Primary color of the Theme
Color get primaryColor => themeData.colorScheme.primary;
Color get logoYellow => const Color.fromARGB(255, 255, 184, 0);
Color get logoRed => const Color.fromARGB(255, 230, 65, 30);
Color get logoPink => const Color.fromARGB(255, 222, 127, 179);
Color get logoGreen => const Color.fromARGB(255, 49, 164, 82);
// Returns the Scaffold background color of the Theme
Color get scaffoldBackgroundColor => colorScheme.surface;
@@ -1,3 +1,6 @@
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
extension TimeAgoExtension on DateTime {
/// Displays the time difference of this [DateTime] object to the current time as a [String]
String timeAgo({bool numericDates = true}) {
@@ -35,3 +38,56 @@ extension TimeAgoExtension on DateTime {
return '${(difference.inDays / 365).floor()} years ago';
}
}
/// Extension to format date ranges according to UI requirements
extension DateRangeFormatting on DateTime {
/// Formats a date range according to specific rules:
/// - Single date of this year: "Aug 28"
/// - Single date of other year: "Aug 28, 2023"
/// - Date range of this year: "Mar 23-May 31"
/// - Date range of other year: "Aug 28 - Sep 30, 2023"
/// - Date range over multiple years: "Apr 17, 2021 - Apr 9, 2022"
static String formatDateRange(
DateTime startDate,
DateTime endDate,
Locale? locale,
) {
final now = DateTime.now();
final currentYear = now.year;
final localeString = locale?.toString() ?? 'en_US';
// Check if it's a single date (same day)
if (startDate.year == endDate.year &&
startDate.month == endDate.month &&
startDate.day == endDate.day) {
if (startDate.year == currentYear) {
// Single date of this year: "Aug 28"
return DateFormat.MMMd(localeString).format(startDate);
} else {
// Single date of other year: "Aug 28, 2023"
return DateFormat.yMMMd(localeString).format(startDate);
}
}
// It's a date range
if (startDate.year == endDate.year) {
// Same year
if (startDate.year == currentYear) {
// Date range of this year: "Mar 23-May 31"
final startFormatted = DateFormat.MMMd(localeString).format(startDate);
final endFormatted = DateFormat.MMMd(localeString).format(endDate);
return '$startFormatted - $endFormatted';
} else {
// Date range of other year: "Aug 28 - Sep 30, 2023"
final startFormatted = DateFormat.MMMd(localeString).format(startDate);
final endFormatted = DateFormat.MMMd(localeString).format(endDate);
return '$startFormatted - $endFormatted, ${startDate.year}';
}
} else {
// Date range over multiple years: "Apr 17, 2021 - Apr 9, 2022"
final startFormatted = DateFormat.yMMMd(localeString).format(startDate);
final endFormatted = DateFormat.yMMMd(localeString).format(endDate);
return '$startFormatted - $endFormatted';
}
}
}
@@ -1,5 +1,6 @@
import 'remote_asset.entity.dart';
import 'local_asset.entity.dart';
import 'stack.entity.dart';
mergedAsset: SELECT * FROM
(
@@ -18,13 +19,33 @@ mergedAsset: SELECT * FROM
rae.checksum,
rae.owner_id,
rae.live_photo_video_id,
0 as orientation
0 as orientation,
rae.stack_id,
COALESCE(stack_count.total_count, 0) AS stack_count
FROM
remote_asset_entity rae
LEFT JOIN
local_asset_entity lae ON rae.checksum = lae.checksum
LEFT JOIN
stack_entity se ON rae.stack_id = se.id
LEFT JOIN
(SELECT
stack_id,
COUNT(*) AS total_count
FROM remote_asset_entity
WHERE deleted_at IS NULL
AND visibility = 0
AND stack_id IS NOT NULL
GROUP BY stack_id
) AS stack_count ON rae.stack_id = stack_count.stack_id
WHERE
rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id in ?
rae.deleted_at IS NULL
AND rae.visibility = 0
AND rae.owner_id in ?
AND (
rae.stack_id IS NULL
OR rae.id = se.primary_asset_id
)
UNION ALL
SELECT
NULL as remote_id,
@@ -41,7 +62,9 @@ mergedAsset: SELECT * FROM
lae.checksum,
NULL as owner_id,
NULL as live_photo_video_id,
lae.orientation
lae.orientation,
NULL as stack_id,
0 AS stack_count
FROM
local_asset_entity lae
LEFT JOIN
@@ -68,8 +91,16 @@ FROM
remote_asset_entity rae
LEFT JOIN
local_asset_entity lae ON rae.checksum = lae.checksum
LEFT JOIN
stack_entity se ON rae.stack_id = se.id
WHERE
rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id in ?
rae.deleted_at IS NULL
AND rae.visibility = 0
AND rae.owner_id in ?
AND (
rae.stack_id IS NULL
OR rae.id = se.primary_asset_id
)
UNION ALL
SELECT
lae.name,
+15 -2
View File
@@ -7,6 +7,8 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.
as i3;
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
as i4;
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'
as i5;
class MergedAssetDrift extends i1.ModularAccessor {
MergedAssetDrift(i0.GeneratedDatabase db) : super(db);
@@ -18,7 +20,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
final generatedlimit = $write(limit, startIndex: $arrayStartIndex);
$arrayStartIndex += generatedlimit.amountOfVariables;
return customSelect(
'SELECT * FROM (SELECT rae.id AS remote_id, lae.id AS local_id, rae.name, rae.type, rae.created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandedvar1) UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) ORDER BY created_at DESC ${generatedlimit.sql}',
'SELECT * FROM (SELECT rae.id AS remote_id, lae.id AS local_id, rae.name, rae.type, rae.created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, COALESCE(stack_count.total_count, 0) AS stack_count FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum LEFT JOIN stack_entity AS se ON rae.stack_id = se.id LEFT JOIN (SELECT stack_id, COUNT(*) AS total_count FROM remote_asset_entity WHERE deleted_at IS NULL AND visibility = 0 AND stack_id IS NOT NULL GROUP BY stack_id) AS stack_count ON rae.stack_id = stack_count.stack_id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandedvar1) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, 0 AS stack_count FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) ORDER BY created_at DESC ${generatedlimit.sql}',
variables: [
for (var $ in var1) i0.Variable<String>($),
...generatedlimit.introducedVariables
@@ -26,6 +28,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
readsFrom: {
remoteAssetEntity,
localAssetEntity,
stackEntity,
...generatedlimit.watchedTables,
}).map((i0.QueryRow row) => MergedAssetResult(
remoteId: row.readNullable<String>('remote_id'),
@@ -44,6 +47,8 @@ class MergedAssetDrift extends i1.ModularAccessor {
ownerId: row.readNullable<String>('owner_id'),
livePhotoVideoId: row.readNullable<String>('live_photo_video_id'),
orientation: row.read<int>('orientation'),
stackId: row.readNullable<String>('stack_id'),
stackCount: row.read<int>('stack_count'),
));
}
@@ -53,7 +58,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
final expandedvar2 = $expandVar($arrayStartIndex, var2.length);
$arrayStartIndex += var2.length;
return customSelect(
'SELECT COUNT(*) AS asset_count, CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at, \'localtime\') END AS bucket_date FROM (SELECT rae.name, rae.created_at FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandedvar2) UNION ALL SELECT lae.name, lae.created_at FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) GROUP BY bucket_date ORDER BY bucket_date DESC',
'SELECT COUNT(*) AS asset_count, CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at, \'localtime\') END AS bucket_date FROM (SELECT rae.name, rae.created_at FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandedvar2) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT lae.name, lae.created_at FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) GROUP BY bucket_date ORDER BY bucket_date DESC',
variables: [
i0.Variable<int>(groupBy),
for (var $ in var2) i0.Variable<String>($)
@@ -61,6 +66,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
readsFrom: {
remoteAssetEntity,
localAssetEntity,
stackEntity,
}).map((i0.QueryRow row) => MergedBucketResult(
assetCount: row.read<int>('asset_count'),
bucketDate: row.read<String>('bucket_date'),
@@ -73,6 +79,9 @@ class MergedAssetDrift extends i1.ModularAccessor {
i4.$LocalAssetEntityTable get localAssetEntity =>
i1.ReadDatabaseContainer(attachedDatabase)
.resultSet<i4.$LocalAssetEntityTable>('local_asset_entity');
i5.$StackEntityTable get stackEntity =>
i1.ReadDatabaseContainer(attachedDatabase)
.resultSet<i5.$StackEntityTable>('stack_entity');
}
class MergedAssetResult {
@@ -91,6 +100,8 @@ class MergedAssetResult {
final String? ownerId;
final String? livePhotoVideoId;
final int orientation;
final String? stackId;
final int stackCount;
MergedAssetResult({
this.remoteId,
this.localId,
@@ -107,6 +118,8 @@ class MergedAssetResult {
this.ownerId,
this.livePhotoVideoId,
required this.orientation,
this.stackId,
required this.stackCount,
});
}
@@ -0,0 +1,34 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class PersonEntity extends Table with DriftDefaultsMixin {
const PersonEntity();
TextColumn get id => text()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
TextColumn get ownerId =>
text().references(UserEntity, #id, onDelete: KeyAction.cascade)();
TextColumn get name => text()();
// TODO: foreign key refering to asset faces
TextColumn get faceAssetId => text().nullable()();
TextColumn get thumbnailPath => text()();
BoolColumn get isFavorite => boolean()();
BoolColumn get isHidden => boolean()();
TextColumn get color => text().nullable()();
DateTimeColumn get birthDate => dateTime().nullable()();
@override
Set<Column> get primaryKey => {id};
}
@@ -0,0 +1,933 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart'
as i1;
import 'package:immich_mobile/infrastructure/entities/person.entity.dart' as i2;
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3;
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'
as i4;
import 'package:drift/internal/modular.dart' as i5;
typedef $$PersonEntityTableCreateCompanionBuilder = i1.PersonEntityCompanion
Function({
required String id,
i0.Value<DateTime> createdAt,
i0.Value<DateTime> updatedAt,
required String ownerId,
required String name,
i0.Value<String?> faceAssetId,
required String thumbnailPath,
required bool isFavorite,
required bool isHidden,
i0.Value<String?> color,
i0.Value<DateTime?> birthDate,
});
typedef $$PersonEntityTableUpdateCompanionBuilder = i1.PersonEntityCompanion
Function({
i0.Value<String> id,
i0.Value<DateTime> createdAt,
i0.Value<DateTime> updatedAt,
i0.Value<String> ownerId,
i0.Value<String> name,
i0.Value<String?> faceAssetId,
i0.Value<String> thumbnailPath,
i0.Value<bool> isFavorite,
i0.Value<bool> isHidden,
i0.Value<String?> color,
i0.Value<DateTime?> birthDate,
});
final class $$PersonEntityTableReferences extends i0.BaseReferences<
i0.GeneratedDatabase, i1.$PersonEntityTable, i1.PersonEntityData> {
$$PersonEntityTableReferences(super.$_db, super.$_table, super.$_typedResult);
static i4.$UserEntityTable _ownerIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i4.$UserEntityTable>('user_entity')
.createAlias(i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(db)
.resultSet<i1.$PersonEntityTable>('person_entity')
.ownerId,
i5.ReadDatabaseContainer(db)
.resultSet<i4.$UserEntityTable>('user_entity')
.id));
i4.$$UserEntityTableProcessedTableManager get ownerId {
final $_column = $_itemColumn<String>('owner_id')!;
final manager = i4
.$$UserEntityTableTableManager(
$_db,
i5.ReadDatabaseContainer($_db)
.resultSet<i4.$UserEntityTable>('user_entity'))
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_ownerIdTable($_db));
if (item == null) return manager;
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]));
}
}
class $$PersonEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$PersonEntityTable> {
$$PersonEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<String> get id => $composableBuilder(
column: $table.id, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<String> get name => $composableBuilder(
column: $table.name, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<String> get faceAssetId => $composableBuilder(
column: $table.faceAssetId,
builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<String> get thumbnailPath => $composableBuilder(
column: $table.thumbnailPath,
builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<bool> get isFavorite => $composableBuilder(
column: $table.isFavorite, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<bool> get isHidden => $composableBuilder(
column: $table.isHidden, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<String> get color => $composableBuilder(
column: $table.color, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<DateTime> get birthDate => $composableBuilder(
column: $table.birthDate, builder: (column) => i0.ColumnFilters(column));
i4.$$UserEntityTableFilterComposer get ownerId {
final i4.$$UserEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.ownerId,
referencedTable: i5.ReadDatabaseContainer($db)
.resultSet<i4.$UserEntityTable>('user_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i4.$$UserEntityTableFilterComposer(
$db: $db,
$table: i5.ReadDatabaseContainer($db)
.resultSet<i4.$UserEntityTable>('user_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$PersonEntityTableOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$PersonEntityTable> {
$$PersonEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<String> get id => $composableBuilder(
column: $table.id, builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<String> get name => $composableBuilder(
column: $table.name, builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<String> get faceAssetId => $composableBuilder(
column: $table.faceAssetId,
builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<String> get thumbnailPath => $composableBuilder(
column: $table.thumbnailPath,
builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<bool> get isFavorite => $composableBuilder(
column: $table.isFavorite,
builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<bool> get isHidden => $composableBuilder(
column: $table.isHidden, builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<String> get color => $composableBuilder(
column: $table.color, builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<DateTime> get birthDate => $composableBuilder(
column: $table.birthDate,
builder: (column) => i0.ColumnOrderings(column));
i4.$$UserEntityTableOrderingComposer get ownerId {
final i4.$$UserEntityTableOrderingComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.ownerId,
referencedTable: i5.ReadDatabaseContainer($db)
.resultSet<i4.$UserEntityTable>('user_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i4.$$UserEntityTableOrderingComposer(
$db: $db,
$table: i5.ReadDatabaseContainer($db)
.resultSet<i4.$UserEntityTable>('user_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$PersonEntityTableAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$PersonEntityTable> {
$$PersonEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<String> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
i0.GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
i0.GeneratedColumn<DateTime> get updatedAt =>
$composableBuilder(column: $table.updatedAt, builder: (column) => column);
i0.GeneratedColumn<String> get name =>
$composableBuilder(column: $table.name, builder: (column) => column);
i0.GeneratedColumn<String> get faceAssetId => $composableBuilder(
column: $table.faceAssetId, builder: (column) => column);
i0.GeneratedColumn<String> get thumbnailPath => $composableBuilder(
column: $table.thumbnailPath, builder: (column) => column);
i0.GeneratedColumn<bool> get isFavorite => $composableBuilder(
column: $table.isFavorite, builder: (column) => column);
i0.GeneratedColumn<bool> get isHidden =>
$composableBuilder(column: $table.isHidden, builder: (column) => column);
i0.GeneratedColumn<String> get color =>
$composableBuilder(column: $table.color, builder: (column) => column);
i0.GeneratedColumn<DateTime> get birthDate =>
$composableBuilder(column: $table.birthDate, builder: (column) => column);
i4.$$UserEntityTableAnnotationComposer get ownerId {
final i4.$$UserEntityTableAnnotationComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.ownerId,
referencedTable: i5.ReadDatabaseContainer($db)
.resultSet<i4.$UserEntityTable>('user_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i4.$$UserEntityTableAnnotationComposer(
$db: $db,
$table: i5.ReadDatabaseContainer($db)
.resultSet<i4.$UserEntityTable>('user_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$PersonEntityTableTableManager extends i0.RootTableManager<
i0.GeneratedDatabase,
i1.$PersonEntityTable,
i1.PersonEntityData,
i1.$$PersonEntityTableFilterComposer,
i1.$$PersonEntityTableOrderingComposer,
i1.$$PersonEntityTableAnnotationComposer,
$$PersonEntityTableCreateCompanionBuilder,
$$PersonEntityTableUpdateCompanionBuilder,
(i1.PersonEntityData, i1.$$PersonEntityTableReferences),
i1.PersonEntityData,
i0.PrefetchHooks Function({bool ownerId})> {
$$PersonEntityTableTableManager(
i0.GeneratedDatabase db, i1.$PersonEntityTable table)
: super(i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$$PersonEntityTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
i1.$$PersonEntityTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
i1.$$PersonEntityTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback: ({
i0.Value<String> id = const i0.Value.absent(),
i0.Value<DateTime> createdAt = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
i0.Value<String> ownerId = const i0.Value.absent(),
i0.Value<String> name = const i0.Value.absent(),
i0.Value<String?> faceAssetId = const i0.Value.absent(),
i0.Value<String> thumbnailPath = const i0.Value.absent(),
i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<bool> isHidden = const i0.Value.absent(),
i0.Value<String?> color = const i0.Value.absent(),
i0.Value<DateTime?> birthDate = const i0.Value.absent(),
}) =>
i1.PersonEntityCompanion(
id: id,
createdAt: createdAt,
updatedAt: updatedAt,
ownerId: ownerId,
name: name,
faceAssetId: faceAssetId,
thumbnailPath: thumbnailPath,
isFavorite: isFavorite,
isHidden: isHidden,
color: color,
birthDate: birthDate,
),
createCompanionCallback: ({
required String id,
i0.Value<DateTime> createdAt = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
required String ownerId,
required String name,
i0.Value<String?> faceAssetId = const i0.Value.absent(),
required String thumbnailPath,
required bool isFavorite,
required bool isHidden,
i0.Value<String?> color = const i0.Value.absent(),
i0.Value<DateTime?> birthDate = const i0.Value.absent(),
}) =>
i1.PersonEntityCompanion.insert(
id: id,
createdAt: createdAt,
updatedAt: updatedAt,
ownerId: ownerId,
name: name,
faceAssetId: faceAssetId,
thumbnailPath: thumbnailPath,
isFavorite: isFavorite,
isHidden: isHidden,
color: color,
birthDate: birthDate,
),
withReferenceMapper: (p0) => p0
.map((e) => (
e.readTable(table),
i1.$$PersonEntityTableReferences(db, table, e)
))
.toList(),
prefetchHooksCallback: ({ownerId = false}) {
return i0.PrefetchHooks(
db: db,
explicitlyWatchedTables: [],
addJoins: <
T extends i0.TableManagerState<
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic>>(state) {
if (ownerId) {
state = state.withJoin(
currentTable: table,
currentColumn: table.ownerId,
referencedTable:
i1.$$PersonEntityTableReferences._ownerIdTable(db),
referencedColumn:
i1.$$PersonEntityTableReferences._ownerIdTable(db).id,
) as T;
}
return state;
},
getPrefetchedDataCallback: (items) async {
return [];
},
);
},
));
}
typedef $$PersonEntityTableProcessedTableManager = i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$PersonEntityTable,
i1.PersonEntityData,
i1.$$PersonEntityTableFilterComposer,
i1.$$PersonEntityTableOrderingComposer,
i1.$$PersonEntityTableAnnotationComposer,
$$PersonEntityTableCreateCompanionBuilder,
$$PersonEntityTableUpdateCompanionBuilder,
(i1.PersonEntityData, i1.$$PersonEntityTableReferences),
i1.PersonEntityData,
i0.PrefetchHooks Function({bool ownerId})>;
class $PersonEntityTable extends i2.PersonEntity
with i0.TableInfo<$PersonEntityTable, i1.PersonEntityData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$PersonEntityTable(this.attachedDatabase, [this._alias]);
static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id');
@override
late final i0.GeneratedColumn<String> id = i0.GeneratedColumn<String>(
'id', aliasedName, false,
type: i0.DriftSqlType.string, requiredDuringInsert: true);
static const i0.VerificationMeta _createdAtMeta =
const i0.VerificationMeta('createdAt');
@override
late final i0.GeneratedColumn<DateTime> createdAt =
i0.GeneratedColumn<DateTime>('created_at', aliasedName, false,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: i3.currentDateAndTime);
static const i0.VerificationMeta _updatedAtMeta =
const i0.VerificationMeta('updatedAt');
@override
late final i0.GeneratedColumn<DateTime> updatedAt =
i0.GeneratedColumn<DateTime>('updated_at', aliasedName, false,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: i3.currentDateAndTime);
static const i0.VerificationMeta _ownerIdMeta =
const i0.VerificationMeta('ownerId');
@override
late final i0.GeneratedColumn<String> ownerId = i0.GeneratedColumn<String>(
'owner_id', aliasedName, false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'REFERENCES user_entity (id) ON DELETE CASCADE'));
static const i0.VerificationMeta _nameMeta =
const i0.VerificationMeta('name');
@override
late final i0.GeneratedColumn<String> name = i0.GeneratedColumn<String>(
'name', aliasedName, false,
type: i0.DriftSqlType.string, requiredDuringInsert: true);
static const i0.VerificationMeta _faceAssetIdMeta =
const i0.VerificationMeta('faceAssetId');
@override
late final i0.GeneratedColumn<String> faceAssetId =
i0.GeneratedColumn<String>('face_asset_id', aliasedName, true,
type: i0.DriftSqlType.string, requiredDuringInsert: false);
static const i0.VerificationMeta _thumbnailPathMeta =
const i0.VerificationMeta('thumbnailPath');
@override
late final i0.GeneratedColumn<String> thumbnailPath =
i0.GeneratedColumn<String>('thumbnail_path', aliasedName, false,
type: i0.DriftSqlType.string, requiredDuringInsert: true);
static const i0.VerificationMeta _isFavoriteMeta =
const i0.VerificationMeta('isFavorite');
@override
late final i0.GeneratedColumn<bool> isFavorite = i0.GeneratedColumn<bool>(
'is_favorite', aliasedName, false,
type: i0.DriftSqlType.bool,
requiredDuringInsert: true,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'CHECK ("is_favorite" IN (0, 1))'));
static const i0.VerificationMeta _isHiddenMeta =
const i0.VerificationMeta('isHidden');
@override
late final i0.GeneratedColumn<bool> isHidden = i0.GeneratedColumn<bool>(
'is_hidden', aliasedName, false,
type: i0.DriftSqlType.bool,
requiredDuringInsert: true,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'CHECK ("is_hidden" IN (0, 1))'));
static const i0.VerificationMeta _colorMeta =
const i0.VerificationMeta('color');
@override
late final i0.GeneratedColumn<String> color = i0.GeneratedColumn<String>(
'color', aliasedName, true,
type: i0.DriftSqlType.string, requiredDuringInsert: false);
static const i0.VerificationMeta _birthDateMeta =
const i0.VerificationMeta('birthDate');
@override
late final i0.GeneratedColumn<DateTime> birthDate =
i0.GeneratedColumn<DateTime>('birth_date', aliasedName, true,
type: i0.DriftSqlType.dateTime, requiredDuringInsert: false);
@override
List<i0.GeneratedColumn> get $columns => [
id,
createdAt,
updatedAt,
ownerId,
name,
faceAssetId,
thumbnailPath,
isFavorite,
isHidden,
color,
birthDate
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'person_entity';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.PersonEntityData> instance,
{bool isInserting = false}) {
final context = i0.VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
} else if (isInserting) {
context.missing(_idMeta);
}
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
}
if (data.containsKey('updated_at')) {
context.handle(_updatedAtMeta,
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta));
}
if (data.containsKey('owner_id')) {
context.handle(_ownerIdMeta,
ownerId.isAcceptableOrUnknown(data['owner_id']!, _ownerIdMeta));
} else if (isInserting) {
context.missing(_ownerIdMeta);
}
if (data.containsKey('name')) {
context.handle(
_nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta));
} else if (isInserting) {
context.missing(_nameMeta);
}
if (data.containsKey('face_asset_id')) {
context.handle(
_faceAssetIdMeta,
faceAssetId.isAcceptableOrUnknown(
data['face_asset_id']!, _faceAssetIdMeta));
}
if (data.containsKey('thumbnail_path')) {
context.handle(
_thumbnailPathMeta,
thumbnailPath.isAcceptableOrUnknown(
data['thumbnail_path']!, _thumbnailPathMeta));
} else if (isInserting) {
context.missing(_thumbnailPathMeta);
}
if (data.containsKey('is_favorite')) {
context.handle(
_isFavoriteMeta,
isFavorite.isAcceptableOrUnknown(
data['is_favorite']!, _isFavoriteMeta));
} else if (isInserting) {
context.missing(_isFavoriteMeta);
}
if (data.containsKey('is_hidden')) {
context.handle(_isHiddenMeta,
isHidden.isAcceptableOrUnknown(data['is_hidden']!, _isHiddenMeta));
} else if (isInserting) {
context.missing(_isHiddenMeta);
}
if (data.containsKey('color')) {
context.handle(
_colorMeta, color.isAcceptableOrUnknown(data['color']!, _colorMeta));
}
if (data.containsKey('birth_date')) {
context.handle(_birthDateMeta,
birthDate.isAcceptableOrUnknown(data['birth_date']!, _birthDateMeta));
}
return context;
}
@override
Set<i0.GeneratedColumn> get $primaryKey => {id};
@override
i1.PersonEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.PersonEntityData(
id: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}id'])!,
createdAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!,
updatedAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!,
ownerId: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}owner_id'])!,
name: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}name'])!,
faceAssetId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string, data['${effectivePrefix}face_asset_id']),
thumbnailPath: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string, data['${effectivePrefix}thumbnail_path'])!,
isFavorite: attachedDatabase.typeMapping
.read(i0.DriftSqlType.bool, data['${effectivePrefix}is_favorite'])!,
isHidden: attachedDatabase.typeMapping
.read(i0.DriftSqlType.bool, data['${effectivePrefix}is_hidden'])!,
color: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}color']),
birthDate: attachedDatabase.typeMapping
.read(i0.DriftSqlType.dateTime, data['${effectivePrefix}birth_date']),
);
}
@override
$PersonEntityTable createAlias(String alias) {
return $PersonEntityTable(attachedDatabase, alias);
}
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class PersonEntityData extends i0.DataClass
implements i0.Insertable<i1.PersonEntityData> {
final String id;
final DateTime createdAt;
final DateTime updatedAt;
final String ownerId;
final String name;
final String? faceAssetId;
final String thumbnailPath;
final bool isFavorite;
final bool isHidden;
final String? color;
final DateTime? birthDate;
const PersonEntityData(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.ownerId,
required this.name,
this.faceAssetId,
required this.thumbnailPath,
required this.isFavorite,
required this.isHidden,
this.color,
this.birthDate});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['id'] = i0.Variable<String>(id);
map['created_at'] = i0.Variable<DateTime>(createdAt);
map['updated_at'] = i0.Variable<DateTime>(updatedAt);
map['owner_id'] = i0.Variable<String>(ownerId);
map['name'] = i0.Variable<String>(name);
if (!nullToAbsent || faceAssetId != null) {
map['face_asset_id'] = i0.Variable<String>(faceAssetId);
}
map['thumbnail_path'] = i0.Variable<String>(thumbnailPath);
map['is_favorite'] = i0.Variable<bool>(isFavorite);
map['is_hidden'] = i0.Variable<bool>(isHidden);
if (!nullToAbsent || color != null) {
map['color'] = i0.Variable<String>(color);
}
if (!nullToAbsent || birthDate != null) {
map['birth_date'] = i0.Variable<DateTime>(birthDate);
}
return map;
}
factory PersonEntityData.fromJson(Map<String, dynamic> json,
{i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return PersonEntityData(
id: serializer.fromJson<String>(json['id']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
ownerId: serializer.fromJson<String>(json['ownerId']),
name: serializer.fromJson<String>(json['name']),
faceAssetId: serializer.fromJson<String?>(json['faceAssetId']),
thumbnailPath: serializer.fromJson<String>(json['thumbnailPath']),
isFavorite: serializer.fromJson<bool>(json['isFavorite']),
isHidden: serializer.fromJson<bool>(json['isHidden']),
color: serializer.fromJson<String?>(json['color']),
birthDate: serializer.fromJson<DateTime?>(json['birthDate']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'createdAt': serializer.toJson<DateTime>(createdAt),
'updatedAt': serializer.toJson<DateTime>(updatedAt),
'ownerId': serializer.toJson<String>(ownerId),
'name': serializer.toJson<String>(name),
'faceAssetId': serializer.toJson<String?>(faceAssetId),
'thumbnailPath': serializer.toJson<String>(thumbnailPath),
'isFavorite': serializer.toJson<bool>(isFavorite),
'isHidden': serializer.toJson<bool>(isHidden),
'color': serializer.toJson<String?>(color),
'birthDate': serializer.toJson<DateTime?>(birthDate),
};
}
i1.PersonEntityData copyWith(
{String? id,
DateTime? createdAt,
DateTime? updatedAt,
String? ownerId,
String? name,
i0.Value<String?> faceAssetId = const i0.Value.absent(),
String? thumbnailPath,
bool? isFavorite,
bool? isHidden,
i0.Value<String?> color = const i0.Value.absent(),
i0.Value<DateTime?> birthDate = const i0.Value.absent()}) =>
i1.PersonEntityData(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
ownerId: ownerId ?? this.ownerId,
name: name ?? this.name,
faceAssetId: faceAssetId.present ? faceAssetId.value : this.faceAssetId,
thumbnailPath: thumbnailPath ?? this.thumbnailPath,
isFavorite: isFavorite ?? this.isFavorite,
isHidden: isHidden ?? this.isHidden,
color: color.present ? color.value : this.color,
birthDate: birthDate.present ? birthDate.value : this.birthDate,
);
PersonEntityData copyWithCompanion(i1.PersonEntityCompanion data) {
return PersonEntityData(
id: data.id.present ? data.id.value : this.id,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId,
name: data.name.present ? data.name.value : this.name,
faceAssetId:
data.faceAssetId.present ? data.faceAssetId.value : this.faceAssetId,
thumbnailPath: data.thumbnailPath.present
? data.thumbnailPath.value
: this.thumbnailPath,
isFavorite:
data.isFavorite.present ? data.isFavorite.value : this.isFavorite,
isHidden: data.isHidden.present ? data.isHidden.value : this.isHidden,
color: data.color.present ? data.color.value : this.color,
birthDate: data.birthDate.present ? data.birthDate.value : this.birthDate,
);
}
@override
String toString() {
return (StringBuffer('PersonEntityData(')
..write('id: $id, ')
..write('createdAt: $createdAt, ')
..write('updatedAt: $updatedAt, ')
..write('ownerId: $ownerId, ')
..write('name: $name, ')
..write('faceAssetId: $faceAssetId, ')
..write('thumbnailPath: $thumbnailPath, ')
..write('isFavorite: $isFavorite, ')
..write('isHidden: $isHidden, ')
..write('color: $color, ')
..write('birthDate: $birthDate')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, createdAt, updatedAt, ownerId, name,
faceAssetId, thumbnailPath, isFavorite, isHidden, color, birthDate);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.PersonEntityData &&
other.id == this.id &&
other.createdAt == this.createdAt &&
other.updatedAt == this.updatedAt &&
other.ownerId == this.ownerId &&
other.name == this.name &&
other.faceAssetId == this.faceAssetId &&
other.thumbnailPath == this.thumbnailPath &&
other.isFavorite == this.isFavorite &&
other.isHidden == this.isHidden &&
other.color == this.color &&
other.birthDate == this.birthDate);
}
class PersonEntityCompanion extends i0.UpdateCompanion<i1.PersonEntityData> {
final i0.Value<String> id;
final i0.Value<DateTime> createdAt;
final i0.Value<DateTime> updatedAt;
final i0.Value<String> ownerId;
final i0.Value<String> name;
final i0.Value<String?> faceAssetId;
final i0.Value<String> thumbnailPath;
final i0.Value<bool> isFavorite;
final i0.Value<bool> isHidden;
final i0.Value<String?> color;
final i0.Value<DateTime?> birthDate;
const PersonEntityCompanion({
this.id = const i0.Value.absent(),
this.createdAt = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
this.ownerId = const i0.Value.absent(),
this.name = const i0.Value.absent(),
this.faceAssetId = const i0.Value.absent(),
this.thumbnailPath = const i0.Value.absent(),
this.isFavorite = const i0.Value.absent(),
this.isHidden = const i0.Value.absent(),
this.color = const i0.Value.absent(),
this.birthDate = const i0.Value.absent(),
});
PersonEntityCompanion.insert({
required String id,
this.createdAt = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
required String ownerId,
required String name,
this.faceAssetId = const i0.Value.absent(),
required String thumbnailPath,
required bool isFavorite,
required bool isHidden,
this.color = const i0.Value.absent(),
this.birthDate = const i0.Value.absent(),
}) : id = i0.Value(id),
ownerId = i0.Value(ownerId),
name = i0.Value(name),
thumbnailPath = i0.Value(thumbnailPath),
isFavorite = i0.Value(isFavorite),
isHidden = i0.Value(isHidden);
static i0.Insertable<i1.PersonEntityData> custom({
i0.Expression<String>? id,
i0.Expression<DateTime>? createdAt,
i0.Expression<DateTime>? updatedAt,
i0.Expression<String>? ownerId,
i0.Expression<String>? name,
i0.Expression<String>? faceAssetId,
i0.Expression<String>? thumbnailPath,
i0.Expression<bool>? isFavorite,
i0.Expression<bool>? isHidden,
i0.Expression<String>? color,
i0.Expression<DateTime>? birthDate,
}) {
return i0.RawValuesInsertable({
if (id != null) 'id': id,
if (createdAt != null) 'created_at': createdAt,
if (updatedAt != null) 'updated_at': updatedAt,
if (ownerId != null) 'owner_id': ownerId,
if (name != null) 'name': name,
if (faceAssetId != null) 'face_asset_id': faceAssetId,
if (thumbnailPath != null) 'thumbnail_path': thumbnailPath,
if (isFavorite != null) 'is_favorite': isFavorite,
if (isHidden != null) 'is_hidden': isHidden,
if (color != null) 'color': color,
if (birthDate != null) 'birth_date': birthDate,
});
}
i1.PersonEntityCompanion copyWith(
{i0.Value<String>? id,
i0.Value<DateTime>? createdAt,
i0.Value<DateTime>? updatedAt,
i0.Value<String>? ownerId,
i0.Value<String>? name,
i0.Value<String?>? faceAssetId,
i0.Value<String>? thumbnailPath,
i0.Value<bool>? isFavorite,
i0.Value<bool>? isHidden,
i0.Value<String?>? color,
i0.Value<DateTime?>? birthDate}) {
return i1.PersonEntityCompanion(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
ownerId: ownerId ?? this.ownerId,
name: name ?? this.name,
faceAssetId: faceAssetId ?? this.faceAssetId,
thumbnailPath: thumbnailPath ?? this.thumbnailPath,
isFavorite: isFavorite ?? this.isFavorite,
isHidden: isHidden ?? this.isHidden,
color: color ?? this.color,
birthDate: birthDate ?? this.birthDate,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (id.present) {
map['id'] = i0.Variable<String>(id.value);
}
if (createdAt.present) {
map['created_at'] = i0.Variable<DateTime>(createdAt.value);
}
if (updatedAt.present) {
map['updated_at'] = i0.Variable<DateTime>(updatedAt.value);
}
if (ownerId.present) {
map['owner_id'] = i0.Variable<String>(ownerId.value);
}
if (name.present) {
map['name'] = i0.Variable<String>(name.value);
}
if (faceAssetId.present) {
map['face_asset_id'] = i0.Variable<String>(faceAssetId.value);
}
if (thumbnailPath.present) {
map['thumbnail_path'] = i0.Variable<String>(thumbnailPath.value);
}
if (isFavorite.present) {
map['is_favorite'] = i0.Variable<bool>(isFavorite.value);
}
if (isHidden.present) {
map['is_hidden'] = i0.Variable<bool>(isHidden.value);
}
if (color.present) {
map['color'] = i0.Variable<String>(color.value);
}
if (birthDate.present) {
map['birth_date'] = i0.Variable<DateTime>(birthDate.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('PersonEntityCompanion(')
..write('id: $id, ')
..write('createdAt: $createdAt, ')
..write('updatedAt: $updatedAt, ')
..write('ownerId: $ownerId, ')
..write('name: $name, ')
..write('faceAssetId: $faceAssetId, ')
..write('thumbnailPath: $thumbnailPath, ')
..write('isFavorite: $isFavorite, ')
..write('isHidden: $isHidden, ')
..write('color: $color, ')
..write('birthDate: $birthDate')
..write(')'))
.toString();
}
}
@@ -34,6 +34,8 @@ class RemoteAssetEntity extends Table
IntColumn get visibility => intEnum<AssetVisibility>()();
TextColumn get stackId => text().nullable()();
@override
Set<Column> get primaryKey => {id};
}
@@ -55,5 +57,6 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
visibility: visibility,
livePhotoVideoId: livePhotoVideoId,
localId: null,
stackId: stackId,
);
}
@@ -29,6 +29,7 @@ typedef $$RemoteAssetEntityTableCreateCompanionBuilder
i0.Value<DateTime?> deletedAt,
i0.Value<String?> livePhotoVideoId,
required i2.AssetVisibility visibility,
i0.Value<String?> stackId,
});
typedef $$RemoteAssetEntityTableUpdateCompanionBuilder
= i1.RemoteAssetEntityCompanion Function({
@@ -48,6 +49,7 @@ typedef $$RemoteAssetEntityTableUpdateCompanionBuilder
i0.Value<DateTime?> deletedAt,
i0.Value<String?> livePhotoVideoId,
i0.Value<i2.AssetVisibility> visibility,
i0.Value<String?> stackId,
});
final class $$RemoteAssetEntityTableReferences extends i0.BaseReferences<
@@ -145,6 +147,9 @@ class $$RemoteAssetEntityTableFilterComposer
column: $table.visibility,
builder: (column) => i0.ColumnWithTypeConverterFilters(column));
i0.ColumnFilters<String> get stackId => $composableBuilder(
column: $table.stackId, builder: (column) => i0.ColumnFilters(column));
i5.$$UserEntityTableFilterComposer get ownerId {
final i5.$$UserEntityTableFilterComposer composer = $composerBuilder(
composer: this,
@@ -231,6 +236,9 @@ class $$RemoteAssetEntityTableOrderingComposer
column: $table.visibility,
builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<String> get stackId => $composableBuilder(
column: $table.stackId, builder: (column) => i0.ColumnOrderings(column));
i5.$$UserEntityTableOrderingComposer get ownerId {
final i5.$$UserEntityTableOrderingComposer composer = $composerBuilder(
composer: this,
@@ -309,6 +317,9 @@ class $$RemoteAssetEntityTableAnnotationComposer
$composableBuilder(
column: $table.visibility, builder: (column) => column);
i0.GeneratedColumn<String> get stackId =>
$composableBuilder(column: $table.stackId, builder: (column) => column);
i5.$$UserEntityTableAnnotationComposer get ownerId {
final i5.$$UserEntityTableAnnotationComposer composer = $composerBuilder(
composer: this,
@@ -373,6 +384,7 @@ class $$RemoteAssetEntityTableTableManager extends i0.RootTableManager<
i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
i0.Value<String?> livePhotoVideoId = const i0.Value.absent(),
i0.Value<i2.AssetVisibility> visibility = const i0.Value.absent(),
i0.Value<String?> stackId = const i0.Value.absent(),
}) =>
i1.RemoteAssetEntityCompanion(
name: name,
@@ -391,6 +403,7 @@ class $$RemoteAssetEntityTableTableManager extends i0.RootTableManager<
deletedAt: deletedAt,
livePhotoVideoId: livePhotoVideoId,
visibility: visibility,
stackId: stackId,
),
createCompanionCallback: ({
required String name,
@@ -409,6 +422,7 @@ class $$RemoteAssetEntityTableTableManager extends i0.RootTableManager<
i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
i0.Value<String?> livePhotoVideoId = const i0.Value.absent(),
required i2.AssetVisibility visibility,
i0.Value<String?> stackId = const i0.Value.absent(),
}) =>
i1.RemoteAssetEntityCompanion.insert(
name: name,
@@ -427,6 +441,7 @@ class $$RemoteAssetEntityTableTableManager extends i0.RootTableManager<
deletedAt: deletedAt,
livePhotoVideoId: livePhotoVideoId,
visibility: visibility,
stackId: stackId,
),
withReferenceMapper: (p0) => p0
.map((e) => (
@@ -602,6 +617,12 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
type: i0.DriftSqlType.int, requiredDuringInsert: true)
.withConverter<i2.AssetVisibility>(
i1.$RemoteAssetEntityTable.$convertervisibility);
static const i0.VerificationMeta _stackIdMeta =
const i0.VerificationMeta('stackId');
@override
late final i0.GeneratedColumn<String> stackId = i0.GeneratedColumn<String>(
'stack_id', aliasedName, true,
type: i0.DriftSqlType.string, requiredDuringInsert: false);
@override
List<i0.GeneratedColumn> get $columns => [
name,
@@ -619,7 +640,8 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
thumbHash,
deletedAt,
livePhotoVideoId,
visibility
visibility,
stackId
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -703,6 +725,10 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
livePhotoVideoId.isAcceptableOrUnknown(
data['live_photo_video_id']!, _livePhotoVideoIdMeta));
}
if (data.containsKey('stack_id')) {
context.handle(_stackIdMeta,
stackId.isAcceptableOrUnknown(data['stack_id']!, _stackIdMeta));
}
return context;
}
@@ -748,6 +774,8 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
visibility: i1.$RemoteAssetEntityTable.$convertervisibility.fromSql(
attachedDatabase.typeMapping.read(
i0.DriftSqlType.int, data['${effectivePrefix}visibility'])!),
stackId: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}stack_id']),
);
}
@@ -785,6 +813,7 @@ class RemoteAssetEntityData extends i0.DataClass
final DateTime? deletedAt;
final String? livePhotoVideoId;
final i2.AssetVisibility visibility;
final String? stackId;
const RemoteAssetEntityData(
{required this.name,
required this.type,
@@ -801,7 +830,8 @@ class RemoteAssetEntityData extends i0.DataClass
this.thumbHash,
this.deletedAt,
this.livePhotoVideoId,
required this.visibility});
required this.visibility,
this.stackId});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
@@ -841,6 +871,9 @@ class RemoteAssetEntityData extends i0.DataClass
map['visibility'] = i0.Variable<int>(
i1.$RemoteAssetEntityTable.$convertervisibility.toSql(visibility));
}
if (!nullToAbsent || stackId != null) {
map['stack_id'] = i0.Variable<String>(stackId);
}
return map;
}
@@ -866,6 +899,7 @@ class RemoteAssetEntityData extends i0.DataClass
livePhotoVideoId: serializer.fromJson<String?>(json['livePhotoVideoId']),
visibility: i1.$RemoteAssetEntityTable.$convertervisibility
.fromJson(serializer.fromJson<int>(json['visibility'])),
stackId: serializer.fromJson<String?>(json['stackId']),
);
}
@override
@@ -890,6 +924,7 @@ class RemoteAssetEntityData extends i0.DataClass
'livePhotoVideoId': serializer.toJson<String?>(livePhotoVideoId),
'visibility': serializer.toJson<int>(
i1.$RemoteAssetEntityTable.$convertervisibility.toJson(visibility)),
'stackId': serializer.toJson<String?>(stackId),
};
}
@@ -909,7 +944,8 @@ class RemoteAssetEntityData extends i0.DataClass
i0.Value<String?> thumbHash = const i0.Value.absent(),
i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
i0.Value<String?> livePhotoVideoId = const i0.Value.absent(),
i2.AssetVisibility? visibility}) =>
i2.AssetVisibility? visibility,
i0.Value<String?> stackId = const i0.Value.absent()}) =>
i1.RemoteAssetEntityData(
name: name ?? this.name,
type: type ?? this.type,
@@ -932,6 +968,7 @@ class RemoteAssetEntityData extends i0.DataClass
? livePhotoVideoId.value
: this.livePhotoVideoId,
visibility: visibility ?? this.visibility,
stackId: stackId.present ? stackId.value : this.stackId,
);
RemoteAssetEntityData copyWithCompanion(i1.RemoteAssetEntityCompanion data) {
return RemoteAssetEntityData(
@@ -959,6 +996,7 @@ class RemoteAssetEntityData extends i0.DataClass
: this.livePhotoVideoId,
visibility:
data.visibility.present ? data.visibility.value : this.visibility,
stackId: data.stackId.present ? data.stackId.value : this.stackId,
);
}
@@ -980,7 +1018,8 @@ class RemoteAssetEntityData extends i0.DataClass
..write('thumbHash: $thumbHash, ')
..write('deletedAt: $deletedAt, ')
..write('livePhotoVideoId: $livePhotoVideoId, ')
..write('visibility: $visibility')
..write('visibility: $visibility, ')
..write('stackId: $stackId')
..write(')'))
.toString();
}
@@ -1002,7 +1041,8 @@ class RemoteAssetEntityData extends i0.DataClass
thumbHash,
deletedAt,
livePhotoVideoId,
visibility);
visibility,
stackId);
@override
bool operator ==(Object other) =>
identical(this, other) ||
@@ -1022,7 +1062,8 @@ class RemoteAssetEntityData extends i0.DataClass
other.thumbHash == this.thumbHash &&
other.deletedAt == this.deletedAt &&
other.livePhotoVideoId == this.livePhotoVideoId &&
other.visibility == this.visibility);
other.visibility == this.visibility &&
other.stackId == this.stackId);
}
class RemoteAssetEntityCompanion
@@ -1043,6 +1084,7 @@ class RemoteAssetEntityCompanion
final i0.Value<DateTime?> deletedAt;
final i0.Value<String?> livePhotoVideoId;
final i0.Value<i2.AssetVisibility> visibility;
final i0.Value<String?> stackId;
const RemoteAssetEntityCompanion({
this.name = const i0.Value.absent(),
this.type = const i0.Value.absent(),
@@ -1060,6 +1102,7 @@ class RemoteAssetEntityCompanion
this.deletedAt = const i0.Value.absent(),
this.livePhotoVideoId = const i0.Value.absent(),
this.visibility = const i0.Value.absent(),
this.stackId = const i0.Value.absent(),
});
RemoteAssetEntityCompanion.insert({
required String name,
@@ -1078,6 +1121,7 @@ class RemoteAssetEntityCompanion
this.deletedAt = const i0.Value.absent(),
this.livePhotoVideoId = const i0.Value.absent(),
required i2.AssetVisibility visibility,
this.stackId = const i0.Value.absent(),
}) : name = i0.Value(name),
type = i0.Value(type),
id = i0.Value(id),
@@ -1101,6 +1145,7 @@ class RemoteAssetEntityCompanion
i0.Expression<DateTime>? deletedAt,
i0.Expression<String>? livePhotoVideoId,
i0.Expression<int>? visibility,
i0.Expression<String>? stackId,
}) {
return i0.RawValuesInsertable({
if (name != null) 'name': name,
@@ -1119,6 +1164,7 @@ class RemoteAssetEntityCompanion
if (deletedAt != null) 'deleted_at': deletedAt,
if (livePhotoVideoId != null) 'live_photo_video_id': livePhotoVideoId,
if (visibility != null) 'visibility': visibility,
if (stackId != null) 'stack_id': stackId,
});
}
@@ -1138,7 +1184,8 @@ class RemoteAssetEntityCompanion
i0.Value<String?>? thumbHash,
i0.Value<DateTime?>? deletedAt,
i0.Value<String?>? livePhotoVideoId,
i0.Value<i2.AssetVisibility>? visibility}) {
i0.Value<i2.AssetVisibility>? visibility,
i0.Value<String?>? stackId}) {
return i1.RemoteAssetEntityCompanion(
name: name ?? this.name,
type: type ?? this.type,
@@ -1156,6 +1203,7 @@ class RemoteAssetEntityCompanion
deletedAt: deletedAt ?? this.deletedAt,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
visibility: visibility ?? this.visibility,
stackId: stackId ?? this.stackId,
);
}
@@ -1213,6 +1261,9 @@ class RemoteAssetEntityCompanion
.$RemoteAssetEntityTable.$convertervisibility
.toSql(visibility.value));
}
if (stackId.present) {
map['stack_id'] = i0.Variable<String>(stackId.value);
}
return map;
}
@@ -1234,7 +1285,8 @@ class RemoteAssetEntityCompanion
..write('thumbHash: $thumbHash, ')
..write('deletedAt: $deletedAt, ')
..write('livePhotoVideoId: $livePhotoVideoId, ')
..write('visibility: $visibility')
..write('visibility: $visibility, ')
..write('stackId: $stackId')
..write(')'))
.toString();
}
@@ -1,5 +1,4 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@@ -15,7 +14,7 @@ class StackEntity extends Table with DriftDefaultsMixin {
TextColumn get ownerId =>
text().references(UserEntity, #id, onDelete: KeyAction.cascade)();
TextColumn get primaryAssetId => text().references(RemoteAssetEntity, #id)();
TextColumn get primaryAssetId => text()();
@override
Set<Column> get primaryKey => {id};
+16 -119
View File
@@ -8,8 +8,6 @@ import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3;
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'
as i4;
import 'package:drift/internal/modular.dart' as i5;
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
as i6;
typedef $$StackEntityTableCreateCompanionBuilder = i1.StackEntityCompanion
Function({
@@ -57,33 +55,6 @@ final class $$StackEntityTableReferences extends i0.BaseReferences<
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]));
}
static i6.$RemoteAssetEntityTable _primaryAssetIdTable(
i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i6.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(db)
.resultSet<i1.$StackEntityTable>('stack_entity')
.primaryAssetId,
i5.ReadDatabaseContainer(db)
.resultSet<i6.$RemoteAssetEntityTable>('remote_asset_entity')
.id));
i6.$$RemoteAssetEntityTableProcessedTableManager get primaryAssetId {
final $_column = $_itemColumn<String>('primary_asset_id')!;
final manager = i6
.$$RemoteAssetEntityTableTableManager(
$_db,
i5.ReadDatabaseContainer($_db)
.resultSet<i6.$RemoteAssetEntityTable>('remote_asset_entity'))
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_primaryAssetIdTable($_db));
if (item == null) return manager;
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]));
}
}
class $$StackEntityTableFilterComposer
@@ -104,6 +75,10 @@ class $$StackEntityTableFilterComposer
i0.ColumnFilters<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<String> get primaryAssetId => $composableBuilder(
column: $table.primaryAssetId,
builder: (column) => i0.ColumnFilters(column));
i4.$$UserEntityTableFilterComposer get ownerId {
final i4.$$UserEntityTableFilterComposer composer = $composerBuilder(
composer: this,
@@ -125,28 +100,6 @@ class $$StackEntityTableFilterComposer
));
return composer;
}
i6.$$RemoteAssetEntityTableFilterComposer get primaryAssetId {
final i6.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.primaryAssetId,
referencedTable: i5.ReadDatabaseContainer($db)
.resultSet<i6.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i6.$$RemoteAssetEntityTableFilterComposer(
$db: $db,
$table: i5.ReadDatabaseContainer($db)
.resultSet<i6.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$StackEntityTableOrderingComposer
@@ -169,6 +122,10 @@ class $$StackEntityTableOrderingComposer
column: $table.updatedAt,
builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<String> get primaryAssetId => $composableBuilder(
column: $table.primaryAssetId,
builder: (column) => i0.ColumnOrderings(column));
i4.$$UserEntityTableOrderingComposer get ownerId {
final i4.$$UserEntityTableOrderingComposer composer = $composerBuilder(
composer: this,
@@ -190,30 +147,6 @@ class $$StackEntityTableOrderingComposer
));
return composer;
}
i6.$$RemoteAssetEntityTableOrderingComposer get primaryAssetId {
final i6.$$RemoteAssetEntityTableOrderingComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.primaryAssetId,
referencedTable: i5.ReadDatabaseContainer($db)
.resultSet<i6.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i6.$$RemoteAssetEntityTableOrderingComposer(
$db: $db,
$table: i5.ReadDatabaseContainer($db)
.resultSet<i6.$RemoteAssetEntityTable>(
'remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$StackEntityTableAnnotationComposer
@@ -234,6 +167,9 @@ class $$StackEntityTableAnnotationComposer
i0.GeneratedColumn<DateTime> get updatedAt =>
$composableBuilder(column: $table.updatedAt, builder: (column) => column);
i0.GeneratedColumn<String> get primaryAssetId => $composableBuilder(
column: $table.primaryAssetId, builder: (column) => column);
i4.$$UserEntityTableAnnotationComposer get ownerId {
final i4.$$UserEntityTableAnnotationComposer composer = $composerBuilder(
composer: this,
@@ -255,30 +191,6 @@ class $$StackEntityTableAnnotationComposer
));
return composer;
}
i6.$$RemoteAssetEntityTableAnnotationComposer get primaryAssetId {
final i6.$$RemoteAssetEntityTableAnnotationComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.primaryAssetId,
referencedTable: i5.ReadDatabaseContainer($db)
.resultSet<i6.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
i6.$$RemoteAssetEntityTableAnnotationComposer(
$db: $db,
$table: i5.ReadDatabaseContainer($db)
.resultSet<i6.$RemoteAssetEntityTable>(
'remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$StackEntityTableTableManager extends i0.RootTableManager<
@@ -292,7 +204,7 @@ class $$StackEntityTableTableManager extends i0.RootTableManager<
$$StackEntityTableUpdateCompanionBuilder,
(i1.StackEntityData, i1.$$StackEntityTableReferences),
i1.StackEntityData,
i0.PrefetchHooks Function({bool ownerId, bool primaryAssetId})> {
i0.PrefetchHooks Function({bool ownerId})> {
$$StackEntityTableTableManager(
i0.GeneratedDatabase db, i1.$StackEntityTable table)
: super(i0.TableManagerState(
@@ -338,7 +250,7 @@ class $$StackEntityTableTableManager extends i0.RootTableManager<
i1.$$StackEntityTableReferences(db, table, e)
))
.toList(),
prefetchHooksCallback: ({ownerId = false, primaryAssetId = false}) {
prefetchHooksCallback: ({ownerId = false}) {
return i0.PrefetchHooks(
db: db,
explicitlyWatchedTables: [],
@@ -365,17 +277,6 @@ class $$StackEntityTableTableManager extends i0.RootTableManager<
i1.$$StackEntityTableReferences._ownerIdTable(db).id,
) as T;
}
if (primaryAssetId) {
state = state.withJoin(
currentTable: table,
currentColumn: table.primaryAssetId,
referencedTable: i1.$$StackEntityTableReferences
._primaryAssetIdTable(db),
referencedColumn: i1.$$StackEntityTableReferences
._primaryAssetIdTable(db)
.id,
) as T;
}
return state;
},
@@ -398,7 +299,7 @@ typedef $$StackEntityTableProcessedTableManager = i0.ProcessedTableManager<
$$StackEntityTableUpdateCompanionBuilder,
(i1.StackEntityData, i1.$$StackEntityTableReferences),
i1.StackEntityData,
i0.PrefetchHooks Function({bool ownerId, bool primaryAssetId})>;
i0.PrefetchHooks Function({bool ownerId})>;
class $StackEntityTable extends i2.StackEntity
with i0.TableInfo<$StackEntityTable, i1.StackEntityData> {
@@ -440,12 +341,8 @@ class $StackEntityTable extends i2.StackEntity
const i0.VerificationMeta('primaryAssetId');
@override
late final i0.GeneratedColumn<String> primaryAssetId =
i0.GeneratedColumn<String>(
'primary_asset_id', aliasedName, false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'REFERENCES remote_asset_entity (id)'));
i0.GeneratedColumn<String>('primary_asset_id', aliasedName, false,
type: i0.DriftSqlType.string, requiredDuringInsert: true);
@override
List<i0.GeneratedColumn> get $columns =>
[id, createdAt, updatedAt, ownerId, primaryAssetId];
@@ -0,0 +1,157 @@
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import "package:immich_mobile/utils/database.utils.dart";
final backupRepositoryProvider = Provider<DriftBackupRepository>(
(ref) => DriftBackupRepository(ref.watch(driftProvider)),
);
class DriftBackupRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftBackupRepository(this._db) : super(_db);
_getExcludedSubquery() {
return _db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
..join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
])
..where(
_db.localAlbumEntity.backupSelection
.equalsValue(BackupSelection.excluded),
);
}
Future<int> getTotalCount() async {
final query = _db.localAlbumAssetEntity.selectOnly(distinct: true)
..addColumns([_db.localAlbumAssetEntity.assetId])
..join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
])
..where(
_db.localAlbumEntity.backupSelection
.equalsValue(BackupSelection.selected) &
_db.localAlbumAssetEntity.assetId
.isNotInQuery(_getExcludedSubquery()),
);
return query.get().then((rows) => rows.length);
}
Future<int> getRemainderCount() async {
final query = _db.localAlbumAssetEntity.selectOnly(distinct: true)
..addColumns([_db.localAlbumAssetEntity.assetId])
..join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
innerJoin(
_db.localAssetEntity,
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
useColumns: false,
),
leftOuterJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum
.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
])
..where(
_db.localAlbumEntity.backupSelection
.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.id.isNull() &
_db.localAlbumAssetEntity.assetId
.isNotInQuery(_getExcludedSubquery()),
);
return query.get().then((rows) => rows.length);
}
Future<int> getBackupCount() async {
final query = _db.localAlbumAssetEntity.selectOnly(distinct: true)
..addColumns(
[_db.localAlbumAssetEntity.assetId],
)
..join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
innerJoin(
_db.localAssetEntity,
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
useColumns: false,
),
innerJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum
.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
])
..where(
_db.localAlbumEntity.backupSelection
.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.id.isNotNull() &
_db.localAlbumAssetEntity.assetId
.isNotInQuery(_getExcludedSubquery()),
);
return query.get().then((rows) => rows.length);
}
Future<List<LocalAsset>> getCandidates() async {
final selectedAlbumIds = _db.localAlbumEntity.selectOnly(distinct: true)
..addColumns([_db.localAlbumEntity.id])
..where(
_db.localAlbumEntity.backupSelection
.equalsValue(BackupSelection.selected),
);
final query = _db.localAssetEntity.select()
..where(
(lae) =>
existsQuery(
_db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
..where(
_db.localAlbumAssetEntity.albumId
.isInQuery(selectedAlbumIds) &
_db.localAlbumAssetEntity.assetId.equalsExp(lae.id),
),
) &
notExistsQuery(
_db.remoteAssetEntity.selectOnly()
..addColumns([_db.remoteAssetEntity.checksum])
..where(
_db.remoteAssetEntity.checksum.equalsExp(lae.checksum) &
lae.checksum.isNotNull(),
),
) &
lae.id.isNotInQuery(_getExcludedSubquery()),
)
..orderBy(
[
(localAsset) => OrderingTerm.desc(localAsset.createdAt),
],
);
return query.map((localAsset) => localAsset.toDto()).get();
}
}
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
@@ -10,6 +11,7 @@ import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/memory.entity.dart';
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.dart';
@@ -17,6 +19,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
import 'package:isar/isar.dart';
import 'db.repository.drift.dart';
@@ -52,6 +55,7 @@ class IsarDatabaseRepository implements IDatabaseRepository {
MemoryEntity,
MemoryAssetEntity,
StackEntity,
PersonEntity,
],
include: {
'package:immich_mobile/infrastructure/entities/merged_asset.drift',
@@ -68,10 +72,40 @@ class Drift extends $Drift implements IDatabaseRepository {
);
@override
int get schemaVersion => 1;
int get schemaVersion => 3;
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async {
// Run migration steps without foreign keys and re-enable them later
await customStatement('PRAGMA foreign_keys = OFF');
await m.runMigrationSteps(
from: from,
to: to,
steps: migrationSteps(
from1To2: (m, v2) async {
for (final entity in v2.entities) {
await m.drop(entity);
await m.create(entity);
}
},
from2To3: (m, v3) async {
// Removed foreign key constraint on stack.primaryAssetId
await m.alterTable(TableMigration(v3.stackEntity));
},
),
);
if (kDebugMode) {
// Fail if the migration broke foreign keys
final wrongFKs =
await customSelect('PRAGMA foreign_key_check').get();
assert(wrongFKs.isEmpty, '${wrongFKs.map((e) => e.data)}');
}
await customStatement('PRAGMA foreign_keys = ON;');
},
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');
await customStatement('PRAGMA synchronous = NORMAL');
@@ -7,31 +7,33 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.
as i2;
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
as i3;
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'
as i4;
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'
as i5;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
as i6;
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
as i7;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'
as i8;
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'
as i9;
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'
as i10;
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'
as i11;
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'
as i12;
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart'
as i13;
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'
as i4;
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'
as i5;
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'
as i6;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
as i7;
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
as i8;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'
as i9;
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'
as i10;
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'
as i11;
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'
as i12;
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'
as i13;
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart'
as i14;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart'
as i15;
import 'package:drift/internal/modular.dart' as i16;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i16;
import 'package:drift/internal/modular.dart' as i17;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@@ -41,28 +43,29 @@ abstract class $Drift extends i0.GeneratedDatabase {
i2.$RemoteAssetEntityTable(this);
late final i3.$LocalAssetEntityTable localAssetEntity =
i3.$LocalAssetEntityTable(this);
late final i4.$UserMetadataEntityTable userMetadataEntity =
i4.$UserMetadataEntityTable(this);
late final i5.$PartnerEntityTable partnerEntity =
i5.$PartnerEntityTable(this);
late final i6.$LocalAlbumEntityTable localAlbumEntity =
i6.$LocalAlbumEntityTable(this);
late final i7.$LocalAlbumAssetEntityTable localAlbumAssetEntity =
i7.$LocalAlbumAssetEntityTable(this);
late final i8.$RemoteExifEntityTable remoteExifEntity =
i8.$RemoteExifEntityTable(this);
late final i9.$RemoteAlbumEntityTable remoteAlbumEntity =
i9.$RemoteAlbumEntityTable(this);
late final i10.$RemoteAlbumAssetEntityTable remoteAlbumAssetEntity =
i10.$RemoteAlbumAssetEntityTable(this);
late final i11.$RemoteAlbumUserEntityTable remoteAlbumUserEntity =
i11.$RemoteAlbumUserEntityTable(this);
late final i12.$MemoryEntityTable memoryEntity = i12.$MemoryEntityTable(this);
late final i13.$MemoryAssetEntityTable memoryAssetEntity =
i13.$MemoryAssetEntityTable(this);
late final i14.$StackEntityTable stackEntity = i14.$StackEntityTable(this);
i15.MergedAssetDrift get mergedAssetDrift => i16.ReadDatabaseContainer(this)
.accessor<i15.MergedAssetDrift>(i15.MergedAssetDrift.new);
late final i4.$StackEntityTable stackEntity = i4.$StackEntityTable(this);
late final i5.$UserMetadataEntityTable userMetadataEntity =
i5.$UserMetadataEntityTable(this);
late final i6.$PartnerEntityTable partnerEntity =
i6.$PartnerEntityTable(this);
late final i7.$LocalAlbumEntityTable localAlbumEntity =
i7.$LocalAlbumEntityTable(this);
late final i8.$LocalAlbumAssetEntityTable localAlbumAssetEntity =
i8.$LocalAlbumAssetEntityTable(this);
late final i9.$RemoteExifEntityTable remoteExifEntity =
i9.$RemoteExifEntityTable(this);
late final i10.$RemoteAlbumEntityTable remoteAlbumEntity =
i10.$RemoteAlbumEntityTable(this);
late final i11.$RemoteAlbumAssetEntityTable remoteAlbumAssetEntity =
i11.$RemoteAlbumAssetEntityTable(this);
late final i12.$RemoteAlbumUserEntityTable remoteAlbumUserEntity =
i12.$RemoteAlbumUserEntityTable(this);
late final i13.$MemoryEntityTable memoryEntity = i13.$MemoryEntityTable(this);
late final i14.$MemoryAssetEntityTable memoryAssetEntity =
i14.$MemoryAssetEntityTable(this);
late final i15.$PersonEntityTable personEntity = i15.$PersonEntityTable(this);
i16.MergedAssetDrift get mergedAssetDrift => i17.ReadDatabaseContainer(this)
.accessor<i16.MergedAssetDrift>(i16.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -71,6 +74,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
userEntity,
remoteAssetEntity,
localAssetEntity,
stackEntity,
i3.idxLocalAssetChecksum,
i2.uQRemoteAssetOwnerChecksum,
i2.idxRemoteAssetChecksum,
@@ -84,7 +88,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
remoteAlbumUserEntity,
memoryEntity,
memoryAssetEntity,
stackEntity
personEntity
];
@override
i0.StreamQueryUpdateRules get streamUpdateRules =>
@@ -97,6 +101,13 @@ abstract class $Drift extends i0.GeneratedDatabase {
i0.TableUpdate('remote_asset_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('user_entity',
limitUpdateKind: i0.UpdateKind.delete),
result: [
i0.TableUpdate('stack_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('user_entity',
limitUpdateKind: i0.UpdateKind.delete),
@@ -213,7 +224,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
on: i0.TableUpdateQuery.onTableName('user_entity',
limitUpdateKind: i0.UpdateKind.delete),
result: [
i0.TableUpdate('stack_entity', kind: i0.UpdateKind.delete),
i0.TableUpdate('person_entity', kind: i0.UpdateKind.delete),
],
),
],
@@ -232,27 +243,29 @@ class $DriftManager {
i2.$$RemoteAssetEntityTableTableManager(_db, _db.remoteAssetEntity);
i3.$$LocalAssetEntityTableTableManager get localAssetEntity =>
i3.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity);
i4.$$UserMetadataEntityTableTableManager get userMetadataEntity =>
i4.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
i5.$$PartnerEntityTableTableManager get partnerEntity =>
i5.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
i6.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
i6.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
i7.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i7
i4.$$StackEntityTableTableManager get stackEntity =>
i4.$$StackEntityTableTableManager(_db, _db.stackEntity);
i5.$$UserMetadataEntityTableTableManager get userMetadataEntity =>
i5.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
i6.$$PartnerEntityTableTableManager get partnerEntity =>
i6.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
i7.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
i7.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
i8.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i8
.$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity);
i8.$$RemoteExifEntityTableTableManager get remoteExifEntity =>
i8.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity);
i9.$$RemoteAlbumEntityTableTableManager get remoteAlbumEntity =>
i9.$$RemoteAlbumEntityTableTableManager(_db, _db.remoteAlbumEntity);
i10.$$RemoteAlbumAssetEntityTableTableManager get remoteAlbumAssetEntity =>
i10.$$RemoteAlbumAssetEntityTableTableManager(
i9.$$RemoteExifEntityTableTableManager get remoteExifEntity =>
i9.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity);
i10.$$RemoteAlbumEntityTableTableManager get remoteAlbumEntity =>
i10.$$RemoteAlbumEntityTableTableManager(_db, _db.remoteAlbumEntity);
i11.$$RemoteAlbumAssetEntityTableTableManager get remoteAlbumAssetEntity =>
i11.$$RemoteAlbumAssetEntityTableTableManager(
_db, _db.remoteAlbumAssetEntity);
i11.$$RemoteAlbumUserEntityTableTableManager get remoteAlbumUserEntity => i11
i12.$$RemoteAlbumUserEntityTableTableManager get remoteAlbumUserEntity => i12
.$$RemoteAlbumUserEntityTableTableManager(_db, _db.remoteAlbumUserEntity);
i12.$$MemoryEntityTableTableManager get memoryEntity =>
i12.$$MemoryEntityTableTableManager(_db, _db.memoryEntity);
i13.$$MemoryAssetEntityTableTableManager get memoryAssetEntity =>
i13.$$MemoryAssetEntityTableTableManager(_db, _db.memoryAssetEntity);
i14.$$StackEntityTableTableManager get stackEntity =>
i14.$$StackEntityTableTableManager(_db, _db.stackEntity);
i13.$$MemoryEntityTableTableManager get memoryEntity =>
i13.$$MemoryEntityTableTableManager(_db, _db.memoryEntity);
i14.$$MemoryAssetEntityTableTableManager get memoryAssetEntity =>
i14.$$MemoryAssetEntityTableTableManager(_db, _db.memoryAssetEntity);
i15.$$PersonEntityTableTableManager get personEntity =>
i15.$$PersonEntityTableTableManager(_db, _db.personEntity);
}
File diff suppressed because it is too large Load Diff
@@ -5,9 +5,16 @@ import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.d
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/database.utils.dart';
import 'package:platform/platform.dart';
enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum }
enum SortLocalAlbumsBy {
id,
backupSelection,
isIosSharedAlbum,
name,
assetCount
}
class DriftLocalAlbumRepository extends DriftDatabaseRepository {
final Drift _db;
@@ -40,6 +47,9 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
OrderingTerm.asc(_db.localAlbumEntity.backupSelection),
SortLocalAlbumsBy.isIosSharedAlbum =>
OrderingTerm.asc(_db.localAlbumEntity.isIosSharedAlbum),
SortLocalAlbumsBy.name =>
OrderingTerm.asc(_db.localAlbumEntity.name),
SortLocalAlbumsBy.assetCount => OrderingTerm.desc(assetCount),
},
);
}
@@ -150,7 +160,15 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
batch.insert(
_db.localAlbumEntity,
companion,
onConflict: DoUpdate((_) => companion),
onConflict: DoUpdate(
(old) => LocalAlbumEntityCompanion(
id: companion.id,
name: companion.name,
updatedAt: companion.updatedAt,
isIosSharedAlbum: companion.isIosSharedAlbum,
marker_: companion.marker_,
),
),
);
}
});
@@ -381,30 +399,3 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return results.isNotEmpty ? results.first : null;
}
}
extension on LocalAlbumEntityData {
LocalAlbum toDto({int assetCount = 0}) {
return LocalAlbum(
id: id,
name: name,
updatedAt: updatedAt,
assetCount: assetCount,
backupSelection: backupSelection,
);
}
}
extension on LocalAssetEntityData {
LocalAsset toDto() {
return LocalAsset(
id: id,
name: name,
checksum: checksum,
type: type,
createdAt: createdAt,
updatedAt: updatedAt,
durationInSeconds: durationInSeconds,
isFavorite: isFavorite,
);
}
}
@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
@@ -43,4 +44,23 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
}
});
}
Future<void> delete(List<String> ids) {
if (ids.isEmpty) {
return Future.value();
}
return _db.batch((batch) {
for (final slice in ids.slices(32000)) {
batch.deleteWhere(_db.localAssetEntity, (e) => e.id.isIn(slice));
}
});
}
Future<LocalAsset?> getById(String id) {
final query = _db.localAssetEntity.select()
..where((lae) => lae.id.equals(id));
return query.map((row) => row.toDto()).getSingleOrNull();
}
}
@@ -0,0 +1,164 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class DriftPartnerRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftPartnerRepository(this._db) : super(_db);
Future<List<PartnerUserDto>> getPartners(String userId) {
final query = _db.select(_db.partnerEntity).join([
innerJoin(
_db.userEntity,
_db.userEntity.id.equalsExp(_db.partnerEntity.sharedById),
),
])
..where(
_db.partnerEntity.sharedWithId.equals(userId),
);
return query.map((row) {
final user = row.readTable(_db.userEntity);
final partner = row.readTable(_db.partnerEntity);
return PartnerUserDto(
id: user.id,
email: user.email,
name: user.name,
inTimeline: partner.inTimeline,
);
}).get();
}
// Get users who we can share our library with
Future<List<PartnerUserDto>> getAvailablePartners(String currentUserId) {
final query = _db.select(_db.userEntity)
..where((row) => row.id.equals(currentUserId).not());
return query.map((user) {
return PartnerUserDto(
id: user.id,
email: user.email,
name: user.name,
inTimeline: false,
);
}).get();
}
// Get users who are sharing their photos WITH the current user
Future<List<PartnerUserDto>> getSharedWith(String partnerId) {
final query = _db.select(_db.partnerEntity).join([
innerJoin(
_db.userEntity,
_db.userEntity.id.equalsExp(_db.partnerEntity.sharedById),
),
])
..where(
_db.partnerEntity.sharedWithId.equals(partnerId),
);
return query.map((row) {
final user = row.readTable(_db.userEntity);
final partner = row.readTable(_db.partnerEntity);
return PartnerUserDto(
id: user.id,
email: user.email,
name: user.name,
inTimeline: partner.inTimeline,
);
}).get();
}
// Get users who the current user is sharing their photos TO
Future<List<PartnerUserDto>> getSharedBy(String userId) {
final query = _db.select(_db.partnerEntity).join([
innerJoin(
_db.userEntity,
_db.userEntity.id.equalsExp(_db.partnerEntity.sharedWithId),
),
])
..where(
_db.partnerEntity.sharedById.equals(userId),
);
return query.map((row) {
final user = row.readTable(_db.userEntity);
final partner = row.readTable(_db.partnerEntity);
return PartnerUserDto(
id: user.id,
email: user.email,
name: user.name,
inTimeline: partner.inTimeline,
);
}).get();
}
Future<List<String>> getAllPartnerIds(String userId) async {
// Get users who are sharing with me (sharedWithId = userId)
final sharingWithMeQuery = _db.select(_db.partnerEntity)
..where((tbl) => tbl.sharedWithId.equals(userId));
final sharingWithMe =
await sharingWithMeQuery.map((row) => row.sharedById).get();
// Get users who I am sharing with (sharedById = userId)
final sharingWithThemQuery = _db.select(_db.partnerEntity)
..where((tbl) => tbl.sharedById.equals(userId));
final sharingWithThem =
await sharingWithThemQuery.map((row) => row.sharedWithId).get();
// Combine both lists and remove duplicates
final allPartnerIds =
<String>{...sharingWithMe, ...sharingWithThem}.toList();
return allPartnerIds;
}
Future<PartnerUserDto?> getPartner(String partnerId, String userId) {
final query = _db.select(_db.partnerEntity).join([
innerJoin(
_db.userEntity,
_db.userEntity.id.equalsExp(_db.partnerEntity.sharedById),
),
])
..where(
_db.partnerEntity.sharedById.equals(partnerId) &
_db.partnerEntity.sharedWithId.equals(userId),
);
return query.map((row) {
final user = row.readTable(_db.userEntity);
final partner = row.readTable(_db.partnerEntity);
return PartnerUserDto(
id: user.id,
email: user.email,
name: user.name,
inTimeline: partner.inTimeline,
);
}).getSingleOrNull();
}
Future<bool> toggleShowInTimeline(PartnerUserDto partner, String userId) {
return _db.partnerEntity.update().replace(
PartnerEntityCompanion(
sharedById: Value(partner.id),
sharedWithId: Value(userId),
inTimeline: Value(!partner.inTimeline),
),
);
}
Future<int> create(String partnerId, String userId) {
final entity = PartnerEntityCompanion(
sharedById: Value(userId),
sharedWithId: Value(partnerId),
inTimeline: const Value(false),
);
return _db.partnerEntity.insertOne(entity);
}
Future<void> delete(String partnerId, String userId) {
return _db.partnerEntity.deleteWhere(
(t) => t.sharedById.equals(userId) & t.sharedWithId.equals(partnerId),
);
}
}
@@ -0,0 +1,36 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class DriftPersonRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftPersonRepository(this._db) : super(_db);
Future<List<Person>> getAll(String userId) {
final query = _db.personEntity.select()
..where((e) => e.ownerId.equals(userId));
return query.map((person) {
return person.toDto();
}).get();
}
}
extension on PersonEntityData {
Person toDto() {
return Person(
id: id,
createdAt: createdAt,
updatedAt: updatedAt,
ownerId: ownerId,
name: name,
faceAssetId: faceAssetId,
thumbnailPath: thumbnailPath,
isFavorite: isFavorite,
isHidden: isHidden,
color: color,
birthDate: birthDate,
);
}
}
@@ -1,7 +1,13 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
enum SortRemoteAlbumsBy { id, updatedAt }
@@ -99,11 +105,169 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
});
}
Future<void> update(RemoteAlbum album) async {
await _db.remoteAlbumEntity.update().replace(
RemoteAlbumEntityCompanion(
id: Value(album.id),
name: Value(album.name),
ownerId: Value(album.ownerId),
createdAt: Value(album.createdAt),
updatedAt: Value(album.updatedAt),
description: Value(album.description),
thumbnailAssetId: Value(album.thumbnailAssetId),
isActivityEnabled: Value(album.isActivityEnabled),
order: Value(album.order),
),
);
}
Future<int> removeAssets(String albumId, List<String> assetIds) {
return _db.remoteAlbumAssetEntity.deleteWhere(
(tbl) => tbl.albumId.equals(albumId) & tbl.assetId.isIn(assetIds),
);
}
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..addColumns([
_db.remoteAssetEntity.createdAt.min(),
_db.remoteAssetEntity.createdAt.max(),
])
..join([
innerJoin(
_db.remoteAssetEntity,
_db.remoteAssetEntity.id
.equalsExp(_db.remoteAlbumAssetEntity.assetId),
),
]);
return query.map((row) {
final minDate = row.read(_db.remoteAssetEntity.createdAt.min());
final maxDate = row.read(_db.remoteAssetEntity.createdAt.max());
return (minDate ?? DateTime.now(), maxDate ?? DateTime.now());
}).getSingle();
}
Future<List<UserDto>> getSharedUsers(String albumId) async {
final albumUserRows = await (_db.select(_db.remoteAlbumUserEntity)
..where((row) => row.albumId.equals(albumId)))
.get();
if (albumUserRows.isEmpty) {
return [];
}
final userIds = albumUserRows.map((row) => row.userId);
return (_db.select(_db.userEntity)..where((row) => row.id.isIn(userIds)))
.map(
(user) => UserDto(
id: user.id,
email: user.email,
name: user.name,
profileImagePath: user.profileImagePath?.isEmpty == true
? null
: user.profileImagePath,
isAdmin: user.isAdmin,
updatedAt: user.updatedAt,
quotaSizeInBytes: user.quotaSizeInBytes ?? 0,
quotaUsageInBytes: user.quotaUsageInBytes,
memoryEnabled: true,
inTimeline: false,
isPartnerSharedBy: false,
isPartnerSharedWith: false,
),
)
.get();
}
Future<List<RemoteAsset>> getAssets(String albumId) {
final query = _db.remoteAlbumAssetEntity.select().join([
innerJoin(
_db.remoteAssetEntity,
_db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId),
),
])
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId));
return query
.map((row) => row.readTable(_db.remoteAssetEntity).toDto())
.get();
}
Future<int> addAssets(String albumId, List<String> assetIds) async {
final albumAssets = assetIds.map(
(assetId) => RemoteAlbumAssetEntityCompanion(
albumId: Value(albumId),
assetId: Value(assetId),
),
);
await _db.batch((batch) {
batch.insertAll(
_db.remoteAlbumAssetEntity,
albumAssets,
);
});
return assetIds.length;
}
Future<void> addUsers(String albumId, List<String> userIds) {
final albumUsers = userIds.map(
(assetId) => RemoteAlbumUserEntityCompanion(
albumId: Value(albumId),
userId: Value(assetId),
role: const Value(AlbumUserRole.editor),
),
);
return _db.batch((batch) {
batch.insertAll(
_db.remoteAlbumUserEntity,
albumUsers,
);
});
}
Future<void> deleteAlbum(String albumId) async {
return _db.transaction(() async {
await _db.remoteAlbumEntity.deleteWhere(
(table) => table.id.equals(albumId),
);
});
}
Stream<RemoteAlbum?> watchAlbum(String albumId) {
final query = _db.remoteAlbumEntity.select().join([
leftOuterJoin(
_db.remoteAlbumAssetEntity,
_db.remoteAlbumAssetEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
useColumns: false,
),
leftOuterJoin(
_db.remoteAssetEntity,
_db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId),
useColumns: false,
),
leftOuterJoin(
_db.userEntity,
_db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId),
useColumns: false,
),
])
..where(_db.remoteAlbumEntity.id.equals(albumId))
..addColumns([_db.userEntity.name])
..groupBy([_db.remoteAlbumEntity.id]);
return query.map((row) {
final album = row.readTable(_db.remoteAlbumEntity).toDto(
ownerName: row.read(_db.userEntity.name)!,
);
return album;
}).watchSingleOrNull();
}
}
extension on RemoteAlbumEntityData {
@@ -1,11 +1,13 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'
hide ExifInfo;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@@ -30,25 +32,66 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
}
Stream<RemoteAsset?> watchAsset(String id) {
final query = _db.remoteAssetEntity
.select()
.addColumns([_db.localAssetEntity.id]).join([
final stackCountRef = _db.stackEntity.id.count();
final query = _db.remoteAssetEntity.select().addColumns([
_db.localAssetEntity.id,
_db.stackEntity.primaryAssetId,
stackCountRef,
]).join([
leftOuterJoin(
_db.localAssetEntity,
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
useColumns: false,
),
leftOuterJoin(
_db.stackEntity,
_db.stackEntity.primaryAssetId.equalsExp(_db.remoteAssetEntity.id),
useColumns: false,
),
leftOuterJoin(
_db.remoteAssetEntity.createAlias('stacked_assets'),
_db.stackEntity.id.equalsExp(
_db.remoteAssetEntity.createAlias('stacked_assets').stackId,
),
useColumns: false,
),
])
..where(_db.remoteAssetEntity.id.equals(id));
..where(_db.remoteAssetEntity.id.equals(id))
..groupBy([
_db.remoteAssetEntity.id,
_db.localAssetEntity.id,
_db.stackEntity.primaryAssetId,
]);
return query.map((row) {
final asset = row.readTable(_db.remoteAssetEntity).toDto();
final primaryAssetId = row.read(_db.stackEntity.primaryAssetId);
final stackCount =
primaryAssetId == id ? (row.read(stackCountRef) ?? 0) : 0;
return asset.copyWith(
localId: row.read(_db.localAssetEntity.id),
stackCount: stackCount,
);
}).watchSingleOrNull();
}
Future<List<RemoteAsset>> getStackChildren(RemoteAsset asset) {
if (asset.stackId == null) {
return Future.value([]);
}
final query = _db.remoteAssetEntity.select()
..where(
(row) =>
row.stackId.equals(asset.stackId!) & row.id.equals(asset.id).not(),
)
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]);
return query.map((row) => row.toDto()).get();
}
Future<ExifInfo?> getExif(String id) {
return _db.managers.remoteExifEntity
.filter((row) => row.assetId.id.equals(id))
@@ -146,4 +189,53 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
}
});
}
Future<void> stack(String userId, StackResponse stack) {
return _db.transaction(() async {
final stackIds = await _db.managers.stackEntity
.filter((row) => row.primaryAssetId.isIn(stack.assetIds))
.map((row) => row.id)
.get();
await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds));
await _db.batch((batch) {
final companion = StackEntityCompanion(
ownerId: Value(userId),
primaryAssetId: Value(stack.primaryAssetId),
);
batch.insert(
_db.stackEntity,
companion.copyWith(id: Value(stack.id)),
onConflict: DoUpdate((_) => companion),
);
for (final assetId in stack.assetIds) {
batch.update(
_db.remoteAssetEntity,
RemoteAssetEntityCompanion(
stackId: Value(stack.id),
),
where: (e) => e.id.equals(assetId),
);
}
});
});
}
Future<void> unStack(List<String> stackIds) {
return _db.transaction(() async {
await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds));
// TODO: delete this after adding foreign key on stackId
await _db.batch((batch) {
batch.update(
_db.remoteAssetEntity,
const RemoteAssetEntityCompanion(stackId: Value(null)),
where: (e) => e.stackId.isIn(stackIds),
);
});
});
}
}
@@ -0,0 +1,87 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'
hide AssetVisibility;
import 'package:immich_mobile/infrastructure/repositories/api.repository.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:openapi/api.dart';
class SearchApiRepository extends ApiRepository {
final SearchApi _api;
const SearchApiRepository(this._api);
Future<SearchResponseDto?> search(SearchFilter filter, int page) {
AssetTypeEnum? type;
if (filter.mediaType.index == AssetType.image.index) {
type = AssetTypeEnum.IMAGE;
} else if (filter.mediaType.index == AssetType.video.index) {
type = AssetTypeEnum.VIDEO;
}
if (filter.context != null && filter.context!.isNotEmpty) {
return _api.searchSmart(
SmartSearchDto(
query: filter.context!,
language: filter.language,
country: filter.location.country,
state: filter.location.state,
city: filter.location.city,
make: filter.camera.make,
model: filter.camera.model,
takenAfter: filter.date.takenAfter,
takenBefore: filter.date.takenBefore,
visibility: filter.display.isArchive
? AssetVisibility.archive
: AssetVisibility.timeline,
isFavorite: filter.display.isFavorite ? true : null,
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
personIds: filter.people.map((e) => e.id).toList(),
type: type,
page: page,
size: 1000,
),
);
}
return _api.searchAssets(
MetadataSearchDto(
originalFileName: filter.filename != null && filter.filename!.isNotEmpty
? filter.filename
: null,
country: filter.location.country,
description:
filter.description != null && filter.description!.isNotEmpty
? filter.description
: null,
state: filter.location.state,
city: filter.location.city,
make: filter.camera.make,
model: filter.camera.model,
takenAfter: filter.date.takenAfter,
takenBefore: filter.date.takenBefore,
visibility: filter.display.isArchive
? AssetVisibility.archive
: AssetVisibility.timeline,
isFavorite: filter.display.isFavorite ? true : null,
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
personIds: filter.people.map((e) => e.id).toList(),
type: type,
page: page,
size: 1000,
),
);
}
Future<List<String>?> getSearchSuggestions(
SearchSuggestionType type, {
String? country,
String? state,
String? make,
String? model,
}) =>
_api.getSearchSuggestions(
type,
country: country,
state: state,
make: make,
model: model,
);
}
@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
@@ -7,8 +8,9 @@ class StorageRepository {
const StorageRepository();
Future<File?> getFileForAsset(String assetId) async {
final log = Logger('StorageRepository');
File? file;
final log = Logger('StorageRepository');
try {
final entity = await AssetEntity.fromId(assetId);
file = await entity?.originFile;
@@ -20,4 +22,48 @@ class StorageRepository {
}
return file;
}
Future<File?> getMotionFileForAsset(LocalAsset asset) async {
File? file;
final log = Logger('StorageRepository');
try {
final entity = await AssetEntity.fromId(asset.id);
file = await entity?.originFileWithSubtype;
if (file == null) {
log.warning(
"Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
);
}
} catch (error, stackTrace) {
log.warning(
"Error getting motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
error,
stackTrace,
);
}
return file;
}
Future<AssetEntity?> getAssetEntityForAsset(LocalAsset asset) async {
final log = Logger('StorageRepository');
AssetEntity? entity;
try {
entity = await AssetEntity.fromId(asset.id);
if (entity == null) {
log.warning(
"Cannot get AssetEntity for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
);
}
} catch (error, stackTrace) {
log.warning(
"Error getting AssetEntity for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
error,
stackTrace,
);
}
return entity;
}
}
@@ -57,6 +57,7 @@ class SyncApiRepository {
SyncRequestType.stacksV1,
SyncRequestType.partnerStacksV1,
SyncRequestType.userMetadataV1,
SyncRequestType.peopleV1,
],
).toJson(),
);
@@ -173,6 +174,8 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.partnerStackDeleteV1: SyncStackDeleteV1.fromJson,
SyncEntityType.userMetadataV1: SyncUserMetadataV1.fromJson,
SyncEntityType.userMetadataDeleteV1: SyncUserMetadataDeleteV1.fromJson,
SyncEntityType.personV1: SyncPersonV1.fromJson,
SyncEntityType.personDeleteV1: SyncPersonDeleteV1.fromJson,
};
class _SyncAckV1 {
@@ -9,6 +9,7 @@ import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart';
@@ -137,6 +138,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
deletedAt: Value(asset.deletedAt),
visibility: Value(asset.visibility.toAssetVisibility()),
livePhotoVideoId: Value(asset.livePhotoVideoId),
stackId: Value(asset.stackId),
);
batch.insert(
@@ -510,6 +512,47 @@ class SyncStreamRepository extends DriftDatabaseRepository {
rethrow;
}
}
Future<void> updatePeopleV1(Iterable<SyncPersonV1> data) async {
try {
await _db.batch((batch) {
for (final person in data) {
final companion = PersonEntityCompanion(
createdAt: Value(person.createdAt),
updatedAt: Value(person.updatedAt),
ownerId: Value(person.ownerId),
name: Value(person.name),
faceAssetId: Value(person.faceAssetId),
isFavorite: Value(person.isFavorite),
isHidden: Value(person.isHidden),
color: Value(person.color),
birthDate: Value(person.birthDate),
);
batch.insert(
_db.personEntity,
companion.copyWith(id: Value(person.id)),
onConflict: DoUpdate((_) => companion),
);
}
});
} catch (error, stack) {
_logger.severe('Error: updatePeopleV1', error, stack);
rethrow;
}
}
Future<void> deletePeopleV1(
Iterable<SyncPersonDeleteV1> data,
) async {
try {
await _db.personEntity.deleteWhere(
(row) => row.id.isIn(data.map((e) => e.personId)),
);
} catch (error, stack) {
_logger.severe('Error: deletePeopleV1', error, stack);
}
}
}
extension on AssetTypeEnum {
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:drift/drift.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
@@ -88,6 +89,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
isFavorite: row.isFavorite,
durationInSeconds: row.durationInSeconds,
livePhotoVideoId: row.livePhotoVideoId,
stackId: row.stackId,
stackCount: row.stackCount,
)
: LocalAsset(
id: row.localId!,
@@ -164,15 +167,25 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
useColumns: false,
),
leftOuterJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum
.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
],
)
..addColumns([_db.remoteAssetEntity.id])
..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)])
..limit(count, offset: offset);
return query
.map((row) => row.readTable(_db.localAssetEntity).toDto())
.get();
return query.map((row) {
final asset = row.readTable(_db.localAssetEntity).toDto();
return asset.copyWith(
remoteId: row.read(_db.remoteAssetEntity.id),
);
}).get();
}
TimelineQuery remoteAlbum(String albumId, GroupAssetsBy groupBy) => (
@@ -195,41 +208,75 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return _db.remoteAlbumAssetEntity
.count(where: (row) => row.albumId.equals(albumId))
.map(_generateBuckets)
.watchSingle();
.watch()
.map((results) => results.isNotEmpty ? results.first : <Bucket>[])
.handleError((error) {
return [];
});
}
final assetCountExp = _db.remoteAssetEntity.id.count();
final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy);
return (_db.remoteAlbumEntity.select()
..where((row) => row.id.equals(albumId)))
.watch()
.switchMap((albums) {
if (albums.isEmpty) {
return Stream.value(<Bucket>[]);
}
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp])
..join([
innerJoin(
_db.remoteAlbumAssetEntity,
_db.remoteAlbumAssetEntity.assetId
.equalsExp(_db.remoteAssetEntity.id),
useColumns: false,
),
])
..where(
_db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAlbumAssetEntity.albumId.equals(albumId),
)
..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]);
final album = albums.first;
final isAscending = album.order == AlbumAssetOrder.asc;
final assetCountExp = _db.remoteAssetEntity.id.count();
final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy);
return query.map((row) {
final timeline = row.read(dateExp)!.dateFmt(groupBy);
final assetCount = row.read(assetCountExp)!;
return TimeBucket(date: timeline, assetCount: assetCount);
}).watch();
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp])
..join([
innerJoin(
_db.remoteAlbumAssetEntity,
_db.remoteAlbumAssetEntity.assetId
.equalsExp(_db.remoteAssetEntity.id),
useColumns: false,
),
])
..where(
_db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAlbumAssetEntity.albumId.equals(albumId),
)
..groupBy([dateExp]);
if (isAscending) {
query.orderBy([OrderingTerm.asc(dateExp)]);
} else {
query.orderBy([OrderingTerm.desc(dateExp)]);
}
return query.map((row) {
final timeline = row.read(dateExp)!.dateFmt(groupBy);
final assetCount = row.read(assetCountExp)!;
return TimeBucket(date: timeline, assetCount: assetCount);
}).watch();
}).handleError((error) {
// If there's an error (e.g., album was deleted), return empty buckets
return <Bucket>[];
});
}
Future<List<BaseAsset>> _getRemoteAlbumBucketAssets(
String albumId, {
required int offset,
required int count,
}) {
}) async {
final albumData = await (_db.remoteAlbumEntity.select()
..where((row) => row.id.equals(albumId)))
.getSingleOrNull();
// If album doesn't exist (was deleted), return empty list
if (albumData == null) {
return <BaseAsset>[];
}
final isAscending = albumData.order == AlbumAssetOrder.asc;
final query = _db.remoteAssetEntity.select().join(
[
innerJoin(
@@ -239,18 +286,30 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
useColumns: false,
),
],
)
..where(
)..where(
_db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAlbumAssetEntity.albumId.equals(albumId),
)
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
..limit(count, offset: offset);
);
if (isAscending) {
query.orderBy([OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]);
} else {
query.orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)]);
}
query.limit(count, offset: offset);
return query
.map((row) => row.readTable(_db.remoteAssetEntity).toDto())
.get();
}
TimelineQuery fromAssets(List<BaseAsset> assets) => (
bucketSource: () => Stream.value(_generateBuckets(assets.length)),
assetSource: (offset, count) =>
Future.value(assets.skip(offset).take(count).toList()),
);
TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) =>
_remoteQueryBuilder(
filter: (row) =>
+34 -1
View File
@@ -31,6 +31,7 @@ import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/cache/widgets_binding.dart';
import 'package:immich_mobile/utils/download.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/utils/licenses.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:logging/logging.dart';
@@ -92,12 +93,30 @@ Future<void> initApp() async {
initializeTimeZones();
// Initialize the file downloader
await FileDownloader().configure(
// maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3
globalConfig: (Config.holdingQueue, (6, 6, 3)),
);
await FileDownloader().trackTasksInGroup(
downloadGroupLivePhoto,
markDownloadedComplete: false,
);
await FileDownloader().trackTasks();
LicenseRegistry.addLicense(
() async* {
for (final license in nonPubLicenses.entries) {
yield LicenseEntryWithLineBreaks(
[license.key],
license.value,
);
}
},
);
}
class ImmichApp extends ConsumerStatefulWidget {
@@ -159,7 +178,21 @@ class ImmichAppState extends ConsumerState<ImmichApp>
}
void _configureFileDownloaderNotifications() {
FileDownloader().configureNotification(
FileDownloader().configureNotificationForGroup(
downloadGroupImage,
running: TaskNotification(
'downloading_media'.tr(),
'${'file_name'.tr()}: {filename}',
),
complete: TaskNotification(
'download_finished'.tr(),
'${'file_name'.tr()}: {filename}',
),
progressBar: true,
);
FileDownloader().configureNotificationForGroup(
downloadGroupVideo,
running: TaskNotification(
'downloading_media'.tr(),
'${'file_name'.tr()}: {filename}',
@@ -237,7 +237,7 @@ class SearchFilter {
String? filename;
String? description;
String? language;
Set<Person> people;
Set<PersonDto> people;
SearchLocationFilter location;
SearchCameraFilter camera;
SearchDateFilter date;
@@ -282,7 +282,7 @@ class SearchFilter {
String? filename,
String? description,
String? language,
Set<Person>? people,
Set<PersonDto>? people,
SearchLocationFilter? location,
SearchCameraFilter? camera,
SearchDateFilter? date,
@@ -17,7 +17,7 @@ enum UploadStatus {
notFound,
failed,
canceled,
waitingtoRetry,
waitingToRetry,
paused,
}
@@ -1,52 +1,40 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
// ignore: must_be_immutable
class AlbumControlButton extends ConsumerWidget {
void Function() onAddPhotosPressed;
void Function() onAddUsersPressed;
final void Function()? onAddPhotosPressed;
final void Function()? onAddUsersPressed;
AlbumControlButton({
const AlbumControlButton({
super.key,
required this.onAddPhotosPressed,
required this.onAddUsersPressed,
this.onAddPhotosPressed,
this.onAddUsersPressed,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userId = ref.watch(authProvider).userId;
final isOwner = ref.watch(
currentAlbumProvider.select((album) {
return album?.ownerId == userId;
}),
);
return Padding(
padding: const EdgeInsets.only(left: 16.0),
child: SizedBox(
height: 36,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
return SizedBox(
height: 36,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
if (onAddPhotosPressed != null)
AlbumActionFilledButton(
key: const ValueKey('add_photos_button'),
iconData: Icons.add_photo_alternate_outlined,
onPressed: onAddPhotosPressed,
labelText: "add_photos".tr(),
),
if (isOwner)
AlbumActionFilledButton(
key: const ValueKey('add_users_button'),
iconData: Icons.person_add_alt_rounded,
onPressed: onAddUsersPressed,
labelText: "album_viewer_page_share_add_users".tr(),
),
],
),
if (onAddUsersPressed != null)
AlbumActionFilledButton(
key: const ValueKey('add_users_button'),
iconData: Icons.person_add_alt_rounded,
onPressed: onAddUsersPressed,
labelText: "album_viewer_page_share_add_users".tr(),
),
],
),
);
}
+12 -4
View File
@@ -41,6 +41,11 @@ class AlbumViewer extends HookConsumerWidget {
final userId = ref.watch(authProvider).userId;
final isMultiselecting = ref.watch(multiselectProvider);
final isProcessing = useProcessingOverlay();
final isOwner = ref.watch(
currentAlbumProvider.select((album) {
return album?.ownerId == userId;
}),
);
Future<bool> onRemoveFromAlbumPressed(Iterable<Asset> assets) async {
final bool isSuccess =
@@ -138,10 +143,13 @@ class AlbumViewer extends HookConsumerWidget {
),
const AlbumSharedUserIcons(),
if (album.isRemote)
AlbumControlButton(
key: const ValueKey("albumControlButton"),
onAddPhotosPressed: onAddPhotosPressed,
onAddUsersPressed: onAddUsersPressed,
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: AlbumControlButton(
key: const ValueKey("albumControlButton"),
onAddPhotosPressed: onAddPhotosPressed,
onAddUsersPressed: isOwner ? onAddUsersPressed : null,
),
),
const SizedBox(height: 8),
],
@@ -0,0 +1,273 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
@RoutePage()
class DriftBackupPage extends ConsumerStatefulWidget {
const DriftBackupPage({super.key});
@override
ConsumerState<DriftBackupPage> createState() => _DriftBackupPageState();
}
class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
@override
void initState() {
super.initState();
ref.read(driftBackupProvider.notifier).getBackupStatus();
}
Future<void> startBackup() async {
await ref.read(driftBackupProvider.notifier).getBackupStatus();
await ref.read(driftBackupProvider.notifier).backup();
}
Future<void> stopBackup() async {
await ref.read(driftBackupProvider.notifier).cancel();
}
@override
Widget build(BuildContext context) {
final selectedAlbum = ref
.watch(backupAlbumProvider)
.where(
(album) => album.backupSelection == BackupSelection.selected,
)
.toList();
final uploadItems = ref.watch(
driftBackupProvider.select((state) => state.uploadItems),
);
return Scaffold(
appBar: AppBar(
elevation: 0,
title: Text(
"backup_controller_page_backup".t(),
),
leading: IconButton(
onPressed: () {
context.maybePop(true);
},
splashRadius: 24,
icon: const Icon(
Icons.arrow_back_ios_rounded,
),
),
),
body: Stack(
children: [
Padding(
padding: const EdgeInsets.only(
left: 16.0,
right: 16,
bottom: 32,
),
child: ListView(
children: [
const SizedBox(height: 8),
const _BackupAlbumSelectionCard(),
if (selectedAlbum.isNotEmpty) ...[
const _TotalCard(),
const _BackupCard(),
const _RemainderCard(),
const Divider(),
BackupToggleButton(
onStart: () async => await startBackup(),
onStop: () async => await stopBackup(),
),
if (uploadItems.isNotEmpty)
TextButton.icon(
icon: const Icon(Icons.info_outline_rounded),
onPressed: () => context.pushRoute(
const DriftUploadDetailRoute(),
),
label: Text("view_details".t(context: context)),
),
],
],
),
),
],
),
);
}
}
class _BackupAlbumSelectionCard extends ConsumerWidget {
const _BackupAlbumSelectionCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget buildSelectedAlbumName() {
String text = "backup_controller_page_backup_selected".tr();
final albums = ref
.watch(backupAlbumProvider)
.where(
(album) => album.backupSelection == BackupSelection.selected,
)
.toList();
if (albums.isNotEmpty) {
for (var album in albums) {
if (album.name == "Recent" || album.name == "Recents") {
text += "${album.name} (${'all'.tr()}), ";
} else {
text += "${album.name}, ";
}
}
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
text.trim().substring(0, text.length - 2),
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
),
);
} else {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
"backup_controller_page_none_selected".tr(),
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
),
);
}
}
Widget buildExcludedAlbumName() {
String text = "backup_controller_page_excluded".tr();
final albums = ref
.watch(backupAlbumProvider)
.where(
(album) => album.backupSelection == BackupSelection.excluded,
)
.toList();
if (albums.isNotEmpty) {
for (var album in albums) {
text += "${album.name}, ";
}
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
text.trim().substring(0, text.length - 2),
style: context.textTheme.labelLarge?.copyWith(
color: Colors.red[300],
),
),
);
} else {
return const SizedBox();
}
}
return Card(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(20)),
side: BorderSide(
color: context.colorScheme.outlineVariant,
width: 1,
),
),
elevation: 0,
borderOnForeground: false,
child: ListTile(
minVerticalPadding: 18,
title: Text(
"backup_controller_page_albums",
style: context.textTheme.titleMedium,
).tr(),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"backup_controller_page_to_backup",
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurfaceSecondary,
),
).tr(),
buildSelectedAlbumName(),
buildExcludedAlbumName(),
],
),
),
trailing: ElevatedButton(
onPressed: () async {
await context.pushRoute(const DriftBackupAlbumSelectionRoute());
ref.read(driftBackupProvider.notifier).getBackupStatus();
},
child: const Text(
"select",
style: TextStyle(
fontWeight: FontWeight.bold,
),
).tr(),
),
),
);
}
}
class _TotalCard extends ConsumerWidget {
const _TotalCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final totalCount =
ref.watch(driftBackupProvider.select((p) => p.totalCount));
return BackupInfoCard(
title: "total".tr(),
subtitle: "backup_controller_page_total_sub".tr(),
info: totalCount.toString(),
);
}
}
class _BackupCard extends ConsumerWidget {
const _BackupCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final backupCount =
ref.watch(driftBackupProvider.select((p) => p.backupCount));
return BackupInfoCard(
title: "backup_controller_page_backup".tr(),
subtitle: "backup_controller_page_backup_sub".tr(),
info: backupCount.toString(),
);
}
}
class _RemainderCard extends ConsumerWidget {
const _RemainderCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final remainderCount =
ref.watch(driftBackupProvider.select((p) => p.remainderCount));
return BackupInfoCard(
title: "backup_controller_page_remainder".tr(),
subtitle: "backup_controller_page_remainder_sub".tr(),
info: remainderCount.toString(),
);
}
}
@@ -0,0 +1,523 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
@RoutePage()
class DriftBackupAlbumSelectionPage extends ConsumerStatefulWidget {
const DriftBackupAlbumSelectionPage({super.key});
@override
ConsumerState<DriftBackupAlbumSelectionPage> createState() =>
_DriftBackupAlbumSelectionPageState();
}
class _DriftBackupAlbumSelectionPageState
extends ConsumerState<DriftBackupAlbumSelectionPage> {
String _searchQuery = '';
bool _isSearchMode = false;
late ValueNotifier<bool> _enableSyncUploadAlbum;
late TextEditingController _searchController;
late FocusNode _searchFocusNode;
@override
void initState() {
super.initState();
_enableSyncUploadAlbum = ValueNotifier<bool>(false);
_searchController = TextEditingController();
_searchFocusNode = FocusNode();
_enableSyncUploadAlbum.value = ref
.read(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.syncAlbums);
ref.read(backupAlbumProvider.notifier).getAll();
}
@override
void dispose() {
_enableSyncUploadAlbum.dispose();
_searchController.dispose();
_searchFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final albums = ref.watch(backupAlbumProvider);
final albumCount = albums.length;
// Filter albums based on search query
final filteredAlbums = albums.where((album) {
if (_searchQuery.isEmpty) return true;
return album.name.toLowerCase().contains(_searchQuery.toLowerCase());
}).toList();
final selectedBackupAlbums = albums
.where((album) => album.backupSelection == BackupSelection.selected)
.toList();
final excludedBackupAlbums = albums
.where((album) => album.backupSelection == BackupSelection.excluded)
.toList();
handleSyncAlbumToggle(bool isEnable) async {
if (isEnable) {
await ref.read(albumProvider.notifier).refreshRemoteAlbums();
for (final album in selectedBackupAlbums) {
await ref.read(albumProvider.notifier).createSyncAlbum(album.name);
}
}
}
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () => context.maybePop(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
title: _isSearchMode
? SearchField(
hintText: 'search_albums'.t(context: context),
autofocus: true,
controller: _searchController,
focusNode: _searchFocusNode,
onChanged: (value) =>
setState(() => _searchQuery = value.trim()),
)
: const Text(
"backup_album_selection_page_select_albums",
).t(context: context),
actions: [
if (!_isSearchMode)
IconButton(
icon: const Icon(Icons.search),
onPressed: () => setState(() {
_isSearchMode = true;
_searchQuery = '';
}),
)
else
IconButton(
icon: const Icon(Icons.close),
onPressed: () => setState(() {
_isSearchMode = false;
_searchQuery = '';
_searchController.clear();
}),
),
],
elevation: 0,
),
body: CustomScrollView(
physics: const ClampingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 16.0,
),
child: Text(
"backup_album_selection_page_selection_info",
style: context.textTheme.titleSmall,
).t(context: context),
),
// Selected Album Chips
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap(
children: [
_SelectedAlbumNameChips(
selectedBackupAlbums: selectedBackupAlbums,
),
_ExcludedAlbumNameChips(
excludedBackupAlbums: excludedBackupAlbums,
),
],
),
),
SettingsSwitchListTile(
valueNotifier: _enableSyncUploadAlbum,
title: "sync_albums".t(context: context),
subtitle:
"sync_upload_album_setting_subtitle".t(context: context),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
titleStyle: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
subtitleStyle: context.textTheme.labelLarge?.copyWith(
color: context.colorScheme.primary,
),
onChanged: handleSyncAlbumToggle,
),
ListTile(
title: Text(
"albums_on_device_count".t(
context: context,
args: {'count': albumCount.toString()},
),
style: context.textTheme.titleSmall,
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
"backup_album_selection_page_albums_tap",
style: context.textTheme.labelLarge?.copyWith(
color: context.primaryColor,
),
).t(context: context),
),
trailing: IconButton(
splashRadius: 16,
icon: Icon(
Icons.info,
size: 20,
color: context.primaryColor,
),
onPressed: () {
// show the dialog
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: const RoundedRectangleBorder(
borderRadius:
BorderRadius.all(Radius.circular(10)),
),
elevation: 5,
title: Text(
'backup_album_selection_page_selection_info',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: context.primaryColor,
),
).t(context: context),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text(
'backup_album_selection_page_assets_scatter',
style: TextStyle(
fontSize: 14,
),
).t(context: context),
],
),
),
);
},
);
},
),
),
if (Platform.isAndroid)
_SelectAllButton(
filteredAlbums: filteredAlbums,
selectedBackupAlbums: selectedBackupAlbums,
),
],
),
),
SliverLayoutBuilder(
builder: (context, constraints) {
if (constraints.crossAxisExtent > 600) {
return _AlbumSelectionGrid(
filteredAlbums: filteredAlbums,
searchQuery: _searchQuery,
);
} else {
return _AlbumSelectionList(
filteredAlbums: filteredAlbums,
searchQuery: _searchQuery,
);
}
},
),
],
),
);
}
}
class _AlbumSelectionList extends StatelessWidget {
final List<LocalAlbum> filteredAlbums;
final String searchQuery;
const _AlbumSelectionList({
required this.filteredAlbums,
required this.searchQuery,
});
@override
Widget build(BuildContext context) {
if (filteredAlbums.isEmpty && searchQuery.isNotEmpty) {
return SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text('album_search_not_found'.t(context: context)),
),
),
);
}
if (filteredAlbums.isEmpty) {
return const SliverToBoxAdapter(
child: Center(
child: CircularProgressIndicator(),
),
);
}
return SliverPadding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
((context, index) {
return DriftAlbumInfoListTile(
album: filteredAlbums[index],
);
}),
childCount: filteredAlbums.length,
),
),
);
}
}
class _AlbumSelectionGrid extends StatelessWidget {
final List<LocalAlbum> filteredAlbums;
final String searchQuery;
const _AlbumSelectionGrid({
required this.filteredAlbums,
required this.searchQuery,
});
@override
Widget build(BuildContext context) {
if (filteredAlbums.isEmpty && searchQuery.isNotEmpty) {
return SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text('album_search_not_found'.t(context: context)),
),
),
);
}
if (filteredAlbums.isEmpty) {
return const SliverToBoxAdapter(
child: Center(
child: CircularProgressIndicator(),
),
);
}
return SliverPadding(
padding: const EdgeInsets.all(12.0),
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 300,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
),
itemCount: filteredAlbums.length,
itemBuilder: ((context, index) {
return DriftAlbumInfoListTile(
album: filteredAlbums[index],
);
}),
),
);
}
}
class _SelectedAlbumNameChips extends ConsumerWidget {
final List<LocalAlbum> selectedBackupAlbums;
const _SelectedAlbumNameChips({
required this.selectedBackupAlbums,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Wrap(
children: selectedBackupAlbums.asMap().entries.map((entry) {
final album = entry.value;
void removeSelection() {
ref.read(backupAlbumProvider.notifier).deselectAlbum(album);
}
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: GestureDetector(
onTap: removeSelection,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
child: Chip(
label: Text(
album.name,
style: TextStyle(
fontSize: 12,
color: context.isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
),
backgroundColor: context.primaryColor,
deleteIconColor:
context.isDarkTheme ? Colors.black : Colors.white,
deleteIcon: const Icon(
Icons.cancel_rounded,
size: 15,
),
onDeleted: removeSelection,
),
),
),
);
}).toList(),
);
}
}
class _ExcludedAlbumNameChips extends ConsumerWidget {
final List<LocalAlbum> excludedBackupAlbums;
const _ExcludedAlbumNameChips({
required this.excludedBackupAlbums,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Wrap(
children: excludedBackupAlbums.asMap().entries.map((entry) {
final album = entry.value;
void removeSelection() {
ref.read(backupAlbumProvider.notifier).deselectAlbum(album);
}
return GestureDetector(
onTap: removeSelection,
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
child: Chip(
label: Text(
album.name,
style: TextStyle(
fontSize: 12,
color: context.scaffoldBackgroundColor,
fontWeight: FontWeight.bold,
),
),
backgroundColor: Colors.red[300],
deleteIconColor: context.scaffoldBackgroundColor,
deleteIcon: const Icon(
Icons.cancel_rounded,
size: 15,
),
onDeleted: removeSelection,
),
),
),
);
}).toList(),
);
}
}
class _SelectAllButton extends ConsumerWidget {
final List<LocalAlbum> filteredAlbums;
final List<LocalAlbum> selectedBackupAlbums;
const _SelectAllButton({
required this.filteredAlbums,
required this.selectedBackupAlbums,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final canSelectAll = filteredAlbums
.where((album) => album.backupSelection != BackupSelection.selected)
.isNotEmpty;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: canSelectAll
? () {
for (final album in filteredAlbums) {
if (album.backupSelection != BackupSelection.selected) {
ref
.read(backupAlbumProvider.notifier)
.selectAlbum(album);
}
}
}
: null,
icon: const Icon(Icons.select_all),
label: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text(
"select_all".t(context: context),
),
),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12.0),
),
),
),
const SizedBox(width: 8.0),
Expanded(
child: OutlinedButton.icon(
onPressed: selectedBackupAlbums.isNotEmpty
? () {
for (final album in filteredAlbums) {
if (album.backupSelection == BackupSelection.selected) {
ref
.read(backupAlbumProvider.notifier)
.deselectAlbum(album);
}
}
}
: null,
icon: const Icon(Icons.deselect),
label: Text('deselect_all'.t(context: context)),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12.0),
),
),
),
],
),
);
}
}
@@ -0,0 +1,428 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:path/path.dart' as path;
@RoutePage()
class DriftUploadDetailPage extends ConsumerWidget {
const DriftUploadDetailPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final uploadItems = ref.watch(
driftBackupProvider.select((state) => state.uploadItems),
);
return Scaffold(
appBar: AppBar(
title: Text("upload_details".t(context: context)),
backgroundColor: context.colorScheme.surface,
elevation: 0,
scrolledUnderElevation: 1,
),
body: uploadItems.isEmpty
? _buildEmptyState(context)
: _buildUploadList(uploadItems),
);
}
Widget _buildEmptyState(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_upload_outlined,
size: 80,
color: context.colorScheme.onSurface.withValues(alpha: 0.3),
),
const SizedBox(height: 16),
Text(
"no_uploads_in_progress".t(context: context),
style: context.textTheme.titleMedium?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
);
}
Widget _buildUploadList(
Map<String, DriftUploadStatus> uploadItems,
) {
return ListView.separated(
addAutomaticKeepAlives: true,
padding: const EdgeInsets.all(16),
itemCount: uploadItems.length,
separatorBuilder: (context, index) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final item = uploadItems.values.elementAt(index);
return _buildUploadCard(context, item);
},
);
}
Widget _buildUploadCard(
BuildContext context,
DriftUploadStatus item,
) {
final isCompleted = item.progress >= 1.0;
final double progressPercentage = (item.progress * 100).clamp(0, 100);
return Card(
elevation: 0,
color: context.colorScheme.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(
Radius.circular(16),
),
side: BorderSide(
color: context.colorScheme.outline.withValues(alpha: 0.1),
width: 1,
),
),
child: InkWell(
onTap: () => _showFileDetailDialog(context, item),
borderRadius: const BorderRadius.all(
Radius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
path.basename(item.filename),
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'Tap for more details',
style: context.textTheme.bodySmall?.copyWith(
color: context.colorScheme.onSurface
.withValues(alpha: 0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
_buildProgressIndicator(
context,
item.progress,
progressPercentage,
isCompleted,
item.networkSpeedAsString,
),
],
),
],
),
),
),
);
}
Widget _buildProgressIndicator(
BuildContext context,
double progress,
double percentage,
bool isCompleted,
String networkSpeedAsString,
) {
return Column(
children: [
Stack(
alignment: AlignmentDirectional.center,
children: [
SizedBox(
width: 36,
height: 36,
child: TweenAnimationBuilder(
tween: Tween<double>(begin: 0.0, end: progress),
duration: const Duration(milliseconds: 300),
builder: (context, value, _) => CircularProgressIndicator(
backgroundColor:
context.colorScheme.outline.withValues(alpha: 0.2),
strokeWidth: 3,
value: value,
color: isCompleted
? context.colorScheme.primary
: context.colorScheme.secondary,
),
),
),
if (isCompleted)
Icon(
Icons.check_circle_rounded,
size: 28,
color: context.colorScheme.primary,
)
else
Text(
percentage.toStringAsFixed(0),
style: context.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 10,
),
),
],
),
Text(
networkSpeedAsString,
style: context.textTheme.labelSmall?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
fontSize: 10,
),
),
],
);
}
Future<void> _showFileDetailDialog(
BuildContext context,
DriftUploadStatus item,
) async {
showDialog(
context: context,
builder: (context) => FileDetailDialog(uploadStatus: item),
);
}
}
class FileDetailDialog extends ConsumerWidget {
final DriftUploadStatus uploadStatus;
const FileDetailDialog({
super.key,
required this.uploadStatus,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return AlertDialog(
insetPadding: const EdgeInsets.all(20),
backgroundColor: context.colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(
Radius.circular(16),
),
side: BorderSide(
color: context.colorScheme.outline.withValues(alpha: 0.2),
width: 1,
),
),
title: Row(
children: [
Icon(
Icons.info_outline,
color: context.primaryColor,
size: 24,
),
const SizedBox(width: 8),
Expanded(
child: Text(
"details".t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
),
),
],
),
content: SizedBox(
width: double.maxFinite,
child: FutureBuilder<LocalAsset?>(
future: _getAssetDetails(ref, uploadStatus.taskId),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const SizedBox(
height: 200,
child: Center(child: CircularProgressIndicator()),
);
}
final asset = snapshot.data;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Thumbnail at the top center
Center(
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Container(
width: 128,
height: 128,
decoration: BoxDecoration(
border: Border.all(
color: context.colorScheme.outline
.withValues(alpha: 0.2),
width: 1,
),
borderRadius:
const BorderRadius.all(Radius.circular(12)),
),
child: asset != null
? Thumbnail(
asset: asset,
size: const Size(512, 512),
fit: BoxFit.cover,
)
: null,
),
),
),
const SizedBox(height: 24),
if (asset != null) ...[
_buildInfoSection(context, [
_buildInfoRow(
context,
"Filename",
path.basename(uploadStatus.filename),
),
_buildInfoRow(
context,
"Local ID",
asset.id,
),
_buildInfoRow(
context,
"File Size",
formatHumanReadableBytes(uploadStatus.fileSize, 2),
),
if (asset.width != null)
_buildInfoRow(context, "Width", "${asset.width}px"),
if (asset.height != null)
_buildInfoRow(
context,
"Height",
"${asset.height}px",
),
_buildInfoRow(
context,
"Created At",
asset.createdAt.toString(),
),
_buildInfoRow(
context,
"Updated At",
asset.updatedAt.toString(),
),
if (asset.checksum != null)
_buildInfoRow(
context,
"Checksum",
asset.checksum!,
),
]),
],
],
),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
"close".t(),
style: TextStyle(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
),
),
],
);
}
Widget _buildInfoSection(
BuildContext context,
List<Widget> children,
) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
border: Border.all(
color: context.colorScheme.outline.withValues(alpha: 0.1),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...children,
],
),
);
}
Widget _buildInfoRow(BuildContext context, String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
"$label:",
style: context.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w500,
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
),
Expanded(
child: Text(
value,
style: context.textTheme.labelMedium?.copyWith(),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
Future<LocalAsset?> _getAssetDetails(
WidgetRef ref,
String localAssetId,
) async {
try {
final repository = ref.read(localAssetRepository);
return await repository.getById(localAssetId);
} catch (e) {
return null;
}
}
}
@@ -0,0 +1,142 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:permission_handler/permission_handler.dart';
@RoutePage()
class ChangeExperiencePage extends ConsumerStatefulWidget {
final bool switchingToBeta;
const ChangeExperiencePage({super.key, required this.switchingToBeta});
@override
ConsumerState createState() => _ChangeExperiencePageState();
}
class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
bool hasMigrated = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _handleMigration());
}
Future<void> _handleMigration() async {
if (widget.switchingToBeta) {
final assetNotifier = ref.read(assetProvider.notifier);
if (assetNotifier.mounted) {
assetNotifier.dispose();
}
final albumNotifier = ref.read(albumProvider.notifier);
if (albumNotifier.mounted) {
albumNotifier.dispose();
}
ref.read(websocketProvider.notifier).stopListenToOldEvents();
ref.read(websocketProvider.notifier).startListeningToBetaEvents();
final permission = await ref
.read(galleryPermissionNotifier.notifier)
.requestGalleryPermission();
if (permission.isGranted) {
await ref.read(backgroundSyncProvider).syncLocal(full: true);
await migrateDeviceAssetToSqlite(
ref.read(isarProvider),
ref.read(driftProvider),
);
await migrateBackupAlbumsToSqlite(
ref.read(isarProvider),
ref.read(driftProvider),
);
}
} else {
await ref.read(backgroundSyncProvider).cancel();
ref.read(websocketProvider.notifier).stopListeningToBetaEvents();
ref.read(websocketProvider.notifier).startListeningToOldEvents();
}
if (mounted) {
setState(() {
HapticFeedback.heavyImpact();
hasMigrated = true;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSwitcher(
duration: Durations.long4,
child: hasMigrated
? const Icon(
Icons.check_circle_rounded,
color: Colors.green,
size: 48.0,
)
: const SizedBox(
width: 50.0,
height: 50.0,
child: CircularProgressIndicator(),
),
),
const SizedBox(height: 16.0),
Center(
child: Column(
children: [
SizedBox(
width: 300.0,
child: AnimatedSwitcher(
duration: Durations.long4,
child: hasMigrated
? Text(
"Migration success!",
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
)
: Text(
"Data migration in progress...\nPlease wait and don't close this page",
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
),
),
),
if (hasMigrated)
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: ElevatedButton(
onPressed: () {
context.replaceRoute(
widget.switchingToBeta
? const TabShellRoute()
: const TabControllerRoute(),
);
},
child: const Text("Continue"),
),
),
],
),
),
],
),
),
);
}
}
@@ -125,7 +125,7 @@ class GalleryViewerPage extends HookConsumerWidget {
final asset = loadAsset(currentIndex.value);
if (asset.isRemote) {
ref.read(castProvider.notifier).loadMedia(asset, false);
ref.read(castProvider.notifier).loadMediaOld(asset, false);
} else {
if (isCasting) {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -394,7 +394,7 @@ class GalleryViewerPage extends HookConsumerWidget {
// send image to casting if the server has it
if (newAsset.isRemote) {
ref.read(castProvider.notifier).loadMedia(newAsset, false);
ref.read(castProvider.notifier).loadMediaOld(newAsset, false);
} else {
context.scaffoldMessenger.clearSnackBars();
+48 -43
View File
@@ -8,6 +8,7 @@ import 'package:immich_mobile/widgets/settings/advanced_settings.dart';
import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_settings.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart';
import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart';
import 'package:immich_mobile/widgets/settings/language_settings.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart';
import 'package:immich_mobile/widgets/settings/notification_setting.dart';
@@ -94,55 +95,59 @@ class _MobileLayout extends StatelessWidget {
const _MobileLayout();
@override
Widget build(BuildContext context) {
return ListView(
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.symmetric(vertical: 10.0),
children: SettingSection.values
.map(
(setting) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
final List<Widget> settings = SettingSection.values
.map(
(setting) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
child: Card(
elevation: 0,
clipBehavior: Clip.antiAlias,
color: context.colorScheme.surfaceContainer,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
child: Card(
elevation: 0,
clipBehavior: Clip.antiAlias,
color: context.colorScheme.surfaceContainer,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
margin: const EdgeInsets.symmetric(vertical: 4.0),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
margin: const EdgeInsets.symmetric(vertical: 4.0),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0,
leading: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(16)),
color: context.isDarkTheme
? Colors.black26
: Colors.white.withAlpha(100),
),
leading: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(16)),
color: context.isDarkTheme
? Colors.black26
: Colors.white.withAlpha(100),
),
padding: const EdgeInsets.all(16.0),
child: Icon(setting.icon, color: context.primaryColor),
),
title: Text(
setting.title,
style: context.textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
).tr(),
subtitle: Text(
setting.subtitle,
style: context.textTheme.labelLarge,
).tr(),
onTap: () =>
context.pushRoute(SettingsSubRoute(section: setting)),
padding: const EdgeInsets.all(16.0),
child: Icon(setting.icon, color: context.primaryColor),
),
title: Text(
setting.title,
style: context.textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
).tr(),
subtitle: Text(
setting.subtitle,
style: context.textTheme.labelLarge,
).tr(),
onTap: () =>
context.pushRoute(SettingsSubRoute(section: setting)),
),
),
)
.toList(),
),
)
.toList();
return ListView(
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(top: 10.0, bottom: 56),
children: [
const BetaTimelineListTile(),
...settings,
],
);
}
}
@@ -73,7 +73,15 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
}
if (context.router.current.name == SplashScreenRoute.name) {
context.replaceRoute(const TabControllerRoute());
context.replaceRoute(
Store.isBetaTimelineEnabled
? const TabShellRoute()
: const TabControllerRoute(),
);
}
if (Store.isBetaTimelineEnabled) {
return;
}
final hasPermission =
+42 -46
View File
@@ -3,45 +3,50 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/migration.dart';
@RoutePage()
class TabShellPage extends ConsumerWidget {
class TabShellPage extends ConsumerStatefulWidget {
const TabShellPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isScreenLandscape = context.orientation == Orientation.landscape;
ConsumerState<TabShellPage> createState() => _TabShellPageState();
}
Widget buildIcon({required Widget icon, required bool isProcessing}) {
if (!isProcessing) return icon;
return Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
icon,
Positioned(
right: -18,
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
context.primaryColor,
),
),
),
),
],
);
}
class _TabShellPageState extends ConsumerState<TabShellPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
ref.read(websocketProvider.notifier).connect();
final isEnableBackup = ref
.read(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.enableBackup);
await runNewSync(ref, full: true).then((_) async {
if (isEnableBackup) {
await ref.read(driftBackupProvider.notifier).handleBackupResume();
}
});
});
}
@override
Widget build(BuildContext context) {
final isScreenLandscape = context.orientation == Orientation.landscape;
final navigationDestinations = [
NavigationDestination(
@@ -49,12 +54,9 @@ class TabShellPage extends ConsumerWidget {
icon: const Icon(
Icons.photo_library_outlined,
),
selectedIcon: buildIcon(
isProcessing: false,
icon: Icon(
Icons.photo_library,
color: context.primaryColor,
),
selectedIcon: Icon(
Icons.photo_library,
color: context.primaryColor,
),
),
NavigationDestination(
@@ -72,12 +74,9 @@ class TabShellPage extends ConsumerWidget {
icon: const Icon(
Icons.photo_album_outlined,
),
selectedIcon: buildIcon(
isProcessing: false,
icon: Icon(
Icons.photo_album_rounded,
color: context.primaryColor,
),
selectedIcon: Icon(
Icons.photo_album_rounded,
color: context.primaryColor,
),
),
NavigationDestination(
@@ -85,12 +84,9 @@ class TabShellPage extends ConsumerWidget {
icon: const Icon(
Icons.space_dashboard_outlined,
),
selectedIcon: buildIcon(
isProcessing: false,
icon: Icon(
Icons.space_dashboard_rounded,
color: context.primaryColor,
),
selectedIcon: Icon(
Icons.space_dashboard_rounded,
color: context.primaryColor,
),
),
];
@@ -117,7 +113,7 @@ class TabShellPage extends ConsumerWidget {
return AutoTabsRouter(
routes: [
const MainTimelineRoute(),
SearchRoute(),
DriftSearchRoute(),
const DriftAlbumsRoute(),
const DriftLibraryRoute(),
],
@@ -167,7 +163,7 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
// Album page
if (index == 2) {
ref.read(remoteAlbumProvider.notifier).getAll();
ref.read(remoteAlbumProvider.notifier).refresh();
}
ref.read(hapticFeedbackProvider.notifier).selectionClick();
@@ -1,13 +1,14 @@
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:flutter_hooks/flutter_hooks.dart' show useState;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/local_auth.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/forms/pin_registration_form.dart';
import 'package:immich_mobile/widgets/forms/pin_verification_form.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@RoutePage()
class PinAuthPage extends HookConsumerWidget {
@@ -19,6 +20,7 @@ class PinAuthPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final localAuthState = ref.watch(localAuthProvider);
final showPinRegistrationForm = useState(createPinCode);
final isBetaTimeline = Store.isBetaTimelineEnabled;
Future<void> registerBiometric(String pinCode) async {
final isRegistered =
@@ -39,7 +41,11 @@ class PinAuthPage extends HookConsumerWidget {
),
);
context.replaceRoute(const LockedRoute());
if (isBetaTimeline) {
context.replaceRoute(const DriftLockedFolderRoute());
} else {
context.replaceRoute(const LockedRoute());
}
}
}
@@ -93,8 +99,14 @@ class PinAuthPage extends HookConsumerWidget {
Center(
child: PinVerificationForm(
autoFocus: true,
onSuccess: (_) =>
context.replaceRoute(const LockedRoute()),
onSuccess: (_) {
if (isBetaTimeline) {
context
.replaceRoute(const DriftLockedFolderRoute());
} else {
context.replaceRoute(const LockedRoute());
}
},
),
),
const SizedBox(height: 24),
@@ -0,0 +1,161 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/partner_user_avatar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/partner.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@RoutePage()
class DriftPartnerPage extends HookConsumerWidget {
const DriftPartnerPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final potentialPartnersAsync = ref.watch(driftAvailablePartnerProvider);
addNewUsersHandler() async {
final potentialPartners = potentialPartnersAsync.value;
if (potentialPartners == null || potentialPartners.isEmpty) {
ImmichToast.show(
context: context,
msg: "partner_page_no_more_users".tr(),
);
return;
}
final selectedUser = await showDialog<PartnerUserDto>(
context: context,
builder: (context) {
return SimpleDialog(
title: const Text("partner_page_select_partner").tr(),
children: [
for (PartnerUserDto partner in potentialPartners)
SimpleDialogOption(
onPressed: () => context.pop(partner),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: PartnerUserAvatar(partner: partner),
),
Text(partner.name),
],
),
),
],
);
},
);
if (selectedUser != null) {
await ref.read(partnerUsersProvider.notifier).addPartner(selectedUser);
}
}
onDeleteUser(PartnerUserDto partner) {
return showDialog(
context: context,
builder: (BuildContext context) {
return ConfirmDialog(
title: "stop_photo_sharing",
content: "partner_page_stop_sharing_content"
.tr(namedArgs: {'partner': partner.name}),
onOk: () =>
ref.read(partnerUsersProvider.notifier).removePartner(partner),
);
},
);
}
return Scaffold(
appBar: AppBar(
title: const Text("partners").t(context: context),
elevation: 0,
centerTitle: false,
actions: [
IconButton(
onPressed: potentialPartnersAsync.whenOrNull(
data: (data) => addNewUsersHandler,
),
icon: const Icon(Icons.person_add),
tooltip: "add_partner".tr(),
),
],
),
body: _SharedToPartnerList(
onAddPartner: addNewUsersHandler,
onDeletePartner: onDeleteUser,
),
);
}
}
class _SharedToPartnerList extends ConsumerWidget {
final VoidCallback onAddPartner;
final Function(PartnerUserDto partner) onDeletePartner;
const _SharedToPartnerList({
required this.onAddPartner,
required this.onDeletePartner,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final partnerAsync = ref.watch(driftSharedByPartnerProvider);
return partnerAsync.when(
data: (partners) {
if (partners.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: const Text(
"partner_page_empty_message",
style: TextStyle(fontSize: 14),
).tr(),
),
Align(
alignment: Alignment.center,
child: ElevatedButton.icon(
onPressed: onAddPartner,
icon: const Icon(Icons.person_add),
label: const Text("add_partner").tr(),
),
),
],
),
);
}
return ListView.builder(
itemCount: partners.length,
itemBuilder: (context, index) {
final partner = partners[index];
return ListTile(
leading: PartnerUserAvatar(partner: partner),
title: Text(partner.name),
subtitle: Text(partner.email),
trailing: IconButton(
icon: const Icon(Icons.person_remove),
onPressed: () => onDeletePartner(partner),
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Text("Error loading partners: $error"),
),
);
}
}
+1 -1
View File
@@ -147,7 +147,7 @@ class SearchPage extends HookConsumerWidget {
);
showPeoplePicker() {
handleOnSelect(Set<Person> value) {
handleOnSelect(Set<PersonDto> value) {
filter.value = filter.value.copyWith(
people: value,
);
@@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
@@ -75,7 +76,9 @@ class ShareIntentPage extends HookConsumerWidget {
leading: IconButton(
onPressed: () {
context.navigateTo(
const TabControllerRoute(),
Store.isBetaTimelineEnabled
? const TabShellRoute()
: const TabControllerRoute(),
);
},
icon: const Icon(Icons.arrow_back),
@@ -265,7 +268,7 @@ class UploadStatusIcon extends StatelessWidget {
color: Colors.red,
semanticLabel: 'canceled'.tr(),
),
UploadStatus.waitingtoRetry || UploadStatus.paused => Icon(
UploadStatus.waitingToRetry || UploadStatus.paused => Icon(
Icons.pause_circle_rounded,
color: context.primaryColor,
semanticLabel: 'paused'.tr(),
@@ -22,16 +22,6 @@ final _features = [
icon: Icons.timeline_rounded,
onTap: (ctx, _) => ctx.pushRoute(const TabShellRoute()),
),
_Feature(
name: 'Video',
icon: Icons.video_collection_outlined,
onTap: (ctx, _) => ctx.pushRoute(const DriftVideoRoute()),
),
_Feature(
name: 'Recently Taken',
icon: Icons.schedule_outlined,
onTap: (ctx, _) => ctx.pushRoute(const DriftRecentlyTakenRoute()),
),
_Feature(
name: 'Selection Mode Timeline',
icon: Icons.developer_mode_rounded,
@@ -101,6 +91,10 @@ final _features = [
),
_Feature(
name: 'Clear Local Data',
style: const TextStyle(
color: Colors.orange,
fontWeight: FontWeight.bold,
),
icon: Icons.delete_forever_rounded,
onTap: (_, ref) async {
final db = ref.read(driftProvider);
@@ -111,6 +105,10 @@ final _features = [
),
_Feature(
name: 'Clear Remote Data',
style: const TextStyle(
color: Colors.orange,
fontWeight: FontWeight.bold,
),
icon: Icons.delete_sweep_rounded,
onTap: (_, ref) async {
final db = ref.read(driftProvider);
@@ -122,21 +120,34 @@ final _features = [
await db.memoryEntity.deleteAll();
await db.memoryAssetEntity.deleteAll();
await db.stackEntity.deleteAll();
await db.personEntity.deleteAll();
},
),
_Feature(
name: 'Local Media Summary',
style: const TextStyle(
color: Colors.indigo,
fontWeight: FontWeight.bold,
),
icon: Icons.table_chart_rounded,
onTap: (ctx, _) => ctx.pushRoute(const LocalMediaSummaryRoute()),
),
_Feature(
name: 'Remote Media Summary',
style: const TextStyle(
color: Colors.indigo,
fontWeight: FontWeight.bold,
),
icon: Icons.summarize_rounded,
onTap: (ctx, _) => ctx.pushRoute(const RemoteMediaSummaryRoute()),
),
_Feature(
name: 'Reset Sqlite',
icon: Icons.table_view_rounded,
style: const TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
),
onTap: (_, ref) async {
final drift = ref.read(driftProvider);
// ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
@@ -169,7 +180,10 @@ class FeatInDevPage extends StatelessWidget {
final feat = _features[index];
return Consumer(
builder: (ctx, ref, _) => ListTile(
title: Text(feat.name),
title: Text(
feat.name,
style: feat.style,
),
trailing: Icon(feat.icon),
visualDensity: VisualDensity.compact,
onTap: () => unawaited(feat.onTap(ctx, ref)),
@@ -192,10 +206,12 @@ class _Feature {
required this.name,
required this.icon,
required this.onTap,
this.style,
});
final String name;
final IconData icon;
final TextStyle? style;
final Future<void> Function(BuildContext, WidgetRef _) onTap;
}
@@ -13,7 +13,7 @@ class MainTimelinePage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final memoryLaneProvider = ref.watch(driftMemoryFutureProvider);
return memoryLaneProvider.when(
return memoryLaneProvider.maybeWhen(
data: (memories) {
return memories.isEmpty
? const Timeline(showStorageIndicator: true)
@@ -26,8 +26,7 @@ class MainTimelinePage extends ConsumerWidget {
showStorageIndicator: true,
);
},
loading: () => const Timeline(showStorageIndicator: true),
error: (error, stackTrace) => const Timeline(showStorageIndicator: true),
orElse: () => const Timeline(showStorageIndicator: true),
);
}
}
@@ -166,6 +166,10 @@ final _remoteStats = [
name: 'Stacks',
load: (db) => db.managers.stackEntity.count(),
),
_Stat(
name: 'People',
load: (db) => db.managers.personEntity.count(),
),
];
@RoutePage()
@@ -13,6 +13,7 @@ import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/remote_album.utils.dart';
@@ -39,7 +40,7 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
// Load albums when component mounts
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(remoteAlbumProvider.notifier).getAll();
ref.read(remoteAlbumProvider.notifier).refresh();
});
searchController.addListener(() {
@@ -87,17 +88,20 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
@override
Widget build(BuildContext context) {
final albumState = ref.watch(remoteAlbumProvider);
final albums = albumState.filteredAlbums;
final isLoading = albumState.isLoading;
final error = albumState.error;
final albums =
ref.watch(remoteAlbumProvider.select((s) => s.filteredAlbums));
final userId = ref.watch(currentUserProvider)?.id;
return RefreshIndicator(
onRefresh: onRefresh,
edgeOffset: 100,
child: CustomScrollView(
slivers: [
ImmichSliverAppBar(
snap: false,
floating: false,
pinned: true,
actions: [
IconButton(
icon: const Icon(
@@ -132,14 +136,10 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
? _AlbumGrid(
albums: albums,
userId: userId,
isLoading: isLoading,
error: error,
)
: _AlbumList(
albums: albums,
userId: userId,
isLoading: isLoading,
error: error,
),
],
),
@@ -478,48 +478,17 @@ class _QuickSortAndViewMode extends StatelessWidget {
}
}
class _AlbumList extends StatelessWidget {
class _AlbumList extends ConsumerWidget {
const _AlbumList({
required this.isLoading,
required this.error,
required this.albums,
required this.userId,
});
final bool isLoading;
final String? error;
final List<RemoteAlbum> albums;
final String? userId;
@override
Widget build(BuildContext context) {
if (isLoading) {
return const SliverToBoxAdapter(
child: Center(
child: Padding(
padding: EdgeInsets.all(20.0),
child: CircularProgressIndicator(),
),
),
);
}
if (error != null) {
return SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Text(
'Error loading albums: $error',
style: TextStyle(
color: context.colorScheme.error,
),
),
),
),
);
}
Widget build(BuildContext context, WidgetRef ref) {
if (albums.isEmpty) {
return const SliverToBoxAdapter(
child: Center(
@@ -567,9 +536,12 @@ class _AlbumList extends StatelessWidget {
color: context.colorScheme.onSurfaceSecondary,
),
),
onTap: () => context.router.push(
RemoteAlbumRoute(album: album),
),
onTap: () {
ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album);
context.router.push(
RemoteAlbumRoute(album: album),
);
},
leadingPadding: const EdgeInsets.only(
right: 16,
),
@@ -619,44 +591,13 @@ class _AlbumGrid extends StatelessWidget {
const _AlbumGrid({
required this.albums,
required this.userId,
required this.isLoading,
required this.error,
});
final List<RemoteAlbum> albums;
final String? userId;
final bool isLoading;
final String? error;
@override
Widget build(BuildContext context) {
if (isLoading) {
return const SliverToBoxAdapter(
child: Center(
child: Padding(
padding: EdgeInsets.all(20.0),
child: CircularProgressIndicator(),
),
),
);
}
if (error != null) {
return SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Text(
'Error loading albums: $error',
style: TextStyle(
color: context.colorScheme.error,
),
),
),
),
);
}
if (albums.isEmpty) {
return const SliverToBoxAdapter(
child: Center(
@@ -692,7 +633,7 @@ class _AlbumGrid extends StatelessWidget {
}
}
class _GridAlbumCard extends StatelessWidget {
class _GridAlbumCard extends ConsumerWidget {
const _GridAlbumCard({
required this.album,
required this.userId,
@@ -702,11 +643,14 @@ class _GridAlbumCard extends StatelessWidget {
final String? userId;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
return GestureDetector(
onTap: () => context.router.push(
RemoteAlbumRoute(album: album),
),
onTap: () {
ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album);
context.router.push(
RemoteAlbumRoute(album: album),
);
},
child: Card(
elevation: 0,
color: context.colorScheme.surfaceBright,
@@ -6,6 +6,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
@@ -202,6 +203,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
);
if (album != null) {
ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album);
context.replaceRoute(
RemoteAlbumRoute(album: album),
);

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