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:
Tronax 2026-08-16 12:04:28 +02:00
parent 85f5c6dd4e
commit 2b35139364
30 changed files with 837 additions and 124 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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