feat(android): reach desktop feature parity and fix 16 KB page-size crash

Swap lazysodium/JNA for a pure-Java Bouncy Castle crypto envelope so the
APK ships no native .so files (resolves the Android 15 16 KB alignment
crash) while staying byte-compatible with the desktop libsodium sync.

Bring the Android app up to the desktop feature set: full item CRUD with
an add/edit screen and FAB, inline editing on the detail view (status,
rating, favorite, per-unit/segment watched toggles, delete), online
metadata search (TMDB/OpenLibrary/AniList/RAWG) that pre-fills the editor,
library filtering and sorting, a statistics view, and settings polish for
dark mode and provider API keys/language.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tronax 2026-06-21 01:40:55 +02:00
parent de353f0218
commit c0389bd8d2
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
24 changed files with 2162 additions and 75 deletions

View file

@ -68,12 +68,9 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.coil.compose)
// lazysodium-android pulls JNA as a plain jar; on Android we need the aar
// (it carries the native .so), so exclude the transitive jar and add the aar.
implementation(libs.lazysodium.android) {
exclude(group = "net.java.dev.jna", module = "jna")
}
implementation(libs.jna) { artifact { type = "aar" } }
// Bouncy Castle (pure Java) provides Argon2id + ChaCha20-Poly1305 with no
// native .so, so the app stays compatible with Android 15's 16 KB page size.
implementation(libs.bouncycastle)
implementation(libs.androidx.security.crypto)
debugImplementation(libs.androidx.ui.tooling)

View file

@ -1,10 +1,13 @@
package com.umt.tracker
import android.content.Context
import com.umt.tracker.data.AppPrefs
import com.umt.tracker.data.LibraryRepository
import com.umt.tracker.data.local.UmtDatabase
import com.umt.tracker.data.sync.SyncClient
import com.umt.tracker.data.sync.SyncSettings
import com.umt.tracker.domain.MediaItem
import com.umt.tracker.providers.ProviderManager
/**
* Tiny manual service locator. Keeps the scaffold dependency-injection-free while
@ -17,6 +20,17 @@ object Graph {
private set
lateinit var syncClient: SyncClient
private set
lateinit var appPrefs: AppPrefs
private set
lateinit var providers: ProviderManager
private set
/**
* One-shot hand-off of a pre-filled item from the online search into the edit
* screen. Navigation can only carry primitives, so the chosen result waits
* here until the edit screen consumes it.
*/
var editDraft: MediaItem? = null
fun provide(context: Context) {
val app = context.applicationContext
@ -24,5 +38,7 @@ object Graph {
repository = LibraryRepository(app, db.mediaDao())
syncSettings = SyncSettings(app)
syncClient = SyncClient(repository, syncSettings)
appPrefs = AppPrefs(app)
providers = ProviderManager(appPrefs, syncSettings)
}
}

View file

@ -6,7 +6,9 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.umt.tracker.ui.UmtNavHost
import com.umt.tracker.ui.theme.UmtTheme
@ -15,7 +17,8 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
UmtTheme {
val darkTheme by Graph.appPrefs.darkMode.collectAsStateWithLifecycle()
UmtTheme(darkTheme = darkTheme) {
Surface(modifier = Modifier.fillMaxSize()) {
UmtNavHost()
}

View file

@ -0,0 +1,46 @@
package com.umt.tracker.data
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Non-sensitive app preferences (theme, metadata-provider keys, query language).
* Sync secrets live in the encrypted [com.umt.tracker.data.sync.SyncSettings];
* these values are harmless in plain SharedPreferences. Dark mode is exposed as a
* StateFlow so the Compose theme recomposes the moment it changes.
*/
class AppPrefs(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("umt_app_prefs", Context.MODE_PRIVATE)
private val _darkMode = MutableStateFlow(prefs.getBoolean(KEY_DARK, true))
val darkMode: StateFlow<Boolean> = _darkMode
fun setDarkMode(value: Boolean) {
prefs.edit().putBoolean(KEY_DARK, value).apply()
_darkMode.value = value
}
var tmdbApiKey: String
get() = prefs.getString(KEY_TMDB, "") ?: ""
set(value) = prefs.edit().putString(KEY_TMDB, value.trim()).apply()
var rawgApiKey: String
get() = prefs.getString(KEY_RAWG, "") ?: ""
set(value) = prefs.edit().putString(KEY_RAWG, value.trim()).apply()
/** ISO language code used for provider queries (e.g. "de"). */
var language: String
get() = prefs.getString(KEY_LANG, "de") ?: "de"
set(value) = prefs.edit().putString(KEY_LANG, value.trim().ifBlank { "de" }).apply()
private companion object {
const val KEY_DARK = "dark_mode"
const val KEY_TMDB = "tmdb_api_key"
const val KEY_RAWG = "rawg_api_key"
const val KEY_LANG = "language"
}
}

View file

@ -9,11 +9,14 @@ import com.umt.tracker.data.local.MediaDao
import com.umt.tracker.data.local.toDomain
import com.umt.tracker.data.local.toEntity
import com.umt.tracker.domain.MediaItem
import com.umt.tracker.domain.MediaType
import com.umt.tracker.domain.WatchStatus
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.time.Instant
/** Outcome of a JSON import, surfaced to the UI for a confirmation message. */
sealed interface ImportResult {
@ -21,6 +24,16 @@ sealed interface ImportResult {
data class Failure(val message: String) : ImportResult
}
/** Aggregate counts shown in the statistics view, mirroring the desktop sidebar. */
data class LibraryStats(
val total: Int = 0,
val completed: Int = 0,
val inProgress: Int = 0,
val planned: Int = 0,
val favorites: Int = 0,
val perType: Map<MediaType, Int> = emptyMap(),
)
/**
* Single source of truth for the library. Backed by Room; the only way data
* enters today is a JSON import produced by the desktop app.
@ -35,6 +48,97 @@ class LibraryRepository(
fun observeItem(id: Long): Flow<MediaItem?> =
dao.observeById(id).map { it?.toDomain() }
suspend fun getItem(id: Long): MediaItem? = withContext(Dispatchers.IO) {
dao.getById(id)?.toDomain()
}
/**
* Inserts a new item (id = 0) or updates an existing one. Stamps dateAdded on
* first save and dateCompleted whenever the status flips to Completed. Returns
* the row id (useful right after creating a brand-new item).
*/
suspend fun save(item: MediaItem): Long = withContext(Dispatchers.IO) {
val now = Instant.now().toString()
val stamped = item.copy(
dateAdded = item.dateAdded.ifBlank { now },
dateCompleted = when {
item.status == WatchStatus.COMPLETED && item.dateCompleted.isBlank() -> now
item.status != WatchStatus.COMPLETED -> ""
else -> item.dateCompleted
},
)
dao.upsert(stamped.toEntity())
}
suspend fun delete(id: Long) = withContext(Dispatchers.IO) {
dao.deleteById(id)
}
suspend fun setFavorite(id: Long, favorite: Boolean) = updateItem(id) { it.copy(favorite = favorite) }
suspend fun setRating(id: Long, rating: Int) = updateItem(id) { it.copy(rating = rating.coerceIn(0, 10)) }
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. */
suspend fun setUnitWatched(id: Long, segmentNumber: Int, unitNumber: Int, watched: Boolean) =
updateItem(id) { item ->
val now = Instant.now().toString()
item.copy(segments = item.segments.map { seg ->
if (seg.number != segmentNumber) seg
else seg.copy(units = seg.units.map { u ->
if (u.number != unitNumber) u
else u.copy(watched = watched, watchedDate = if (watched) now else "")
})
})
}
/** Marks every unit in a segment watched or not at once. */
suspend fun setSegmentWatched(id: Long, segmentNumber: Int, watched: Boolean) =
updateItem(id) { item ->
val now = Instant.now().toString()
item.copy(segments = item.segments.map { seg ->
if (seg.number != segmentNumber) seg
else seg.copy(units = seg.units.map {
it.copy(watched = watched, watchedDate = if (watched) now else "")
})
})
}
private suspend inline fun updateItem(id: Long, transform: (MediaItem) -> MediaItem) {
val current = getItem(id) ?: return
save(transform(current))
}
/** Distinct sorted genres across the whole library, for filter chips. */
suspend fun allGenres(): List<String> = withContext(Dispatchers.IO) {
dao.getAll().flatMap { it.toDomain().genres }.distinct().sorted()
}
suspend fun allTags(): List<String> = withContext(Dispatchers.IO) {
dao.getAll().flatMap { it.toDomain().tags }.distinct().sorted()
}
/** Franchises that group at least two items, matching the desktop filter. */
suspend fun allFranchises(): List<String> = withContext(Dispatchers.IO) {
dao.getAll().map { it.toDomain().franchise }
.filter { it.isNotBlank() }
.groupingBy { it }.eachCount()
.filterValues { it >= 2 }.keys.sorted()
}
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(),
)
}
/**
* Replaces the whole library with the contents of [stream]. Import is
* all-or-nothing: a parse error leaves the existing data untouched.

View file

@ -19,15 +19,25 @@ interface MediaDao {
@Query("SELECT * FROM media ORDER BY title COLLATE NOCASE")
suspend fun getAll(): List<MediaEntity>
@Query("SELECT * FROM media WHERE id = :id")
suspend fun getById(id: Long): MediaEntity?
@Query("SELECT COUNT(*) FROM media")
suspend fun count(): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(items: List<MediaEntity>)
/** Inserts a new row (id = 0) or replaces an existing one; returns its id. */
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(item: MediaEntity): Long
@Update
suspend fun update(item: MediaEntity)
@Query("DELETE FROM media WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("DELETE FROM media")
suspend fun clear()
}

View file

@ -1,11 +1,13 @@
package com.umt.tracker.data.sync
import com.goterl.lazysodium.LazySodiumAndroid
import com.goterl.lazysodium.SodiumAndroid
import com.goterl.lazysodium.interfaces.PwHash
import com.sun.jna.NativeLong
import org.bouncycastle.crypto.generators.Argon2BytesGenerator
import org.bouncycastle.crypto.modes.ChaCha20Poly1305
import org.bouncycastle.crypto.params.AEADParameters
import org.bouncycastle.crypto.params.Argon2Parameters
import org.bouncycastle.crypto.params.KeyParameter
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.SecureRandom
/**
* Versioned, zero-knowledge encryption envelope shared byte-for-byte with the
@ -20,21 +22,29 @@ import java.nio.ByteOrder
* KDF: Argon2id (libsodium crypto_pwhash, ALG 1.3) 32-byte key.
* AEAD: XChaCha20-Poly1305-IETF, no associated data.
*
* Implemented with the Bouncy Castle lightweight API (pure Java) so the app ships
* no native .so files this keeps it compatible with Android 15's mandatory 16 KB
* memory page size, which lazysodium/JNA's prebuilt natives did not satisfy.
*
* libsodium's XChaCha20-Poly1305 is not packaged in Bouncy Castle, so it is built
* from the two primitives BC does provide: HChaCha20 derives a subkey from the
* passphrase key and the first 16 nonce bytes, then ChaCha20-Poly1305 (IETF, with
* a 12-byte nonce of four zero bytes followed by the remaining 8 nonce bytes)
* does the AEAD. This reproduces libsodium's construction exactly.
*
* The opslimit/memlimit are stored in the header so a snapshot stays decryptable
* even if the defaults below ever change. Both platforms must agree on this
* exact layout and the Argon2id algorithm so either can decrypt the other's data.
*/
object CryptoEnvelope {
private val sodium = LazySodiumAndroid(SodiumAndroid())
private val MAGIC = byteArrayOf('U'.code.toByte(), 'M'.code.toByte(), 'T'.code.toByte(), 'S'.code.toByte())
private const val VERSION: Byte = 1
private const val SALT_BYTES = 16 // crypto_pwhash_SALTBYTES
private const val NONCE_BYTES = 24 // crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
private const val KEY_BYTES = 32 // crypto_aead_xchacha20poly1305_ietf_KEYBYTES
private const val ABYTES = 16 // crypto_aead_xchacha20poly1305_ietf_ABYTES
private const val ABYTES = 16 // crypto_aead_xchacha20poly1305_ietf_ABYTES (Poly1305 tag)
// libsodium INTERACTIVE limits — a balance of security and phone CPU cost.
private const val OPSLIMIT = 2L // crypto_pwhash_OPSLIMIT_INTERACTIVE
@ -42,23 +52,17 @@ object CryptoEnvelope {
private const val HEADER_BYTES = 4 + 1 + 4 + 4 + SALT_BYTES + NONCE_BYTES
private val random = SecureRandom()
class CryptoException(message: String) : Exception(message)
/** Encrypts [plaintext] under [passphrase] and returns the full envelope. */
fun encrypt(plaintext: ByteArray, passphrase: String): ByteArray {
val salt = sodium.randomBytesBuf(SALT_BYTES)
val nonce = sodium.randomBytesBuf(NONCE_BYTES)
val salt = ByteArray(SALT_BYTES).also { random.nextBytes(it) }
val nonce = ByteArray(NONCE_BYTES).also { random.nextBytes(it) }
val key = deriveKey(passphrase, salt, OPSLIMIT, MEMLIMIT)
val cipher = ByteArray(plaintext.size + ABYTES)
val cipherLen = LongArray(1)
val ok = sodium.cryptoAeadXChaCha20Poly1305IetfEncrypt(
cipher, cipherLen,
plaintext, plaintext.size.toLong(),
ByteArray(0), 0L,
null, nonce, key,
)
if (!ok) throw CryptoException("Verschlüsselung fehlgeschlagen")
val cipher = xChaChaAead(forEncryption = true, key = key, nonce = nonce, input = plaintext)
return ByteBuffer.allocate(HEADER_BYTES + cipher.size).order(ByteOrder.BIG_ENDIAN).apply {
put(MAGIC)
@ -88,29 +92,94 @@ object CryptoEnvelope {
if (cipher.size < ABYTES) throw CryptoException("Daten unvollständig")
val key = deriveKey(passphrase, salt, ops, mem)
val plain = ByteArray(cipher.size - ABYTES)
val plainLen = LongArray(1)
val ok = sodium.cryptoAeadXChaCha20Poly1305IetfDecrypt(
plain, plainLen,
null,
cipher, cipher.size.toLong(),
ByteArray(0), 0L,
nonce, key,
)
if (!ok) throw CryptoException("Entschlüsselung fehlgeschlagen (falsche Passphrase?)")
return plain
return try {
xChaChaAead(forEncryption = false, key = key, nonce = nonce, input = cipher)
} catch (e: Exception) {
throw CryptoException("Entschlüsselung fehlgeschlagen (falsche Passphrase?)")
}
}
/**
* XChaCha20-Poly1305-IETF via Bouncy Castle: HChaCha20 derives a subkey from
* the 32-byte key and the first 16 nonce bytes, then ChaCha20-Poly1305 (IETF)
* runs with a 12-byte nonce = 0x00000000 nonce[16:24].
*/
private fun xChaChaAead(forEncryption: Boolean, key: ByteArray, nonce: ByteArray, input: ByteArray): ByteArray {
val subkey = hChaCha20(key, nonce.copyOfRange(0, 16))
val chachaNonce = ByteArray(12)
System.arraycopy(nonce, 16, chachaNonce, 4, 8)
val aead = ChaCha20Poly1305()
aead.init(forEncryption, AEADParameters(KeyParameter(subkey), ABYTES * 8, chachaNonce))
val out = ByteArray(aead.getOutputSize(input.size))
val written = aead.processBytes(input, 0, input.size, out, 0)
aead.doFinal(out, written)
return out
}
private fun deriveKey(passphrase: String, salt: ByteArray, ops: Long, mem: Long): ByteArray {
val pw = passphrase.toByteArray(Charsets.UTF_8)
val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
.withVersion(Argon2Parameters.ARGON2_VERSION_13)
.withIterations(ops.toInt())
.withMemoryAsKB((mem / 1024L).toInt())
.withParallelism(1)
.withSalt(salt)
.build()
val generator = Argon2BytesGenerator().apply { init(params) }
val key = ByteArray(KEY_BYTES)
val ok = sodium.cryptoPwHash(
key, KEY_BYTES,
pw, pw.size,
salt, ops, NativeLong(mem),
PwHash.Alg.PWHASH_ALG_ARGON2ID13,
)
if (!ok) throw CryptoException("Schlüsselableitung fehlgeschlagen")
generator.generateBytes(passphrase.toByteArray(Charsets.UTF_8), key)
return key
}
/**
* HChaCha20: maps a 32-byte key and a 16-byte input to a 32-byte subkey using
* the ChaCha20 permutation (10 double rounds) over the standard state, taking
* the first and last four words of the permuted state without the usual final
* addition. Matches RFC draft-irtf-cfrg-xchacha and libsodium.
*/
private fun hChaCha20(key: ByteArray, input: ByteArray): ByteArray {
val state = IntArray(16)
state[0] = 0x61707865
state[1] = 0x3320646e
state[2] = 0x79622d32
state[3] = 0x6b206574
for (i in 0 until 8) state[4 + i] = leInt(key, i * 4)
for (i in 0 until 4) state[12 + i] = leInt(input, i * 4)
repeat(10) {
quarterRound(state, 0, 4, 8, 12)
quarterRound(state, 1, 5, 9, 13)
quarterRound(state, 2, 6, 10, 14)
quarterRound(state, 3, 7, 11, 15)
quarterRound(state, 0, 5, 10, 15)
quarterRound(state, 1, 6, 11, 12)
quarterRound(state, 2, 7, 8, 13)
quarterRound(state, 3, 4, 9, 14)
}
val out = ByteArray(32)
for (i in 0 until 4) putLeInt(out, i * 4, state[i])
for (i in 0 until 4) putLeInt(out, 16 + i * 4, state[12 + i])
return out
}
private fun quarterRound(s: IntArray, a: Int, b: Int, c: Int, d: Int) {
s[a] += s[b]; s[d] = Integer.rotateLeft(s[d] xor s[a], 16)
s[c] += s[d]; s[b] = Integer.rotateLeft(s[b] xor s[c], 12)
s[a] += s[b]; s[d] = Integer.rotateLeft(s[d] xor s[a], 8)
s[c] += s[d]; s[b] = Integer.rotateLeft(s[b] xor s[c], 7)
}
private fun leInt(b: ByteArray, off: Int): Int =
(b[off].toInt() and 0xFF) or
((b[off + 1].toInt() and 0xFF) shl 8) or
((b[off + 2].toInt() and 0xFF) shl 16) or
((b[off + 3].toInt() and 0xFF) shl 24)
private fun putLeInt(b: ByteArray, off: Int, v: Int) {
b[off] = (v and 0xFF).toByte()
b[off + 1] = ((v ushr 8) and 0xFF).toByte()
b[off + 2] = ((v ushr 16) and 0xFF).toByte()
b[off + 3] = ((v ushr 24) and 0xFF).toByte()
}
}

View file

@ -0,0 +1,305 @@
package com.umt.tracker.providers
import com.umt.tracker.data.AppPrefs
import com.umt.tracker.data.sync.SyncSettings
import com.umt.tracker.domain.MediaType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/**
* Routes an online metadata search to the right provider for a [MediaType],
* mirroring the desktop ProviderManager: TMDB for films/series (direct key or via
* the proxy server), OpenLibrary for books, AniList for manga, RAWG for games.
* All calls are plain HttpURLConnection on the IO dispatcher no extra deps.
*/
class ProviderManager(
private val prefs: AppPrefs,
private val sync: SyncSettings,
) {
private val json = Json { ignoreUnknownKeys = true }
/** Whether a search for [type] can run with the current configuration. */
fun isConfigured(type: MediaType): Boolean = when (type) {
MediaType.MOVIE, MediaType.SERIES -> sync.serverUrl.isNotBlank() || prefs.tmdbApiKey.isNotBlank()
MediaType.GAME -> prefs.rawgApiKey.isNotBlank()
MediaType.BOOK, MediaType.MANGA -> true
}
/** Human-readable hint shown when [isConfigured] is false. */
fun configHint(type: MediaType): String = when (type) {
MediaType.MOVIE, MediaType.SERIES ->
"Für Film-/Serien-Suche bitte einen TMDB-Key oder Proxy-Server in den Einstellungen hinterlegen."
MediaType.GAME ->
"Für die Spiele-Suche bitte einen kostenlosen RAWG-API-Key in den Einstellungen hinterlegen (rawg.io/apidocs)."
else -> ""
}
suspend fun search(query: String, type: MediaType): SearchOutcome = withContext(Dispatchers.IO) {
try {
when (type) {
MediaType.MOVIE, MediaType.SERIES -> searchTmdb(query, type)
MediaType.BOOK -> searchOpenLibrary(query)
MediaType.MANGA -> searchAniList(query)
MediaType.GAME -> searchRawg(query)
}
} catch (e: IOException) {
SearchOutcome.Error("Netzwerkfehler: ${e.message}")
} catch (e: Exception) {
SearchOutcome.Error("Suche fehlgeschlagen: ${e.message}")
}
}
/** Enriches a chosen result. Only TMDB series gain seasons + genres. */
suspend fun fetchDetails(partial: SearchResult): SearchResult = withContext(Dispatchers.IO) {
if (partial.type != MediaType.SERIES) return@withContext partial
runCatching { tmdbSeriesDetails(partial) }.getOrDefault(partial)
}
// ---- TMDB (films + series) -------------------------------------------------
private fun tmdbBase(): Pair<String, String?> {
val proxy = sync.serverUrl.trim().trimEnd('/')
return if (proxy.isNotBlank()) "$proxy/3" to sync.bearerToken
else "https://api.themoviedb.org/3" to null
}
private fun tmdbGet(path: String, extra: Map<String, String> = emptyMap()): JsonElement {
val (base, bearer) = tmdbBase()
val params = buildMap {
// In proxy mode the server injects the real key; never leak ours.
if (bearer == null) put("api_key", prefs.tmdbApiKey)
put("language", prefs.language)
putAll(extra)
}
val url = URL(base + path + "?" + params.toQuery())
val conn = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = 15_000
readTimeout = 30_000
setRequestProperty("User-Agent", UA)
if (bearer != null && bearer.isNotBlank()) setRequestProperty("Authorization", "Bearer $bearer")
}
return json.parseToJsonElement(conn.readBody())
}
private fun searchTmdb(query: String, type: MediaType): SearchOutcome {
if (!isConfigured(type)) return SearchOutcome.Error(configHint(type))
val movie = type == MediaType.MOVIE
val path = if (movie) "/search/movie" else "/search/tv"
val root = tmdbGet(path, mapOf("query" to query, "include_adult" to "false"))
val results = root.jsonObject["results"]?.jsonArray.orEmpty().map { el ->
val o = el.jsonObject
val original = if (movie) o.str("original_title") else o.str("original_name")
val date = if (movie) o.str("release_date") else o.str("first_air_date")
val poster = o.str("poster_path")
SearchResult(
type = type,
source = "TMDB",
externalId = o.int("id").toString(),
title = if (movie) o.str("title") else o.str("name"),
originalTitle = original,
year = date.take(4).toIntOrNull() ?: 0,
overview = o.str("overview"),
coverUrl = if (poster.isNotBlank()) TMDB_IMG + poster else "",
externalRating = o.dbl("vote_average"),
localizedTitles = if (original.isNotBlank()) mapOf("original" to original) else emptyMap(),
)
}
return SearchOutcome.Success(results)
}
private fun tmdbSeriesDetails(partial: SearchResult): SearchResult {
val o = tmdbGet("/tv/${partial.externalId}").jsonObject
val genres = o["genres"]?.jsonArray.orEmpty().mapNotNull { it.jsonObject.str("name").ifBlank { null } }
val segments = o["seasons"]?.jsonArray.orEmpty().mapNotNull { sv ->
val so = sv.jsonObject
val num = so.int("season_number")
if (num <= 0) null // skip "Specials"
else ResultSegment(number = num, title = so.str("name"), unitCount = so.int("episode_count"))
}
return partial.copy(genres = genres, segments = segments)
}
// ---- OpenLibrary (books) ---------------------------------------------------
private fun searchOpenLibrary(query: String): SearchOutcome {
val params = mapOf(
"q" to query,
"limit" to "25",
"fields" to "title,author_name,first_publish_year,cover_i,subject,first_sentence,key",
)
val url = URL("https://openlibrary.org/search.json?" + params.toQuery())
val conn = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"; connectTimeout = 15_000; readTimeout = 30_000
setRequestProperty("User-Agent", UA)
}
val docs = json.parseToJsonElement(conn.readBody()).jsonObject["docs"]?.jsonArray.orEmpty()
val results = docs.map { el ->
val o = el.jsonObject
val authors = o["author_name"]?.jsonArray.orEmpty().mapNotNull { it.jsonPrimitive.contentOrNull }
val firstSentence = o["first_sentence"]?.jsonArray.orEmpty().mapNotNull { it.jsonPrimitive.contentOrNull }
val overview = buildString {
if (authors.isNotEmpty()) append("von ${authors.first()}")
if (firstSentence.isNotEmpty()) {
if (isNotEmpty()) append("\n\n")
append(firstSentence.first())
}
}
val coverId = o.int("cover_i")
SearchResult(
type = MediaType.BOOK,
source = "OpenLibrary",
title = o.str("title"),
year = o.int("first_publish_year"),
externalId = o.str("key"),
overview = overview,
genres = o["subject"]?.jsonArray.orEmpty().take(6).mapNotNull { it.jsonPrimitive.contentOrNull },
coverUrl = if (coverId > 0) "https://covers.openlibrary.org/b/id/$coverId-L.jpg" else "",
)
}
return SearchOutcome.Success(results)
}
// ---- AniList (manga) -------------------------------------------------------
private fun searchAniList(query: String): SearchOutcome {
val gql = """
query (${'$'}q: String) {
Page(perPage: 25) {
media(search: ${'$'}q, type: MANGA, sort: SEARCH_MATCH) {
id title { romaji english native } description(asHtml: false)
coverImage { large } genres chapters volumes startDate { year } averageScore
}
}
}
""".trimIndent()
val body = buildJsonObject {
put("query", gql)
put("variables", buildJsonObject { put("q", query) })
}
val conn = (URL("https://graphql.anilist.co").openConnection() as HttpURLConnection).apply {
requestMethod = "POST"; doOutput = true; connectTimeout = 15_000; readTimeout = 30_000
setRequestProperty("Content-Type", "application/json")
setRequestProperty("Accept", "application/json")
setRequestProperty("User-Agent", UA)
}
conn.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) }
val media = json.parseToJsonElement(conn.readBody())
.jsonObject["data"]?.jsonObject
?.get("Page")?.jsonObject
?.get("media")?.jsonArray.orEmpty()
val results = media.map { el ->
val o = el.jsonObject
val t = o["title"]?.jsonObject ?: JsonObject(emptyMap())
val romaji = t.str("romaji"); val english = t.str("english"); val native = t.str("native")
val localized = buildMap {
if (romaji.isNotBlank()) put("romaji", romaji)
if (english.isNotBlank()) put("en", english)
if (native.isNotBlank()) put("ja", native)
}
val desc = o.str("description")
.replace("<br>", "\n")
.replace(Regex("<[^>]*>"), "")
val chapters = o.int("chapters"); val volumes = o.int("volumes")
val segments = buildMangaSegments(chapters, volumes)
SearchResult(
type = MediaType.MANGA,
source = "AniList",
externalId = o.int("id").toString(),
title = english.ifBlank { romaji.ifBlank { native } },
originalTitle = native,
localizedTitles = localized,
overview = desc,
year = o["startDate"]?.jsonObject?.int("year") ?: 0,
externalRating = o.dbl("averageScore") / 10.0,
genres = o["genres"]?.jsonArray.orEmpty().mapNotNull { it.jsonPrimitive.contentOrNull },
coverUrl = o["coverImage"]?.jsonObject?.str("large") ?: "",
segments = segments,
)
}
return SearchOutcome.Success(results)
}
private fun buildMangaSegments(chapters: Int, volumes: Int): List<ResultSegment> = when {
volumes > 0 && chapters > 0 -> {
val base = chapters / volumes
val rem = chapters % volumes
(1..volumes).map { vol ->
ResultSegment(number = vol, title = "Band $vol", unitCount = base + if (vol <= rem) 1 else 0)
}
}
chapters > 0 -> listOf(ResultSegment(number = 1, title = "Kapitel", unitCount = chapters))
volumes > 0 -> (1..volumes).map { ResultSegment(number = it, title = "Band $it", unitCount = 0) }
else -> emptyList()
}
// ---- RAWG (games) ----------------------------------------------------------
private fun searchRawg(query: String): SearchOutcome {
val key = prefs.rawgApiKey.trim()
if (key.isBlank()) return SearchOutcome.Error(configHint(MediaType.GAME))
val params = mapOf("key" to key, "search" to query, "page_size" to "25")
val url = URL("https://api.rawg.io/api/games?" + params.toQuery())
val conn = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"; connectTimeout = 15_000; readTimeout = 30_000
setRequestProperty("User-Agent", UA)
}
val games = json.parseToJsonElement(conn.readBody()).jsonObject["results"]?.jsonArray.orEmpty()
val results = games.map { el ->
val o = el.jsonObject
SearchResult(
type = MediaType.GAME,
source = "RAWG",
title = o.str("name"),
externalId = o.int("id").toString(),
year = o.str("released").take(4).toIntOrNull() ?: 0,
coverUrl = o.str("background_image"),
externalRating = o.dbl("rating"),
genres = o["genres"]?.jsonArray.orEmpty().mapNotNull { it.jsonObject.str("name").ifBlank { null } },
)
}
return SearchOutcome.Success(results)
}
// ---- helpers ---------------------------------------------------------------
private fun HttpURLConnection.readBody(): String {
val ok = responseCode in 200..299
val stream = if (ok) inputStream else (errorStream ?: inputStream)
val text = stream.bufferedReader().use { it.readText() }
if (!ok) throw IOException("HTTP $responseCode")
return text
}
private fun Map<String, String>.toQuery(): String =
entries.joinToString("&") { (k, v) ->
"${URLEncoder.encode(k, "UTF-8")}=${URLEncoder.encode(v, "UTF-8")}"
}
private fun JsonObject.str(key: String): String = this[key]?.jsonPrimitive?.contentOrNull ?: ""
private fun JsonObject.int(key: String): Int = this[key]?.jsonPrimitive?.intOrNull ?: 0
private fun JsonObject.dbl(key: String): Double = this[key]?.jsonPrimitive?.doubleOrNull ?: 0.0
private companion object {
const val UA = "UltimateMediaTracker-Android/1.0"
const val TMDB_IMG = "https://image.tmdb.org/t/p/w500"
}
}
private fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList())

View file

@ -0,0 +1,61 @@
package com.umt.tracker.providers
import com.umt.tracker.domain.MediaItem
import com.umt.tracker.domain.MediaType
import com.umt.tracker.domain.ProgressUnit
import com.umt.tracker.domain.Segment
/** A season/volume layout hint from a provider, used to pre-build the unit tree. */
data class ResultSegment(
val number: Int,
val title: String = "",
val unitCount: Int = 0,
)
/** One hit from an online metadata search, mirroring the desktop SearchResult. */
data class SearchResult(
val type: MediaType = MediaType.MOVIE,
val title: String = "",
val originalTitle: String = "",
val overview: String = "",
val coverUrl: String = "",
val year: Int = 0,
val externalId: String = "",
val source: String = "",
val genres: List<String> = emptyList(),
val localizedTitles: Map<String, String> = emptyMap(),
val segments: List<ResultSegment> = emptyList(),
val externalRating: Double = 0.0,
)
/** Result of a search request, surfaced to the UI. */
sealed interface SearchOutcome {
data class Success(val results: List<SearchResult>) : SearchOutcome
data class Error(val message: String) : SearchOutcome
}
/**
* Builds a fresh [MediaItem] from a chosen search result, expanding each
* [ResultSegment] into a numbered list of unwatched units so the user lands on a
* ready-to-track entry. The provider rating (0..10) becomes the initial rating.
*/
fun SearchResult.toMediaItem(): MediaItem = MediaItem(
type = type,
title = title,
originalTitle = originalTitle,
localizedTitles = localizedTitles,
coverUrl = coverUrl,
genres = genres,
overview = overview,
year = year,
rating = externalRating.toInt().coerceIn(0, 10),
segments = segments.map { seg ->
Segment(
number = seg.number,
title = seg.title,
units = (1..seg.unitCount).map { ProgressUnit(number = it) },
)
},
externalId = externalId,
externalSource = source,
)

View file

@ -6,15 +6,24 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.umt.tracker.domain.MediaType
import com.umt.tracker.ui.detail.DetailScreen
import com.umt.tracker.ui.edit.EditScreen
import com.umt.tracker.ui.library.LibraryScreen
import com.umt.tracker.ui.search.SearchScreen
import com.umt.tracker.ui.settings.SettingsScreen
import com.umt.tracker.ui.stats.StatsScreen
private object Routes {
const val LIBRARY = "library"
const val DETAIL = "detail/{itemId}"
const val EDIT = "edit?itemId={itemId}"
const val SEARCH = "search/{type}"
const val SETTINGS = "settings"
const val STATS = "stats"
fun detail(id: Long) = "detail/$id"
fun edit(id: Long = 0) = "edit?itemId=$id"
fun search(type: MediaType) = "search/${type.name}"
}
@Composable
@ -25,18 +34,57 @@ fun UmtNavHost() {
composable(Routes.LIBRARY) {
LibraryScreen(
onItemClick = { id -> navController.navigate(Routes.detail(id)) },
onAddItem = { navController.navigate(Routes.edit()) },
onOpenStats = { navController.navigate(Routes.STATS) },
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
)
}
composable(Routes.SETTINGS) {
SettingsScreen(onBack = { navController.popBackStack() })
}
composable(Routes.STATS) {
StatsScreen(onBack = { navController.popBackStack() })
}
composable(
route = Routes.DETAIL,
arguments = listOf(navArgument("itemId") { type = NavType.LongType }),
) { backStackEntry ->
val id = backStackEntry.arguments?.getLong("itemId") ?: return@composable
DetailScreen(itemId = id, onBack = { navController.popBackStack() })
DetailScreen(
itemId = id,
onBack = { navController.popBackStack() },
onEdit = { navController.navigate(Routes.edit(id)) },
onDeleted = { navController.popBackStack() },
)
}
composable(
route = Routes.EDIT,
arguments = listOf(navArgument("itemId") { type = NavType.LongType; defaultValue = 0L }),
) { backStackEntry ->
val id = backStackEntry.arguments?.getLong("itemId") ?: 0L
EditScreen(
itemId = id,
onBack = { navController.popBackStack() },
onSaved = { navController.popBackStack() },
onSearch = { type -> navController.navigate(Routes.search(type)) },
)
}
composable(
route = Routes.SEARCH,
arguments = listOf(navArgument("type") { type = NavType.StringType }),
) { backStackEntry ->
val typeName = backStackEntry.arguments?.getString("type") ?: MediaType.MOVIE.name
val type = runCatching { MediaType.valueOf(typeName) }.getOrDefault(MediaType.MOVIE)
SearchScreen(
type = type,
onBack = { navController.popBackStack() },
onPicked = {
// Replace the current edit (if any) with a fresh, draft-filled one.
navController.navigate(Routes.edit()) {
popUpTo(Routes.EDIT) { inclusive = true }
}
},
)
}
}
}

View file

@ -1,11 +1,28 @@
package com.umt.tracker.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@ -28,3 +45,64 @@ fun TagChip(
.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
/** A read-only text field that opens a dropdown of [options] when tapped. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun <T> LabeledDropdown(
label: String,
selected: T,
options: List<T>,
optionLabel: (T) -> String,
onSelect: (T) -> Unit,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = modifier,
) {
OutlinedTextField(
value = optionLabel(selected),
onValueChange = {},
readOnly = true,
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor()
.fillMaxWidth(),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(optionLabel(option)) },
onClick = { onSelect(option); expanded = false },
)
}
}
}
}
/** Interactive 0..10 star rating. Tapping the same star again clears to 0. */
@Composable
fun StarRatingInput(
rating: Int,
onRatingChange: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
Row(modifier) {
// Ten half-units rendered as five tappable stars (each = 2 points).
for (star in 1..5) {
val value = star * 2
Icon(
imageVector = if (rating >= value) Icons.Filled.Star else Icons.Filled.StarBorder,
contentDescription = "$value",
tint = Color(0xFFFFD60A),
modifier = Modifier
.size(32.dp)
.clickable { onRatingChange(if (rating == value) value - 1 else value) },
)
}
}
}

View file

@ -19,10 +19,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.ExpandLess
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.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -30,12 +34,15 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
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.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -48,11 +55,14 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.umt.tracker.domain.MediaItem
import com.umt.tracker.domain.Segment
import com.umt.tracker.domain.WatchStatus
import com.umt.tracker.domain.accentArgb
import com.umt.tracker.domain.label
import com.umt.tracker.domain.labelSingular
import com.umt.tracker.domain.segmentLabel
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
@OptIn(ExperimentalMaterial3Api::class)
@ -60,9 +70,12 @@ import com.umt.tracker.ui.components.TagChip
fun DetailScreen(
itemId: Long,
onBack: () -> Unit,
onEdit: () -> Unit,
onDeleted: () -> Unit,
viewModel: DetailViewModel = viewModel(factory = DetailViewModel.factory(itemId)),
) {
val item by viewModel.item.collectAsStateWithLifecycle()
var confirmDelete by remember { mutableStateOf(false) }
Scaffold(
topBar = {
@ -73,6 +86,22 @@ fun DetailScreen(
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
},
actions = {
val fav = item?.favorite == true
IconButton(onClick = viewModel::toggleFavorite) {
Icon(
imageVector = if (fav) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
contentDescription = "Favorit",
tint = if (fav) Color(0xFFFF375F) else MaterialTheme.colorScheme.onBackground,
)
}
IconButton(onClick = onEdit) {
Icon(Icons.Filled.Edit, contentDescription = "Bearbeiten")
}
IconButton(onClick = { confirmDelete = true }) {
Icon(Icons.Filled.Delete, contentDescription = "Löschen")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
titleContentColor = MaterialTheme.colorScheme.onBackground,
@ -86,13 +115,34 @@ fun DetailScreen(
Text("Nicht gefunden", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else {
DetailContent(current, Modifier.padding(padding))
DetailContent(current, viewModel, Modifier.padding(padding))
}
}
if (confirmDelete) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text("Eintrag löschen?") },
text = { Text("Dieser Eintrag wird dauerhaft entfernt.") },
confirmButton = {
TextButton(onClick = {
confirmDelete = false
viewModel.delete(onDeleted)
}) { Text("Löschen") }
},
dismissButton = {
TextButton(onClick = { confirmDelete = false }) { Text("Abbrechen") }
},
)
}
}
@Composable
private fun DetailContent(item: MediaItem, modifier: Modifier = Modifier) {
private fun DetailContent(
item: MediaItem,
viewModel: DetailViewModel,
modifier: Modifier = Modifier,
) {
// Seasons/volumes start collapsed for overview, mirroring the desktop app.
val expanded = remember { mutableStateMapOf<Int, Boolean>() }
@ -148,24 +198,34 @@ private fun DetailContent(item: MediaItem, modifier: Modifier = Modifier) {
modifier = Modifier.padding(top = 8.dp),
)
}
if (item.rating > 0) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 4.dp),
) {
Icon(Icons.Filled.Star, null, tint = Color(0xFFFFD60A))
Text(
text = "${item.rating} / 10",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 4.dp),
)
}
}
}
}
}
item {
LabeledDropdown(
label = "Status",
selected = item.status,
options = WatchStatus.entries,
optionLabel = { it.label },
onSelect = viewModel::setStatus,
)
}
item {
Column {
Text(
text = "Bewertung",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
)
StarRatingInput(
rating = item.rating,
onRatingChange = viewModel::setRating,
)
}
}
if (item.hasSegments) {
item {
Column {
@ -189,6 +249,10 @@ private fun DetailContent(item: MediaItem, modifier: Modifier = Modifier) {
typeUnitLabel = item.type.unitLabel,
expanded = expanded[segment.number] == true,
onToggle = { expanded[segment.number] = !(expanded[segment.number] ?: false) },
onToggleSegment = { watched -> viewModel.setSegmentWatched(segment.number, watched) },
onToggleUnit = { unitNumber, watched ->
viewModel.setUnitWatched(segment.number, unitNumber, watched)
},
)
}
}
@ -238,7 +302,10 @@ private fun SegmentRow(
typeUnitLabel: String,
expanded: Boolean,
onToggle: () -> Unit,
onToggleSegment: (Boolean) -> Unit,
onToggleUnit: (Int, Boolean) -> Unit,
) {
val allWatched = segment.units.isNotEmpty() && segment.units.all { it.watched }
Column(
modifier = Modifier
.fillMaxWidth()
@ -252,7 +319,17 @@ private fun SegmentRow(
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
// Tapping the leading icon flips the whole segment watched/unwatched.
Icon(
imageVector = if (allWatched) Icons.Filled.CheckCircle
else Icons.Filled.RadioButtonUnchecked,
contentDescription = "Alle markieren",
tint = if (allWatched) Color(0xFF30D158) else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(24.dp)
.clickable { onToggleSegment(!allWatched) },
)
Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) {
val name = segment.title.ifBlank { "$typeSegmentLabel ${segment.number}" }
Text(
text = name,
@ -278,6 +355,7 @@ private fun SegmentRow(
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onToggleUnit(unit.number, !unit.watched) }
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {

View file

@ -6,11 +6,16 @@ import androidx.lifecycle.viewModelScope
import com.umt.tracker.Graph
import com.umt.tracker.data.LibraryRepository
import com.umt.tracker.domain.MediaItem
import com.umt.tracker.domain.WatchStatus
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class DetailViewModel(repo: LibraryRepository, itemId: Long) : ViewModel() {
class DetailViewModel(
private val repo: LibraryRepository,
private val itemId: Long,
) : ViewModel() {
val item: StateFlow<MediaItem?> =
repo.observeItem(itemId).stateIn(
@ -19,6 +24,27 @@ class DetailViewModel(repo: LibraryRepository, itemId: Long) : ViewModel() {
initialValue = null,
)
fun toggleFavorite() = launch { repo.setFavorite(itemId, !(item.value?.favorite ?: false)) }
fun setStatus(status: WatchStatus) = launch { repo.setStatus(itemId, status) }
fun setRating(rating: Int) = launch { repo.setRating(itemId, rating) }
fun setUnitWatched(segmentNumber: Int, unitNumber: Int, watched: Boolean) =
launch { repo.setUnitWatched(itemId, segmentNumber, unitNumber, watched) }
fun setSegmentWatched(segmentNumber: Int, watched: Boolean) =
launch { repo.setSegmentWatched(itemId, segmentNumber, watched) }
fun delete(onDone: () -> Unit) = launch {
repo.delete(itemId)
onDone()
}
private inline fun launch(crossinline block: suspend () -> Unit) {
viewModelScope.launch { block() }
}
companion object {
fun factory(itemId: Long) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")

View file

@ -0,0 +1,276 @@
package com.umt.tracker.ui.edit
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
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.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.umt.tracker.domain.MediaType
import com.umt.tracker.domain.WatchStatus
import com.umt.tracker.domain.label
import com.umt.tracker.domain.labelSingular
import com.umt.tracker.domain.segmentLabel
import com.umt.tracker.domain.unitLabel
import com.umt.tracker.ui.components.LabeledDropdown
import com.umt.tracker.ui.components.StarRatingInput
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditScreen(
itemId: Long,
onBack: () -> Unit,
onSaved: (Long) -> Unit,
onSearch: (MediaType) -> Unit,
viewModel: EditViewModel = viewModel(factory = EditViewModel.factory(itemId)),
) {
val form by viewModel.form.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (form.isNew) "Neuer Eintrag" else "Bearbeiten") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
},
actions = {
TextButton(onClick = {
scope.launch { viewModel.save()?.let(onSaved) }
}) { Text("Speichern") }
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
titleContentColor = MaterialTheme.colorScheme.onBackground,
),
)
},
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
LabeledDropdown(
label = "Typ",
selected = form.type,
options = MediaType.entries,
optionLabel = { it.labelSingular },
onSelect = { t -> viewModel.update { it.copy(type = t) } },
)
OutlinedButton(
onClick = { onSearch(form.type) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Search, contentDescription = null)
Text("Online suchen & übernehmen", modifier = Modifier.padding(start = 8.dp))
}
field("Titel", form.title) { v -> viewModel.update { it.copy(title = v) } }
field("Originaltitel", form.originalTitle) { v -> viewModel.update { it.copy(originalTitle = v) } }
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = form.yearText,
onValueChange = { v -> viewModel.update { it.copy(yearText = v.filter(Char::isDigit)) } },
label = { Text("Jahr") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(120.dp),
)
LabeledDropdown(
label = "Status",
selected = form.status,
options = WatchStatus.entries,
optionLabel = { it.label },
onSelect = { s -> viewModel.update { it.copy(status = s) } },
modifier = Modifier.weight(1f),
)
}
Column {
Text("Bewertung", style = MaterialTheme.typography.labelLarge)
StarRatingInput(
rating = form.rating,
onRatingChange = { r -> viewModel.update { it.copy(rating = r) } },
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(
checked = form.favorite,
onCheckedChange = { c -> viewModel.update { it.copy(favorite = c) } },
)
Text("Favorit", modifier = Modifier.padding(start = 8.dp))
}
field("Cover-URL", form.coverUrl) { v -> viewModel.update { it.copy(coverUrl = v) } }
field("Genres (Komma-getrennt)", form.genresText) { v -> viewModel.update { it.copy(genresText = v) } }
field("Tags (Komma-getrennt)", form.tagsText) { v -> viewModel.update { it.copy(tagsText = v) } }
field("Reihe / Franchise", form.franchise) { v -> viewModel.update { it.copy(franchise = v) } }
field("Beschreibung", form.overview, minLines = 3) { v -> viewModel.update { it.copy(overview = v) } }
field("Notizen", form.notes, minLines = 3) { v -> viewModel.update { it.copy(notes = v) } }
// Segments / units (seasons & episodes, volumes & chapters, ...)
Text(
"${form.type.segmentLabel}n & ${form.type.unitLabel}n",
style = MaterialTheme.typography.titleMedium,
)
form.segments.forEach { segment ->
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = segment.title,
onValueChange = { viewModel.setSegmentTitle(segment.number, it) },
label = { Text("${form.type.segmentLabel} ${segment.number}") },
singleLine = true,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { viewModel.removeSegment(segment.number) }) {
Icon(Icons.Filled.Close, contentDescription = "Entfernen")
}
}
OutlinedTextField(
value = segment.units.size.toString(),
onValueChange = {
viewModel.setUnitCount(segment.number, it.filter(Char::isDigit).toIntOrNull() ?: 0)
},
label = { Text("Anzahl ${form.type.unitLabel}n") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(200.dp),
)
}
}
TextButton(onClick = viewModel::addSegment) {
Icon(Icons.Filled.Add, contentDescription = null)
Text("${form.type.segmentLabel} hinzufügen", modifier = Modifier.padding(start = 8.dp))
}
// Localized titles
Text("Lokalisierte Titel", style = MaterialTheme.typography.titleMedium)
form.localizedTitles.forEachIndexed { index, (key, value) ->
KeyValueRow(
key = key, value = value,
keyLabel = "Sprache", valueLabel = "Titel",
onKey = { viewModel.setLocalizedTitle(index, it, value) },
onValue = { viewModel.setLocalizedTitle(index, key, it) },
onRemove = { viewModel.removeLocalizedTitle(index) },
)
}
TextButton(onClick = viewModel::addLocalizedTitle) {
Icon(Icons.Filled.Add, contentDescription = null)
Text("Titel hinzufügen", modifier = Modifier.padding(start = 8.dp))
}
// Custom fields
Text("Eigene Felder", style = MaterialTheme.typography.titleMedium)
form.customFields.forEachIndexed { index, (key, value) ->
KeyValueRow(
key = key, value = value,
keyLabel = "Feld", valueLabel = "Wert",
onKey = { viewModel.setCustomField(index, it, value) },
onValue = { viewModel.setCustomField(index, key, it) },
onRemove = { viewModel.removeCustomField(index) },
)
}
TextButton(onClick = viewModel::addCustomField) {
Icon(Icons.Filled.Add, contentDescription = null)
Text("Feld hinzufügen", modifier = Modifier.padding(start = 8.dp))
}
Button(
onClick = { scope.launch { viewModel.save()?.let(onSaved) } },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) { Text("Speichern") }
}
}
}
@Composable
private fun field(
label: String,
value: String,
minLines: Int = 1,
onChange: (String) -> Unit,
) {
OutlinedTextField(
value = value,
onValueChange = onChange,
label = { Text(label) },
singleLine = minLines == 1,
minLines = minLines,
modifier = Modifier.fillMaxWidth(),
)
}
@Composable
private fun KeyValueRow(
key: String,
value: String,
keyLabel: String,
valueLabel: String,
onKey: (String) -> Unit,
onValue: (String) -> Unit,
onRemove: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = key, onValueChange = onKey,
label = { Text(keyLabel) }, singleLine = true,
modifier = Modifier.width(120.dp),
)
OutlinedTextField(
value = value, onValueChange = onValue,
label = { Text(valueLabel) }, singleLine = true,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onRemove) {
Icon(Icons.Filled.Close, contentDescription = "Entfernen")
}
}
}

View file

@ -0,0 +1,181 @@
package com.umt.tracker.ui.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.umt.tracker.Graph
import com.umt.tracker.data.LibraryRepository
import com.umt.tracker.domain.MediaItem
import com.umt.tracker.domain.MediaType
import com.umt.tracker.domain.ProgressUnit
import com.umt.tracker.domain.Segment
import com.umt.tracker.domain.WatchStatus
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* Editable form mirror of a [MediaItem]. Collection fields are held as separate
* editable rows (genres/tags as one comma string; localized titles and custom
* fields as key/value pairs) and folded back into a MediaItem on save.
*/
data class EditFormState(
val id: Long = 0,
val type: MediaType = MediaType.MOVIE,
val title: String = "",
val originalTitle: String = "",
val yearText: String = "",
val status: WatchStatus = WatchStatus.PLAN_TO_WATCH,
val rating: Int = 0,
val favorite: Boolean = false,
val genresText: String = "",
val tagsText: String = "",
val franchise: String = "",
val coverUrl: String = "",
val overview: String = "",
val notes: String = "",
val segments: List<Segment> = emptyList(),
val localizedTitles: List<Pair<String, String>> = emptyList(),
val customFields: List<Pair<String, String>> = emptyList(),
val externalId: String = "",
val externalSource: String = "",
val dateAdded: String = "",
val dateCompleted: String = "",
val loading: Boolean = true,
) {
val isNew: Boolean get() = id == 0L
}
class EditViewModel(
private val repo: LibraryRepository,
private val itemId: Long,
) : ViewModel() {
private val _form = MutableStateFlow(EditFormState())
val form: StateFlow<EditFormState> = _form.asStateFlow()
init {
viewModelScope.launch {
val existing = if (itemId > 0) repo.getItem(itemId) else null
val draft = if (itemId <= 0) Graph.editDraft else null
Graph.editDraft = null
_form.value = (existing ?: draft)?.toForm() ?: EditFormState(loading = false)
}
}
fun update(transform: (EditFormState) -> EditFormState) {
_form.value = transform(_form.value)
}
// -- segment helpers ---------------------------------------------------------
fun addSegment() = update { s ->
val nextNum = (s.segments.maxOfOrNull { it.number } ?: 0) + 1
s.copy(segments = s.segments + Segment(number = nextNum, units = emptyList()))
}
fun removeSegment(number: Int) = update { s ->
s.copy(segments = s.segments.filterNot { it.number == number })
}
fun setSegmentTitle(number: Int, title: String) = update { s ->
s.copy(segments = s.segments.map { if (it.number == number) it.copy(title = title) else it })
}
/** Grows or shrinks a segment's unit list, preserving the watched flags kept. */
fun setUnitCount(number: Int, count: Int) = update { s ->
s.copy(segments = s.segments.map { seg ->
if (seg.number != number) seg
else {
val target = count.coerceIn(0, 999)
val units = when {
target <= seg.units.size -> seg.units.take(target)
else -> seg.units + (seg.units.size + 1..target).map { ProgressUnit(number = it) }
}
seg.copy(units = units)
}
})
}
// -- localized titles / custom fields ---------------------------------------
fun addLocalizedTitle() = update { it.copy(localizedTitles = it.localizedTitles + ("" to "")) }
fun removeLocalizedTitle(index: Int) = update { it.copy(localizedTitles = it.localizedTitles.filterIndexed { i, _ -> i != index }) }
fun setLocalizedTitle(index: Int, key: String, value: String) = update {
it.copy(localizedTitles = it.localizedTitles.mapIndexed { i, p -> if (i == index) key to value else p })
}
fun addCustomField() = update { it.copy(customFields = it.customFields + ("" to "")) }
fun removeCustomField(index: Int) = update { it.copy(customFields = it.customFields.filterIndexed { i, _ -> i != index }) }
fun setCustomField(index: Int, key: String, value: String) = update {
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? {
val s = _form.value
if (s.title.isBlank()) return null
return repo.save(s.toMediaItem())
}
private fun EditFormState.toMediaItem(): MediaItem = MediaItem(
id = id,
type = type,
title = title.trim(),
originalTitle = originalTitle.trim(),
localizedTitles = localizedTitles.filter { it.first.isNotBlank() }.toMap(),
coverUrl = coverUrl.trim(),
genres = genresText.splitList(),
tags = tagsText.splitList(),
franchise = franchise.trim(),
status = status,
rating = rating,
favorite = favorite,
year = yearText.trim().toIntOrNull() ?: 0,
overview = overview.trim(),
notes = notes.trim(),
segments = segments,
customFields = customFields.filter { it.first.isNotBlank() }.toMap(),
dateAdded = dateAdded,
dateCompleted = dateCompleted,
externalId = externalId,
externalSource = externalSource,
)
private fun MediaItem.toForm(): EditFormState = EditFormState(
id = id,
type = type,
title = title,
originalTitle = originalTitle,
yearText = if (year > 0) year.toString() else "",
status = status,
rating = rating,
favorite = favorite,
genresText = genres.joinToString(", "),
tagsText = tags.joinToString(", "),
franchise = franchise,
coverUrl = coverUrl,
overview = overview,
notes = notes,
segments = segments,
localizedTitles = localizedTitles.toList(),
customFields = customFields.toList(),
externalId = externalId,
externalSource = externalSource,
dateAdded = dateAdded,
dateCompleted = dateCompleted,
loading = false,
)
private fun String.splitList(): List<String> =
split(',', ';').map { it.trim() }.filter { it.isNotEmpty() }
companion object {
fun factory(itemId: Long) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
EditViewModel(Graph.repository, itemId) as T
}
}
}

View file

@ -5,38 +5,57 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
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.FilterList
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@ -45,6 +64,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.umt.tracker.domain.MediaType
import com.umt.tracker.domain.WatchStatus
import com.umt.tracker.domain.label
import com.umt.tracker.domain.labelPlural
import com.umt.tracker.ui.components.MediaCard
@ -52,6 +73,8 @@ import com.umt.tracker.ui.components.MediaCard
@Composable
fun LibraryScreen(
onItemClick: (Long) -> Unit,
onAddItem: () -> Unit,
onOpenStats: () -> Unit,
onOpenSettings: () -> Unit,
viewModel: LibraryViewModel = viewModel(factory = LibraryViewModel.Factory),
) {
@ -59,6 +82,7 @@ fun LibraryScreen(
val message by viewModel.messages.collectAsStateWithLifecycle()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
var showFilters by remember { mutableStateOf(false) }
// System file picker → hand the chosen JSON to the view model for import.
val picker = rememberLauncherForActivityResult(
@ -78,6 +102,11 @@ fun LibraryScreen(
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = {
FloatingActionButton(onClick = onAddItem) {
Icon(Icons.Filled.Add, contentDescription = "Eintrag hinzufügen")
}
},
topBar = {
TopAppBar(
title = { Text("Media Tracker") },
@ -87,6 +116,18 @@ fun LibraryScreen(
Icon(Icons.Filled.Sync, contentDescription = "Jetzt synchronisieren")
}
}
IconButton(onClick = { showFilters = true }) {
BadgedBox(badge = {
if (state.filter.activeCount > 0) {
Badge { Text(state.filter.activeCount.toString()) }
}
}) {
Icon(Icons.Filled.FilterList, contentDescription = "Filter & Sortierung")
}
}
IconButton(onClick = onOpenStats) {
Icon(Icons.Filled.BarChart, contentDescription = "Statistik")
}
IconButton(onClick = { picker.launch(arrayOf("application/json", "*/*")) }) {
Icon(Icons.Filled.FileDownload, contentDescription = "Bibliothek importieren")
}
@ -142,6 +183,17 @@ fun LibraryScreen(
}
}
}
if (showFilters) {
FilterSheet(
filter = state.filter,
allGenres = state.allGenres,
allFranchises = state.allFranchises,
onChange = viewModel::updateFilter,
onClear = viewModel::clearFilters,
onDismiss = { showFilters = false },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@ -194,3 +246,133 @@ private fun EmptyState(onImport: () -> Unit) {
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun FilterSheet(
filter: LibraryFilter,
allGenres: List<String>,
allFranchises: List<String>,
onChange: ((LibraryFilter) -> LibraryFilter) -> Unit,
onClear: () -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.navigationBarsPadding()
.padding(horizontal = 16.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Filter & Sortierung",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onClear) { Text("Zurücksetzen") }
}
SectionTitle("Sortieren nach")
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SortMode.entries.forEach { mode ->
FilterChip(
selected = filter.sortMode == mode,
onClick = { onChange { it.copy(sortMode = mode) } },
label = { Text(mode.label) },
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Absteigend", modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurface)
Switch(
checked = filter.sortDescending,
onCheckedChange = { v -> onChange { it.copy(sortDescending = v) } },
)
}
HorizontalDivider()
SectionTitle("Status")
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = filter.status == null,
onClick = { onChange { it.copy(status = null) } },
label = { Text("Alle") },
)
WatchStatus.entries.forEach { st ->
FilterChip(
selected = filter.status == st,
onClick = { onChange { it.copy(status = if (it.status == st) null else st) } },
label = { Text(st.label) },
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Nur Favoriten", modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurface)
Switch(
checked = filter.favoritesOnly,
onCheckedChange = { v -> onChange { it.copy(favoritesOnly = v) } },
)
}
SectionTitle("Mindestbewertung: ${if (filter.minRating > 0) filter.minRating else ""}")
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(0, 2, 4, 6, 8, 10).forEach { r ->
FilterChip(
selected = filter.minRating == r,
onClick = { onChange { it.copy(minRating = r) } },
label = { Text(if (r == 0) "Alle" else "$r+") },
)
}
}
if (allGenres.isNotEmpty()) {
SectionTitle("Genres")
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
allGenres.forEach { g ->
FilterChip(
selected = g in filter.genres,
onClick = {
onChange {
val next = if (g in it.genres) it.genres - g else it.genres + g
it.copy(genres = next)
}
},
label = { Text(g) },
)
}
}
}
if (allFranchises.isNotEmpty()) {
SectionTitle("Reihe / Franchise")
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
allFranchises.forEach { f ->
FilterChip(
selected = filter.franchise == f,
onClick = { onChange { it.copy(franchise = if (it.franchise == f) null else f) } },
label = { Text(f) },
)
}
}
}
}
}
}
@Composable
private fun SectionTitle(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}

View file

@ -12,6 +12,7 @@ import com.umt.tracker.data.sync.SyncResult
import com.umt.tracker.data.sync.SyncSettings
import com.umt.tracker.domain.MediaItem
import com.umt.tracker.domain.MediaType
import com.umt.tracker.domain.WatchStatus
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -19,10 +20,41 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/** Sort options for the library grid, mirroring the desktop sort modes. */
enum class SortMode(val label: String) {
DATE_ADDED("Hinzugefügt"),
TITLE("Titel"),
RATING("Bewertung"),
YEAR("Jahr"),
PROGRESS("Fortschritt"),
}
/** Active filter/sort selection, mirroring the desktop FilterCriteria. */
data class LibraryFilter(
val status: WatchStatus? = null,
val favoritesOnly: Boolean = false,
val genres: Set<String> = emptySet(),
val franchise: String? = null,
val minRating: Int = 0,
val sortMode: SortMode = SortMode.DATE_ADDED,
val sortDescending: Boolean = true,
) {
/** Number of active narrowing filters (sort excluded), for a badge. */
val activeCount: Int
get() = (if (status != null) 1 else 0) +
(if (favoritesOnly) 1 else 0) +
genres.size +
(if (franchise != null) 1 else 0) +
(if (minRating > 0) 1 else 0)
}
data class LibraryUiState(
val items: List<MediaItem> = emptyList(),
val query: String = "",
val typeFilter: MediaType? = null,
val filter: LibraryFilter = LibraryFilter(),
val allGenres: List<String> = emptyList(),
val allFranchises: List<String> = emptyList(),
val loading: Boolean = true,
val syncEnabled: Boolean = false,
)
@ -35,21 +67,29 @@ class LibraryViewModel(
private val query = MutableStateFlow("")
private val typeFilter = MutableStateFlow<MediaType?>(null)
private val filter = MutableStateFlow(LibraryFilter())
/** One-shot messages (e.g. import result) for a snackbar. */
val messages = MutableStateFlow<String?>(null)
val uiState: StateFlow<LibraryUiState> =
combine(repo.observeLibrary(), query, typeFilter) { items, q, type ->
val filtered = items.filter { item ->
(type == null || item.type == type) &&
(q.isBlank() || item.title.contains(q, ignoreCase = true) ||
item.originalTitle.contains(q, ignoreCase = true))
}
combine(repo.observeLibrary(), query, typeFilter, filter) { items, q, type, f ->
val filtered = items
.filter { it.matches(q, type, f) }
.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 }
.filter { it.isNotBlank() }
.groupingBy { it }.eachCount()
.filterValues { it >= 2 }.keys.sorted()
LibraryUiState(
items = filtered,
query = q,
typeFilter = type,
filter = f,
allGenres = genres,
allFranchises = franchises,
loading = false,
syncEnabled = syncSettings.isCloud && syncSettings.isConfigured,
)
@ -59,6 +99,30 @@ class LibraryViewModel(
initialValue = LibraryUiState(),
)
private fun MediaItem.matches(q: String, type: MediaType?, f: LibraryFilter): Boolean {
if (type != null && this.type != type) return false
if (q.isNotBlank() && !title.contains(q, true) &&
!originalTitle.contains(q, true) && !overview.contains(q, true)
) return false
if (f.status != null && status != f.status) return false
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
return true
}
private fun LibraryFilter.comparator(): Comparator<MediaItem> {
val base: Comparator<MediaItem> = when (sortMode) {
SortMode.TITLE -> compareBy { it.title.lowercase() }
SortMode.RATING -> compareBy { it.rating }
SortMode.YEAR -> compareBy { it.year }
SortMode.PROGRESS -> compareBy { it.progress }
SortMode.DATE_ADDED -> compareBy { it.dateAdded }
}
return if (sortDescending) base.reversed() else base
}
fun onQueryChange(value: String) {
query.value = value
}
@ -67,6 +131,17 @@ class LibraryViewModel(
typeFilter.value = type
}
fun updateFilter(transform: (LibraryFilter) -> LibraryFilter) {
filter.value = transform(filter.value)
}
fun clearFilters() {
filter.value = LibraryFilter(
sortMode = filter.value.sortMode,
sortDescending = filter.value.sortDescending,
)
}
fun import(uri: Uri, openStream: (Uri) -> java.io.InputStream?) {
viewModelScope.launch {
val stream = openStream(uri)

View file

@ -0,0 +1,183 @@
package com.umt.tracker.ui.search
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.umt.tracker.domain.MediaType
import com.umt.tracker.domain.labelPlural
import com.umt.tracker.providers.SearchResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchScreen(
type: MediaType,
onBack: () -> Unit,
onPicked: () -> Unit,
viewModel: SearchViewModel = viewModel(factory = SearchViewModel.factory(type)),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("${type.labelPlural} suchen") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
titleContentColor = MaterialTheme.colorScheme.onBackground,
),
)
},
) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
OutlinedTextField(
value = state.query,
onValueChange = viewModel::onQueryChange,
singleLine = true,
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
placeholder = { Text("Titel eingeben…") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { viewModel.search() }),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
)
when {
state.loading -> Box(Modifier.fillMaxSize(), Alignment.Center) {
CircularProgressIndicator()
}
state.error != null -> CenteredMessage(state.error!!)
state.hint.isNotBlank() && !state.searched -> CenteredMessage(state.hint)
state.results.isEmpty() && state.searched ->
CenteredMessage("Keine Ergebnisse gefunden.")
state.results.isEmpty() ->
CenteredMessage("Gib einen Titel ein und tippe auf Suchen.")
else -> LazyColumn(
contentPadding = PaddingValues(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize(),
) {
items(state.results) { result ->
ResultRow(result) { viewModel.choose(result, onPicked) }
}
}
}
}
}
}
@Composable
private fun ResultRow(result: SearchResult, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface)
.clickable(onClick = onClick)
.padding(8.dp),
) {
Box(
modifier = Modifier
.width(60.dp)
.height(90.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (result.coverUrl.isNotBlank()) {
AsyncImage(
model = result.coverUrl,
contentDescription = result.title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
Column(modifier = Modifier.padding(start = 12.dp).weight(1f)) {
Text(
text = result.title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
val sub = listOfNotNull(
result.year.takeIf { it > 0 }?.toString(),
result.source.ifBlank { null },
).joinToString(" · ")
if (sub.isNotBlank()) {
Text(
text = sub,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (result.overview.isNotBlank()) {
Text(
text = result.overview,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
}
@Composable
private fun CenteredMessage(text: String) {
Box(Modifier.fillMaxSize().padding(32.dp), Alignment.Center) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View file

@ -0,0 +1,79 @@
package com.umt.tracker.ui.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.umt.tracker.Graph
import com.umt.tracker.providers.ProviderManager
import com.umt.tracker.providers.SearchOutcome
import com.umt.tracker.providers.SearchResult
import com.umt.tracker.providers.toMediaItem
import com.umt.tracker.domain.MediaType
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class SearchUiState(
val type: MediaType = MediaType.MOVIE,
val query: String = "",
val results: List<SearchResult> = emptyList(),
val loading: Boolean = false,
val error: String? = null,
val hint: String = "",
val searched: Boolean = false,
)
/**
* Drives the online metadata search for a single [MediaType]. On select it
* enriches the chosen hit (e.g. TMDB series seasons), stashes the resulting draft
* item in [Graph.editDraft], and hands its type back so navigation can open the
* edit screen pre-filled.
*/
class SearchViewModel(
private val providers: ProviderManager,
type: MediaType,
) : ViewModel() {
private val _state = MutableStateFlow(
SearchUiState(type = type, hint = if (providers.isConfigured(type)) "" else providers.configHint(type)),
)
val state: StateFlow<SearchUiState> = _state.asStateFlow()
fun onQueryChange(value: String) {
_state.value = _state.value.copy(query = value)
}
fun search() {
val s = _state.value
if (s.query.isBlank() || s.loading) return
_state.value = s.copy(loading = true, error = null)
viewModelScope.launch {
_state.value = when (val outcome = providers.search(s.query.trim(), s.type)) {
is SearchOutcome.Success ->
_state.value.copy(loading = false, results = outcome.results, searched = true)
is SearchOutcome.Error ->
_state.value.copy(loading = false, error = outcome.message, results = emptyList(), searched = true)
}
}
}
/** Enriches and stages the chosen result; invokes [onReady] with its id (0 = new). */
fun choose(result: SearchResult, onReady: () -> Unit) {
_state.value = _state.value.copy(loading = true)
viewModelScope.launch {
val full = providers.fetchDetails(result)
Graph.editDraft = full.toMediaItem()
_state.value = _state.value.copy(loading = false)
onReady()
}
}
companion object {
fun factory(type: MediaType) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
SearchViewModel(Graph.providers, type) as T
}
}
}

View file

@ -20,9 +20,11 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@ -31,6 +33,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
@ -98,6 +101,17 @@ fun SettingsScreen(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("Darstellung", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Dunkles Design", modifier = Modifier.weight(1f))
Switch(
checked = state.darkMode,
onCheckedChange = viewModel::onDarkMode,
)
}
HorizontalDivider()
Text("Speicherort", style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
@ -144,6 +158,43 @@ fun SettingsScreen(
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider()
Text("Metadaten-Provider", style = MaterialTheme.typography.titleMedium)
Text(
"Optionale eigene API-Schlüssel. TMDB (Filme/Serien) wird nur benötigt, " +
"wenn kein Proxy-Server gesetzt ist. RAWG ist für die Spiele-Suche nötig " +
"(kostenlos über rawg.io/apidocs). Bücher (OpenLibrary) und Manga (AniList) " +
"brauchen keinen Schlüssel.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = state.tmdbApiKey,
onValueChange = viewModel::onTmdbApiKey,
label = { Text("TMDB API-Key") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.rawgApiKey,
onValueChange = viewModel::onRawgApiKey,
label = { Text("RAWG API-Key") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.language,
onValueChange = viewModel::onLanguage,
label = { Text("Sprache der Metadaten (z. B. de)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider()
if (state.storageMode == StorageMode.CLOUD) {
Text(
"Deine Bibliothek wird Ende-zu-Ende verschlüsselt. Die Passphrase " +

View file

@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.umt.tracker.Graph
import com.umt.tracker.data.AppPrefs
import com.umt.tracker.data.sync.StorageMode
import com.umt.tracker.data.sync.SyncClient
import com.umt.tracker.data.sync.SyncResult
@ -20,6 +21,10 @@ data class SettingsUiState(
val bearerToken: String = "",
val passphrase: String = "",
val revision: Long = 0,
val darkMode: Boolean = true,
val tmdbApiKey: String = "",
val rawgApiKey: String = "",
val language: String = "de",
val busy: Boolean = false,
val message: String? = null,
/** Set when a push hit a 409; the UI offers overwrite vs. pull. */
@ -29,6 +34,7 @@ data class SettingsUiState(
class SettingsViewModel(
private val settings: SyncSettings,
private val client: SyncClient,
private val prefs: AppPrefs,
) : ViewModel() {
private val _state = MutableStateFlow(
@ -38,6 +44,10 @@ class SettingsViewModel(
bearerToken = settings.bearerToken,
passphrase = settings.passphrase,
revision = settings.revision,
darkMode = prefs.darkMode.value,
tmdbApiKey = prefs.tmdbApiKey,
rawgApiKey = prefs.rawgApiKey,
language = prefs.language,
)
)
val state: StateFlow<SettingsUiState> = _state.asStateFlow()
@ -62,6 +72,26 @@ class SettingsViewModel(
_state.update { it.copy(passphrase = value) }
}
fun onDarkMode(value: Boolean) {
prefs.setDarkMode(value)
_state.update { it.copy(darkMode = value) }
}
fun onTmdbApiKey(value: String) {
prefs.tmdbApiKey = value
_state.update { it.copy(tmdbApiKey = value) }
}
fun onRawgApiKey(value: String) {
prefs.rawgApiKey = value
_state.update { it.copy(rawgApiKey = value) }
}
fun onLanguage(value: String) {
prefs.language = value
_state.update { it.copy(language = value) }
}
fun pull() = run { client.pull() }
fun push() = run { client.push(force = false) }
@ -94,7 +124,7 @@ class SettingsViewModel(
val Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
SettingsViewModel(Graph.syncSettings, Graph.syncClient) as T
SettingsViewModel(Graph.syncSettings, Graph.syncClient, Graph.appPrefs) as T
}
}
}

View file

@ -0,0 +1,161 @@
package com.umt.tracker.ui.stats
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.umt.tracker.data.LibraryStats
import com.umt.tracker.domain.MediaType
import com.umt.tracker.domain.accentArgb
import com.umt.tracker.domain.labelPlural
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StatsScreen(
onBack: () -> Unit,
viewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory),
) {
val stats by viewModel.stats.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Statistik") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
titleContentColor = MaterialTheme.colorScheme.onBackground,
),
)
},
) { padding ->
val current = stats
if (current == null) {
Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) {
CircularProgressIndicator()
}
} else {
StatsContent(current, Modifier.padding(padding))
}
}
}
@Composable
private fun StatsContent(stats: LibraryStats, modifier: Modifier = Modifier) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
SummaryCard("Gesamt", stats.total, Modifier.weight(1f))
SummaryCard("Favoriten", stats.favorites, Modifier.weight(1f))
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
SummaryCard("Abgeschlossen", stats.completed, Modifier.weight(1f))
SummaryCard("Aktiv", stats.inProgress, Modifier.weight(1f))
SummaryCard("Geplant", stats.planned, Modifier.weight(1f))
}
}
item {
Text(
text = "Nach Typ",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(top = 8.dp),
)
}
items(MediaType.entries.toList()) { type ->
TypeRow(type, stats.perType[type] ?: 0)
}
}
}
@Composable
private fun SummaryCard(label: String, value: Int, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(16.dp),
) {
Text(
text = value.toString(),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun TypeRow(type: MediaType, count: Int) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(12.dp)
.clip(RoundedCornerShape(3.dp))
.background(Color(type.accentArgb)),
)
Text(
text = type.labelPlural,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 10.dp).weight(1f),
)
Text(
text = count.toString(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
}
}

View file

@ -0,0 +1,30 @@
package com.umt.tracker.ui.stats
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
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.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
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() }
}
companion object {
val Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
StatsViewModel(Graph.repository) as T
}
}
}

View file

@ -12,8 +12,7 @@ navigation = "2.7.7"
room = "2.6.1"
serialization = "1.7.3"
coil = "2.7.0"
lazysodium = "5.1.0"
jna = "5.13.0"
bouncycastle = "1.78.1"
securityCrypto = "1.1.0-alpha06"
[libraries]
@ -35,8 +34,7 @@ androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref =
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
lazysodium-android = { group = "com.goterl", name = "lazysodium-android", version.ref = "lazysodium" }
jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" }
bouncycastle = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
[plugins]