feat: add item notes and duplicate detection across platforms
- Add per-item notes editable in the detail and edit screens, synced between desktop and Android - Guard against duplicates by external ID and by type/year/title, mirroring the desktop findDuplicate logic in MediaDao - Wrap multi-step database operations in Room transactions - Disable Android auto backup and remove the destructive Room migration fallback so schema bumps fail loudly instead of wiping data - Bound crypto envelope KDF parameters when reading untrusted headers - Handle malformed server responses in SyncClient instead of crashing - Extend settings, database, and image cache on the desktop side
This commit is contained in:
parent
85f5c6dd4e
commit
2b35139364
30 changed files with 837 additions and 124 deletions
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
<application
|
||||
android:name=".UmtApp"
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ object Graph {
|
|||
fun provide(context: Context) {
|
||||
val app = context.applicationContext
|
||||
val db = UmtDatabase.get(app)
|
||||
repository = LibraryRepository(app, db.mediaDao())
|
||||
repository = LibraryRepository(app, db)
|
||||
syncSettings = SyncSettings(app)
|
||||
syncClient = SyncClient(repository, syncSettings)
|
||||
appPrefs = AppPrefs(app)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
package com.umt.tracker.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.withTransaction
|
||||
import com.umt.tracker.data.json.LibraryExport
|
||||
import com.umt.tracker.data.json.LibraryJson
|
||||
import com.umt.tracker.data.json.toDomain
|
||||
import com.umt.tracker.data.json.toDto
|
||||
import com.umt.tracker.data.local.MediaDao
|
||||
import com.umt.tracker.data.local.UmtDatabase
|
||||
import com.umt.tracker.data.local.toDomain
|
||||
import com.umt.tracker.data.local.toEntity
|
||||
import com.umt.tracker.domain.MediaItem
|
||||
|
|
@ -16,6 +18,7 @@ import kotlinx.coroutines.flow.Flow
|
|||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.time.Instant
|
||||
|
||||
/** Outcome of a JSON import, surfaced to the UI for a confirmation message. */
|
||||
|
|
@ -40,8 +43,10 @@ data class LibraryStats(
|
|||
*/
|
||||
class LibraryRepository(
|
||||
private val context: Context,
|
||||
private val dao: MediaDao,
|
||||
db: UmtDatabase,
|
||||
) {
|
||||
private val dao: MediaDao = db.mediaDao()
|
||||
private val database: UmtDatabase = db
|
||||
fun observeLibrary(): Flow<List<MediaItem>> =
|
||||
dao.observeAll().map { rows -> rows.map { it.toDomain() } }
|
||||
|
||||
|
|
@ -81,6 +86,8 @@ class LibraryRepository(
|
|||
/** Replaces the item's cover with a remote image URL (from a metadata provider). */
|
||||
suspend fun setCover(id: Long, coverUrl: String) = updateItem(id) { it.copy(coverUrl = coverUrl) }
|
||||
|
||||
suspend fun setNotes(id: Long, notes: String) = updateItem(id) { it.copy(notes = notes) }
|
||||
|
||||
suspend fun setStatus(id: Long, status: WatchStatus) = updateItem(id) { it.copy(status = status) }
|
||||
|
||||
/** Marks one unit (episode/chapter) watched or not, by its (segment, unit) number. */
|
||||
|
|
@ -108,9 +115,29 @@ class LibraryRepository(
|
|||
})
|
||||
}
|
||||
|
||||
private suspend inline fun updateItem(id: Long, transform: (MediaItem) -> MediaItem) {
|
||||
val current = getItem(id) ?: return
|
||||
save(transform(current))
|
||||
private suspend fun updateItem(id: Long, transform: (MediaItem) -> MediaItem) {
|
||||
// One transaction around read + write so concurrent toggles can't
|
||||
// silently clobber each other's updates.
|
||||
database.withTransaction {
|
||||
val current = dao.getById(id)?.toDomain() ?: return@withTransaction
|
||||
save(transform(current))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies an existing item the given one would duplicate, mirroring the
|
||||
* desktop guard: match by (externalSource, externalId) when both are set,
|
||||
* otherwise by (type, title, year). Returns the duplicate's title, or null.
|
||||
* The item's own id is excluded so an edit never matches itself.
|
||||
*/
|
||||
suspend fun findDuplicate(item: MediaItem): String? = withContext(Dispatchers.IO) {
|
||||
val dupId = when {
|
||||
item.externalId.isNotBlank() && item.externalSource.isNotBlank() ->
|
||||
dao.findDuplicateByExternalId(item.externalSource, item.externalId, item.id)
|
||||
else ->
|
||||
dao.findDuplicateByTitle(item.type.storage, item.year, item.title.trim(), item.id)
|
||||
}
|
||||
dupId?.let { dao.getTitle(it) }
|
||||
}
|
||||
|
||||
/** Distinct sorted genres across the whole library, for filter chips. */
|
||||
|
|
@ -131,17 +158,19 @@ class LibraryRepository(
|
|||
}
|
||||
|
||||
suspend fun stats(): LibraryStats = withContext(Dispatchers.IO) {
|
||||
val items = dao.getAll().map { it.toDomain() }
|
||||
LibraryStats(
|
||||
total = items.size,
|
||||
completed = items.count { it.status == WatchStatus.COMPLETED },
|
||||
inProgress = items.count { it.status == WatchStatus.IN_PROGRESS },
|
||||
planned = items.count { it.status == WatchStatus.PLAN_TO_WATCH },
|
||||
favorites = items.count { it.favorite },
|
||||
perType = items.groupingBy { it.type }.eachCount(),
|
||||
)
|
||||
statsOf(dao.getAll().map { it.toDomain() })
|
||||
}
|
||||
|
||||
/** Pure computation so UI flows can re-derive stats from observed items. */
|
||||
fun statsOf(items: List<MediaItem>): LibraryStats = LibraryStats(
|
||||
total = items.size,
|
||||
completed = items.count { it.status == WatchStatus.COMPLETED },
|
||||
inProgress = items.count { it.status == WatchStatus.IN_PROGRESS },
|
||||
planned = items.count { it.status == WatchStatus.PLAN_TO_WATCH },
|
||||
favorites = items.count { it.favorite },
|
||||
perType = items.groupingBy { it.type }.eachCount(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Replaces the whole library with the contents of [stream]. Import is
|
||||
* all-or-nothing: a parse error leaves the existing data untouched.
|
||||
|
|
@ -153,20 +182,28 @@ class LibraryRepository(
|
|||
|
||||
/**
|
||||
* Replaces the whole library with [text] (a JSON [LibraryExport]). Used by
|
||||
* both the file importer and the sync pull. All-or-nothing.
|
||||
* both the file importer and the sync pull. All-or-nothing: the wipe and
|
||||
* the inserts run in one Room transaction.
|
||||
*/
|
||||
suspend fun replaceFromJson(text: String): ImportResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val export = LibraryJson.instance.decodeFromString(LibraryExport.serializer(), text)
|
||||
val entities = export.items.map { it.toDomain().toEntity() }
|
||||
dao.clear()
|
||||
dao.upsertAll(entities)
|
||||
database.withTransaction {
|
||||
dao.clear()
|
||||
dao.upsertAll(entities)
|
||||
}
|
||||
ImportResult.Success(entities.size)
|
||||
} catch (e: Exception) {
|
||||
ImportResult.Failure(e.message ?: "Unbekannter Fehler beim Import")
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the canonical JSON snapshot to [stream] (file export / backup). */
|
||||
suspend fun exportTo(stream: OutputStream) = withContext(Dispatchers.IO) {
|
||||
stream.bufferedWriter().use { it.write(snapshotJson()) }
|
||||
}
|
||||
|
||||
/** Serializes the entire library to the canonical JSON snapshot for sync push. */
|
||||
suspend fun snapshotJson(): String = withContext(Dispatchers.IO) {
|
||||
val items = dao.getAll().map { it.toDomain().toDto() }
|
||||
|
|
|
|||
|
|
@ -40,4 +40,21 @@ interface MediaDao {
|
|||
|
||||
@Query("DELETE FROM media")
|
||||
suspend fun clear()
|
||||
|
||||
// --- duplicate guard (mirrors the desktop findDuplicate) ---
|
||||
|
||||
@Query(
|
||||
"SELECT id FROM media WHERE externalSource = :source AND externalId = :extId " +
|
||||
"AND id <> :selfId LIMIT 1"
|
||||
)
|
||||
suspend fun findDuplicateByExternalId(source: String, extId: String, selfId: Long): Long?
|
||||
|
||||
@Query(
|
||||
"SELECT id FROM media WHERE type = :type AND year = :year " +
|
||||
"AND LOWER(TRIM(title)) = LOWER(TRIM(:title)) AND id <> :selfId LIMIT 1"
|
||||
)
|
||||
suspend fun findDuplicateByTitle(type: String, year: Int, title: String, selfId: Long): Long?
|
||||
|
||||
@Query("SELECT title FROM media WHERE id = :id")
|
||||
suspend fun getTitle(id: Long): String?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ abstract class UmtDatabase : RoomDatabase() {
|
|||
context.applicationContext,
|
||||
UmtDatabase::class.java,
|
||||
"umt-library.db"
|
||||
).fallbackToDestructiveMigration().build().also { instance = it }
|
||||
).build().also { instance = it }
|
||||
// No fallbackToDestructiveMigration(): a schema bump without a
|
||||
// Migration must fail loudly instead of silently wiping the
|
||||
// user's library.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,13 @@ object CryptoEnvelope {
|
|||
private const val OPSLIMIT = 2L // crypto_pwhash_OPSLIMIT_INTERACTIVE
|
||||
private const val MEMLIMIT = 67108864L // crypto_pwhash_MEMLIMIT_INTERACTIVE (64 MiB)
|
||||
|
||||
// Upper bounds accepted when reading an envelope. The header is untrusted
|
||||
// input (a malicious server can craft it); unbounded values would let a
|
||||
// crafted snapshot force huge Argon2 allocations (OOM/crash). Legit
|
||||
// envelopes carry OPSLIMIT/MEMLIMIT. Must stay in sync with the desktop.
|
||||
private const val MAX_OPS = 8L
|
||||
private const val MAX_MEM = 268435456L // 256 MiB
|
||||
|
||||
private const val HEADER_BYTES = 4 + 1 + 4 + 4 + SALT_BYTES + NONCE_BYTES
|
||||
|
||||
private val random = SecureRandom()
|
||||
|
|
@ -86,6 +93,8 @@ object CryptoEnvelope {
|
|||
if (version != VERSION) throw CryptoException("Nicht unterstützte Version: $version")
|
||||
val ops = buf.int.toLong() and 0xFFFFFFFFL
|
||||
val mem = buf.int.toLong() and 0xFFFFFFFFL
|
||||
if (ops == 0L || mem == 0L || ops > MAX_OPS || mem > MAX_MEM)
|
||||
throw CryptoException("Ungültige Krypto-Parameter im Datenformat")
|
||||
val salt = ByteArray(SALT_BYTES).also { buf.get(it) }
|
||||
val nonce = ByteArray(NONCE_BYTES).also { buf.get(it) }
|
||||
val cipher = ByteArray(buf.remaining()).also { buf.get(it) }
|
||||
|
|
|
|||
|
|
@ -68,6 +68,10 @@ class SyncClient(
|
|||
SyncResult.Error(e.message ?: "Entschlüsselung fehlgeschlagen.")
|
||||
} catch (e: IOException) {
|
||||
SyncResult.Error("Netzwerkfehler: ${e.message}")
|
||||
} catch (e: Exception) {
|
||||
// Malformed server responses (bad JSON, bad base64, ...) must never
|
||||
// crash the app — surface them like any other sync error instead.
|
||||
SyncResult.Error("Antwort des Servers war ungültig: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,6 +115,8 @@ class SyncClient(
|
|||
SyncResult.Error(e.message ?: "Verschlüsselung fehlgeschlagen.")
|
||||
} catch (e: IOException) {
|
||||
SyncResult.Error("Netzwerkfehler: ${e.message}")
|
||||
} catch (e: Exception) {
|
||||
SyncResult.Error("Antwort des Servers war ungültig: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
113
android/app/src/main/java/com/umt/tracker/domain/Franchise.kt
Normal file
113
android/app/src/main/java/com/umt/tracker/domain/Franchise.kt
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
package com.umt.tracker.domain
|
||||
|
||||
/**
|
||||
* Franchise auto-detection, ported 1:1 from the desktop app (MediaItem.h) so
|
||||
* both platforms group titles identically: a subtitle after a colon/dash, a
|
||||
* trailing "(year)" and a trailing sequel marker (number or roman numeral) are
|
||||
* dropped, and titles sharing a longest common word prefix collapse onto one
|
||||
* franchise label. An explicit franchise on the item always wins.
|
||||
*/
|
||||
object Franchise {
|
||||
|
||||
private val SEPARATORS = listOf(": ", " - ", " – ", " — ")
|
||||
private val TAIL_NUMERAL = Regex("\\s+(?:[0-9]{1,3}|[IVXLCDM]+)$")
|
||||
private val NON_WORD = Regex("[^\\p{L}\\p{N}]+")
|
||||
|
||||
private val STOP_WORDS = setOf(
|
||||
"the", "a", "an", "and", "of", "to", "in", "on", "for",
|
||||
"und", "der", "die", "das", "den", "dem", "ein", "eine",
|
||||
"le", "la", "les", "el", "il", "no", "wa",
|
||||
)
|
||||
|
||||
/** Normalized base name of a title (drops subtitle/year/sequel marker). */
|
||||
fun keyFromTitle(title: String): String {
|
||||
var t = title.trim()
|
||||
for (sep in SEPARATORS) {
|
||||
val idx = t.indexOf(sep)
|
||||
if (idx > 0) { t = t.substring(0, idx); break }
|
||||
}
|
||||
val paren = t.indexOf('(')
|
||||
if (paren > 0) t = t.substring(0, paren)
|
||||
t = t.trim()
|
||||
t = TAIL_NUMERAL.replace(t, "")
|
||||
return t.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* The franchise an item belongs to: an explicit value always wins, otherwise
|
||||
* the auto-detected key of its title (optionally resolved through a
|
||||
* precomputed [clusterTitles] map).
|
||||
*/
|
||||
fun effective(item: MediaItem, autoMap: Map<String, String> = emptyMap()): String {
|
||||
item.franchise.trim().takeIf { it.isNotEmpty() }?.let { return it }
|
||||
return autoMap[item.title] ?: keyFromTitle(item.title)
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups titles into franchises by their longest shared word prefix (used by
|
||||
* at least two titles). Returns a map: original title -> franchise label.
|
||||
*/
|
||||
fun clusterTitles(titlesIn: List<String>): Map<String, String> {
|
||||
// De-duplicate while preserving order.
|
||||
val list = LinkedHashSet<String>()
|
||||
for (t in titlesIn) if (t.isNotBlank()) list.add(t)
|
||||
val n = list.size
|
||||
val titles = list.toList()
|
||||
|
||||
val orig = ArrayList<List<String>>(n)
|
||||
val norm = ArrayList<List<String>>(n)
|
||||
for (i in 0 until n) {
|
||||
val base = keyFromTitle(titles[i])
|
||||
val words = base.split(NON_WORD).filter { it.isNotEmpty() }
|
||||
orig.add(words)
|
||||
norm.add(words.map { it.lowercase() })
|
||||
}
|
||||
|
||||
// Count how many titles share each word-prefix.
|
||||
val prefixCount = HashMap<String, Int>()
|
||||
for (words in norm) {
|
||||
val key = StringBuilder()
|
||||
for (w in words) {
|
||||
if (key.isNotEmpty()) key.append(' ')
|
||||
key.append(w)
|
||||
val k = key.toString()
|
||||
prefixCount[k] = (prefixCount[k] ?: 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
val labelForKey = HashMap<String, String>()
|
||||
val result = HashMap<String, String>()
|
||||
for (i in 0 until n) {
|
||||
// Longest prefix shared by at least two titles.
|
||||
var bestL = 0
|
||||
val key = StringBuilder()
|
||||
for (L in norm[i].indices) {
|
||||
if (L > 0) key.append(' ')
|
||||
key.append(norm[i][L])
|
||||
if ((prefixCount[key.toString()] ?: 0) >= 2) bestL = L + 1
|
||||
}
|
||||
if (bestL == 0) {
|
||||
result[titles[i]] = keyFromTitle(titles[i])
|
||||
continue
|
||||
}
|
||||
// Drop trailing stop words from the shared prefix.
|
||||
var len = bestL
|
||||
while (len > 1 && norm[i][len - 1] in STOP_WORDS) --len
|
||||
val hasContent = (0 until len).any { norm[i][it] !in STOP_WORDS }
|
||||
if (!hasContent) {
|
||||
result[titles[i]] = keyFromTitle(titles[i])
|
||||
continue
|
||||
}
|
||||
val nkey = StringBuilder()
|
||||
val disp = StringBuilder()
|
||||
for (j in 0 until len) {
|
||||
if (j > 0) { nkey.append(' '); disp.append(' ') }
|
||||
nkey.append(norm[i][j])
|
||||
disp.append(orig[i][j])
|
||||
}
|
||||
if (nkey.toString() !in labelForKey) labelForKey[nkey.toString()] = disp.toString()
|
||||
result[titles[i]] = labelForKey[nkey.toString()]!!
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
package com.umt.tracker.ui.detail
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
|
|
@ -26,6 +30,7 @@ import androidx.compose.material.icons.filled.ExpandMore
|
|||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.PhotoLibrary
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
|
|
@ -33,12 +38,14 @@ import androidx.compose.material3.Icon
|
|||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -49,6 +56,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
|
|
@ -66,6 +74,9 @@ import com.umt.tracker.domain.unitLabel
|
|||
import com.umt.tracker.ui.components.LabeledDropdown
|
||||
import com.umt.tracker.ui.components.StarRatingInput
|
||||
import com.umt.tracker.ui.components.TagChip
|
||||
import kotlinx.coroutines.delay
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -79,6 +90,17 @@ fun DetailScreen(
|
|||
) {
|
||||
val item by viewModel.item.collectAsStateWithLifecycle()
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
|
||||
// Gallery picker for the cover (parity with the desktop "Datei…" button).
|
||||
val galleryPicker = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.GetContent()
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
val path = copyCoverToFiles(context, uri)
|
||||
if (path != null) viewModel.setCover(path)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
|
|
@ -122,6 +144,7 @@ fun DetailScreen(
|
|||
item = current,
|
||||
viewModel = viewModel,
|
||||
onChangeCover = { onChangeCover(current.type, current.title) },
|
||||
onPickCoverFromGallery = { galleryPicker.launch("image/*") },
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
}
|
||||
|
|
@ -150,6 +173,7 @@ private fun DetailContent(
|
|||
item: MediaItem,
|
||||
viewModel: DetailViewModel,
|
||||
onChangeCover: () -> Unit,
|
||||
onPickCoverFromGallery: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Seasons/volumes start collapsed for overview, mirroring the desktop app.
|
||||
|
|
@ -195,6 +219,14 @@ private fun DetailContent(
|
|||
)
|
||||
Text("Cover ändern", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
TextButton(onClick = onPickCoverFromGallery) {
|
||||
Icon(
|
||||
Icons.Filled.PhotoLibrary,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text("Aus Galerie", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.padding(start = 16.dp)) {
|
||||
Text(
|
||||
|
|
@ -301,26 +333,55 @@ private fun DetailContent(
|
|||
}
|
||||
}
|
||||
|
||||
if (item.notes.isNotBlank()) {
|
||||
item {
|
||||
Column {
|
||||
Text(
|
||||
text = "Notizen",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
text = item.notes,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
item {
|
||||
// Editable notes, saved debounced like the desktop dialog.
|
||||
Column {
|
||||
Text(
|
||||
text = "Notizen",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
var notes by remember(item.id) { mutableStateOf(item.notes) }
|
||||
var initialized by remember(item.id) { mutableStateOf(false) }
|
||||
LaunchedEffect(notes) {
|
||||
if (!initialized) {
|
||||
initialized = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
delay(600)
|
||||
viewModel.setNotes(notes)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = notes,
|
||||
onValueChange = { notes = it },
|
||||
placeholder = { Text("Persönliche Notizen…") },
|
||||
minLines = 2,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a picked gallery image into the app's private covers dir and returns a
|
||||
* file:// URI Coil can render, or null if the copy failed.
|
||||
*/
|
||||
private fun copyCoverToFiles(context: Context, uri: Uri): String? = runCatching {
|
||||
val dir = File(context.filesDir, "covers").apply { mkdirs() }
|
||||
val ext = context.contentResolver.getType(uri)
|
||||
?.substringAfterLast('/')
|
||||
?.takeIf { it.length in 2..5 }
|
||||
?: "jpg"
|
||||
val dest = File(dir, "${UUID.randomUUID()}.$ext")
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
dest.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: return null
|
||||
"file://${dest.absolutePath}"
|
||||
}.getOrNull()
|
||||
|
||||
@Composable
|
||||
private fun SegmentRow(
|
||||
segment: Segment,
|
||||
|
|
|
|||
|
|
@ -28,8 +28,14 @@ class DetailViewModel(
|
|||
|
||||
fun setStatus(status: WatchStatus) = launch { repo.setStatus(itemId, status) }
|
||||
|
||||
/** Persists edited free-text notes (detail screen editor). */
|
||||
fun setNotes(notes: String) = launch { repo.setNotes(itemId, notes) }
|
||||
|
||||
fun setRating(rating: Int) = launch { repo.setRating(itemId, rating) }
|
||||
|
||||
/** Sets the cover to a URL or local file:// path (gallery pick). */
|
||||
fun setCover(coverUrl: String) = launch { repo.setCover(itemId, coverUrl) }
|
||||
|
||||
fun setUnitWatched(segmentNumber: Int, unitNumber: Int, watched: Boolean) =
|
||||
launch { repo.setUnitWatched(itemId, segmentNumber, unitNumber, watched) }
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,10 @@ import androidx.compose.material3.TopAppBar
|
|||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
|
|
@ -58,6 +61,7 @@ fun EditScreen(
|
|||
) {
|
||||
val form by viewModel.form.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
var saveError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
|
|
@ -70,7 +74,17 @@ fun EditScreen(
|
|||
},
|
||||
actions = {
|
||||
TextButton(onClick = {
|
||||
scope.launch { viewModel.save()?.let(onSaved) }
|
||||
scope.launch {
|
||||
when (val r = viewModel.save()) {
|
||||
is EditViewModel.SaveResult.Saved -> onSaved(r.id)
|
||||
is EditViewModel.SaveResult.EmptyTitle ->
|
||||
saveError = "Bitte einen Titel angeben."
|
||||
is EditViewModel.SaveResult.Duplicate ->
|
||||
saveError = "„${r.existingTitle}“ ist bereits in deiner " +
|
||||
"Bibliothek vorhanden. Es wurde nichts hinzugefügt, damit " +
|
||||
"dein bestehender Fortschritt nicht überschrieben wird."
|
||||
}
|
||||
}
|
||||
}) { Text("Speichern") }
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
|
|
@ -88,6 +102,13 @@ fun EditScreen(
|
|||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
saveError?.let { err ->
|
||||
Text(
|
||||
text = err,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
LabeledDropdown(
|
||||
label = "Typ",
|
||||
selected = form.type,
|
||||
|
|
@ -221,7 +242,19 @@ fun EditScreen(
|
|||
}
|
||||
|
||||
Button(
|
||||
onClick = { scope.launch { viewModel.save()?.let(onSaved) } },
|
||||
onClick = {
|
||||
scope.launch {
|
||||
when (val r = viewModel.save()) {
|
||||
is EditViewModel.SaveResult.Saved -> onSaved(r.id)
|
||||
is EditViewModel.SaveResult.EmptyTitle ->
|
||||
saveError = "Bitte einen Titel angeben."
|
||||
is EditViewModel.SaveResult.Duplicate ->
|
||||
saveError = "„${r.existingTitle}“ ist bereits in deiner " +
|
||||
"Bibliothek vorhanden. Es wurde nichts hinzugefügt, damit " +
|
||||
"dein bestehender Fortschritt nicht überschrieben wird."
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) { Text("Speichern") }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,11 +112,27 @@ class EditViewModel(
|
|||
it.copy(customFields = it.customFields.mapIndexed { i, p -> if (i == index) key to value else p })
|
||||
}
|
||||
|
||||
/** Persists the form. Returns the saved item id, or null if the title is empty. */
|
||||
suspend fun save(): Long? {
|
||||
/**
|
||||
* Outcome of saving: either the persisted id, a rejection because the title
|
||||
* is empty, or a rejection because the item would duplicate an existing one
|
||||
* (same guard as the desktop app).
|
||||
*/
|
||||
sealed interface SaveResult {
|
||||
data class Saved(val id: Long) : SaveResult
|
||||
data object EmptyTitle : SaveResult
|
||||
data class Duplicate(val existingTitle: String) : SaveResult
|
||||
}
|
||||
|
||||
suspend fun save(): SaveResult {
|
||||
val s = _form.value
|
||||
if (s.title.isBlank()) return null
|
||||
return repo.save(s.toMediaItem())
|
||||
if (s.title.isBlank()) return SaveResult.EmptyTitle
|
||||
val item = s.toMediaItem()
|
||||
// Only new items are checked; an edit keeps its own id and is therefore
|
||||
// never flagged (mirrors the desktop behaviour).
|
||||
if (item.id == 0L) {
|
||||
repo.findDuplicate(item)?.let { return SaveResult.Duplicate(it) }
|
||||
}
|
||||
return SaveResult.Saved(repo.save(item))
|
||||
}
|
||||
|
||||
private fun EditFormState.toMediaItem(): MediaItem = MediaItem(
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import androidx.compose.material.icons.Icons
|
|||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.BarChart
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
|
|
@ -93,6 +94,15 @@ fun LibraryScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// System file picker for the JSON export target.
|
||||
val exportPicker = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/json")
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
viewModel.export(uri) { context.contentResolver.openOutputStream(it) }
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(message) {
|
||||
message?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
|
|
@ -131,6 +141,9 @@ fun LibraryScreen(
|
|||
IconButton(onClick = { picker.launch(arrayOf("application/json", "*/*")) }) {
|
||||
Icon(Icons.Filled.FileDownload, contentDescription = "Bibliothek importieren")
|
||||
}
|
||||
IconButton(onClick = { exportPicker.launch("media-library.json") }) {
|
||||
Icon(Icons.Filled.FileUpload, contentDescription = "Bibliothek exportieren")
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = "Einstellungen")
|
||||
}
|
||||
|
|
@ -188,6 +201,7 @@ fun LibraryScreen(
|
|||
FilterSheet(
|
||||
filter = state.filter,
|
||||
allGenres = state.allGenres,
|
||||
allTags = state.allTags,
|
||||
allFranchises = state.allFranchises,
|
||||
onChange = viewModel::updateFilter,
|
||||
onClear = viewModel::clearFilters,
|
||||
|
|
@ -252,6 +266,7 @@ private fun EmptyState(onImport: () -> Unit) {
|
|||
private fun FilterSheet(
|
||||
filter: LibraryFilter,
|
||||
allGenres: List<String>,
|
||||
allTags: List<String>,
|
||||
allFranchises: List<String>,
|
||||
onChange: ((LibraryFilter) -> LibraryFilter) -> Unit,
|
||||
onClear: () -> Unit,
|
||||
|
|
@ -333,6 +348,30 @@ private fun FilterSheet(
|
|||
}
|
||||
}
|
||||
|
||||
SectionTitle(
|
||||
"Jahr: ${filter.yearFrom.takeIf { it > 0 } ?: "…"} – ${filter.yearTo.takeIf { it > 0 } ?: "…"}"
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = filter.yearFrom.takeIf { it > 0 }?.toString() ?: "",
|
||||
onValueChange = { v ->
|
||||
onChange { it.copy(yearFrom = v.filter(Char::isDigit).take(4).toIntOrNull() ?: 0) }
|
||||
},
|
||||
label = { Text("von") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = filter.yearTo.takeIf { it > 0 }?.toString() ?: "",
|
||||
onValueChange = { v ->
|
||||
onChange { it.copy(yearTo = v.filter(Char::isDigit).take(4).toIntOrNull() ?: 0) }
|
||||
},
|
||||
label = { Text("bis") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
if (allGenres.isNotEmpty()) {
|
||||
SectionTitle("Genres")
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
|
@ -351,6 +390,24 @@ private fun FilterSheet(
|
|||
}
|
||||
}
|
||||
|
||||
if (allTags.isNotEmpty()) {
|
||||
SectionTitle("Tags")
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
allTags.forEach { t ->
|
||||
FilterChip(
|
||||
selected = t in filter.tags,
|
||||
onClick = {
|
||||
onChange {
|
||||
val next = if (t in it.tags) it.tags - t else it.tags + t
|
||||
it.copy(tags = next)
|
||||
}
|
||||
},
|
||||
label = { Text(t) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allFranchises.isNotEmpty()) {
|
||||
SectionTitle("Reihe / Franchise")
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import com.umt.tracker.data.LibraryRepository
|
|||
import com.umt.tracker.data.sync.SyncClient
|
||||
import com.umt.tracker.data.sync.SyncResult
|
||||
import com.umt.tracker.data.sync.SyncSettings
|
||||
import com.umt.tracker.domain.Franchise
|
||||
import com.umt.tracker.domain.MediaItem
|
||||
import com.umt.tracker.domain.MediaType
|
||||
import com.umt.tracker.domain.WatchStatus
|
||||
|
|
@ -34,8 +35,11 @@ data class LibraryFilter(
|
|||
val status: WatchStatus? = null,
|
||||
val favoritesOnly: Boolean = false,
|
||||
val genres: Set<String> = emptySet(),
|
||||
val tags: Set<String> = emptySet(),
|
||||
val franchise: String? = null,
|
||||
val minRating: Int = 0,
|
||||
val yearFrom: Int = 0,
|
||||
val yearTo: Int = 0,
|
||||
val sortMode: SortMode = SortMode.DATE_ADDED,
|
||||
val sortDescending: Boolean = true,
|
||||
) {
|
||||
|
|
@ -44,8 +48,11 @@ data class LibraryFilter(
|
|||
get() = (if (status != null) 1 else 0) +
|
||||
(if (favoritesOnly) 1 else 0) +
|
||||
genres.size +
|
||||
tags.size +
|
||||
(if (franchise != null) 1 else 0) +
|
||||
(if (minRating > 0) 1 else 0)
|
||||
(if (minRating > 0) 1 else 0) +
|
||||
(if (yearFrom > 0) 1 else 0) +
|
||||
(if (yearTo > 0) 1 else 0)
|
||||
}
|
||||
|
||||
data class LibraryUiState(
|
||||
|
|
@ -54,6 +61,7 @@ data class LibraryUiState(
|
|||
val typeFilter: MediaType? = null,
|
||||
val filter: LibraryFilter = LibraryFilter(),
|
||||
val allGenres: List<String> = emptyList(),
|
||||
val allTags: List<String> = emptyList(),
|
||||
val allFranchises: List<String> = emptyList(),
|
||||
val loading: Boolean = true,
|
||||
val syncEnabled: Boolean = false,
|
||||
|
|
@ -74,12 +82,14 @@ class LibraryViewModel(
|
|||
|
||||
val uiState: StateFlow<LibraryUiState> =
|
||||
combine(repo.observeLibrary(), query, typeFilter, filter) { items, q, type, f ->
|
||||
val autoMap = Franchise.clusterTitles(items.map { it.title })
|
||||
val filtered = items
|
||||
.filter { it.matches(q, type, f) }
|
||||
.filter { it.matches(q, type, f, autoMap) }
|
||||
.sortedWith(f.comparator())
|
||||
// Facets reflect the whole library, not the filtered view.
|
||||
val genres = items.flatMap { it.genres }.distinct().sorted()
|
||||
val franchises = items.map { it.franchise }
|
||||
val tags = items.flatMap { it.tags }.distinct().sorted()
|
||||
val franchises = items.map { Franchise.effective(it, autoMap) }
|
||||
.filter { it.isNotBlank() }
|
||||
.groupingBy { it }.eachCount()
|
||||
.filterValues { it >= 2 }.keys.sorted()
|
||||
|
|
@ -89,6 +99,7 @@ class LibraryViewModel(
|
|||
typeFilter = type,
|
||||
filter = f,
|
||||
allGenres = genres,
|
||||
allTags = tags,
|
||||
allFranchises = franchises,
|
||||
loading = false,
|
||||
syncEnabled = syncSettings.isCloud && syncSettings.isConfigured,
|
||||
|
|
@ -99,7 +110,12 @@ class LibraryViewModel(
|
|||
initialValue = LibraryUiState(),
|
||||
)
|
||||
|
||||
private fun MediaItem.matches(q: String, type: MediaType?, f: LibraryFilter): Boolean {
|
||||
private fun MediaItem.matches(
|
||||
q: String,
|
||||
type: MediaType?,
|
||||
f: LibraryFilter,
|
||||
autoMap: Map<String, String>,
|
||||
): Boolean {
|
||||
if (type != null && this.type != type) return false
|
||||
if (q.isNotBlank() && !title.contains(q, true) &&
|
||||
!originalTitle.contains(q, true) && !overview.contains(q, true)
|
||||
|
|
@ -108,7 +124,10 @@ class LibraryViewModel(
|
|||
if (f.favoritesOnly && !favorite) return false
|
||||
if (f.minRating > 0 && rating < f.minRating) return false
|
||||
if (f.genres.isNotEmpty() && !f.genres.all { it in genres }) return false
|
||||
if (f.franchise != null && franchise != f.franchise) return false
|
||||
if (f.tags.isNotEmpty() && !f.tags.all { it in tags }) return false
|
||||
if (f.yearFrom > 0 && year != 0 && year < f.yearFrom) return false
|
||||
if (f.yearTo > 0 && year != 0 && year > f.yearTo) return false
|
||||
if (f.franchise != null && Franchise.effective(this, autoMap) != f.franchise) return false
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -156,13 +175,38 @@ class LibraryViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
/** One-tap pull from the server (when cloud sync is configured). */
|
||||
/** Writes the whole library as JSON to the user-picked document. */
|
||||
fun export(uri: Uri, openStream: (Uri) -> java.io.OutputStream?) {
|
||||
viewModelScope.launch {
|
||||
val stream = openStream(uri)
|
||||
if (stream == null) {
|
||||
messages.value = "Datei konnte nicht geöffnet werden."
|
||||
return@launch
|
||||
}
|
||||
runCatching { stream.use { repo.exportTo(it) } }
|
||||
.onSuccess { messages.value = "Bibliothek exportiert." }
|
||||
.onFailure { messages.value = "Export fehlgeschlagen: ${it.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-tap sync like the desktop: push local changes first, then pull the
|
||||
* canonical server state back down. A push conflict is surfaced for the
|
||||
* settings screen, where it can be resolved (pull server vs. overwrite).
|
||||
*/
|
||||
fun syncNow() {
|
||||
viewModelScope.launch {
|
||||
messages.value = when (val r = syncClient.pull()) {
|
||||
is SyncResult.Success -> r.message
|
||||
is SyncResult.Conflict -> "Konflikt – bitte in den Einstellungen auflösen."
|
||||
is SyncResult.Error -> r.message
|
||||
messages.value = when (val pushed = syncClient.push()) {
|
||||
is SyncResult.Conflict ->
|
||||
"Konflikt: Der Server hat eine neuere Version (Revision " +
|
||||
"${pushed.serverRevision}). Bitte in den Einstellungen auflösen."
|
||||
is SyncResult.Error -> pushed.message
|
||||
is SyncResult.Success -> when (val pulled = syncClient.pull()) {
|
||||
is SyncResult.Success -> "Bibliothek synchronisiert."
|
||||
is SyncResult.Error -> pulled.message
|
||||
is SyncResult.Conflict ->
|
||||
"Konflikt – bitte in den Einstellungen auflösen."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,19 +6,21 @@ import androidx.lifecycle.viewModelScope
|
|||
import com.umt.tracker.Graph
|
||||
import com.umt.tracker.data.LibraryRepository
|
||||
import com.umt.tracker.data.LibraryStats
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
class StatsViewModel(private val repo: LibraryRepository) : ViewModel() {
|
||||
|
||||
private val _stats = MutableStateFlow<LibraryStats?>(null)
|
||||
val stats: StateFlow<LibraryStats?> = _stats.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch { _stats.value = repo.stats() }
|
||||
}
|
||||
/** Recomputed whenever the library changes, so stats never go stale. */
|
||||
val stats: StateFlow<LibraryStats?> = repo.observeLibrary()
|
||||
.map { repo.statsOf(it) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
companion object {
|
||||
val Factory = object : ViewModelProvider.Factory {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue