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

View file

@ -1,10 +1,13 @@
package com.umt.tracker package com.umt.tracker
import android.content.Context import android.content.Context
import com.umt.tracker.data.AppPrefs
import com.umt.tracker.data.LibraryRepository import com.umt.tracker.data.LibraryRepository
import com.umt.tracker.data.local.UmtDatabase import com.umt.tracker.data.local.UmtDatabase
import com.umt.tracker.data.sync.SyncClient import com.umt.tracker.data.sync.SyncClient
import com.umt.tracker.data.sync.SyncSettings 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 * Tiny manual service locator. Keeps the scaffold dependency-injection-free while
@ -17,6 +20,17 @@ object Graph {
private set private set
lateinit var syncClient: SyncClient lateinit var syncClient: SyncClient
private set 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) { fun provide(context: Context) {
val app = context.applicationContext val app = context.applicationContext
@ -24,5 +38,7 @@ object Graph {
repository = LibraryRepository(app, db.mediaDao()) repository = LibraryRepository(app, db.mediaDao())
syncSettings = SyncSettings(app) syncSettings = SyncSettings(app)
syncClient = SyncClient(repository, syncSettings) 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.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.umt.tracker.ui.UmtNavHost import com.umt.tracker.ui.UmtNavHost
import com.umt.tracker.ui.theme.UmtTheme import com.umt.tracker.ui.theme.UmtTheme
@ -15,7 +17,8 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
UmtTheme { val darkTheme by Graph.appPrefs.darkMode.collectAsStateWithLifecycle()
UmtTheme(darkTheme = darkTheme) {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
UmtNavHost() 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.toDomain
import com.umt.tracker.data.local.toEntity import com.umt.tracker.data.local.toEntity
import com.umt.tracker.domain.MediaItem 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.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.InputStream import java.io.InputStream
import java.time.Instant
/** Outcome of a JSON import, surfaced to the UI for a confirmation message. */ /** Outcome of a JSON import, surfaced to the UI for a confirmation message. */
sealed interface ImportResult { sealed interface ImportResult {
@ -21,6 +24,16 @@ sealed interface ImportResult {
data class Failure(val message: String) : 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 * 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. * enters today is a JSON import produced by the desktop app.
@ -35,6 +48,97 @@ class LibraryRepository(
fun observeItem(id: Long): Flow<MediaItem?> = fun observeItem(id: Long): Flow<MediaItem?> =
dao.observeById(id).map { it?.toDomain() } 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 * Replaces the whole library with the contents of [stream]. Import is
* all-or-nothing: a parse error leaves the existing data untouched. * 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") @Query("SELECT * FROM media ORDER BY title COLLATE NOCASE")
suspend fun getAll(): List<MediaEntity> suspend fun getAll(): List<MediaEntity>
@Query("SELECT * FROM media WHERE id = :id")
suspend fun getById(id: Long): MediaEntity?
@Query("SELECT COUNT(*) FROM media") @Query("SELECT COUNT(*) FROM media")
suspend fun count(): Int suspend fun count(): Int
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(items: List<MediaEntity>) 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 @Update
suspend fun update(item: MediaEntity) suspend fun update(item: MediaEntity)
@Query("DELETE FROM media WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("DELETE FROM media") @Query("DELETE FROM media")
suspend fun clear() suspend fun clear()
} }

View file

@ -1,11 +1,13 @@
package com.umt.tracker.data.sync package com.umt.tracker.data.sync
import com.goterl.lazysodium.LazySodiumAndroid import org.bouncycastle.crypto.generators.Argon2BytesGenerator
import com.goterl.lazysodium.SodiumAndroid import org.bouncycastle.crypto.modes.ChaCha20Poly1305
import com.goterl.lazysodium.interfaces.PwHash import org.bouncycastle.crypto.params.AEADParameters
import com.sun.jna.NativeLong import org.bouncycastle.crypto.params.Argon2Parameters
import org.bouncycastle.crypto.params.KeyParameter
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.security.SecureRandom
/** /**
* Versioned, zero-knowledge encryption envelope shared byte-for-byte with the * 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. * KDF: Argon2id (libsodium crypto_pwhash, ALG 1.3) 32-byte key.
* AEAD: XChaCha20-Poly1305-IETF, no associated data. * 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 * 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 * 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. * exact layout and the Argon2id algorithm so either can decrypt the other's data.
*/ */
object CryptoEnvelope { object CryptoEnvelope {
private val sodium = LazySodiumAndroid(SodiumAndroid())
private val MAGIC = byteArrayOf('U'.code.toByte(), 'M'.code.toByte(), 'T'.code.toByte(), 'S'.code.toByte()) 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 VERSION: Byte = 1
private const val SALT_BYTES = 16 // crypto_pwhash_SALTBYTES private const val SALT_BYTES = 16 // crypto_pwhash_SALTBYTES
private const val NONCE_BYTES = 24 // crypto_aead_xchacha20poly1305_ietf_NPUBBYTES 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 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. // libsodium INTERACTIVE limits — a balance of security and phone CPU cost.
private const val OPSLIMIT = 2L // crypto_pwhash_OPSLIMIT_INTERACTIVE 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 const val HEADER_BYTES = 4 + 1 + 4 + 4 + SALT_BYTES + NONCE_BYTES
private val random = SecureRandom()
class CryptoException(message: String) : Exception(message) class CryptoException(message: String) : Exception(message)
/** Encrypts [plaintext] under [passphrase] and returns the full envelope. */ /** Encrypts [plaintext] under [passphrase] and returns the full envelope. */
fun encrypt(plaintext: ByteArray, passphrase: String): ByteArray { fun encrypt(plaintext: ByteArray, passphrase: String): ByteArray {
val salt = sodium.randomBytesBuf(SALT_BYTES) val salt = ByteArray(SALT_BYTES).also { random.nextBytes(it) }
val nonce = sodium.randomBytesBuf(NONCE_BYTES) val nonce = ByteArray(NONCE_BYTES).also { random.nextBytes(it) }
val key = deriveKey(passphrase, salt, OPSLIMIT, MEMLIMIT) val key = deriveKey(passphrase, salt, OPSLIMIT, MEMLIMIT)
val cipher = ByteArray(plaintext.size + ABYTES) val cipher = xChaChaAead(forEncryption = true, key = key, nonce = nonce, input = plaintext)
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")
return ByteBuffer.allocate(HEADER_BYTES + cipher.size).order(ByteOrder.BIG_ENDIAN).apply { return ByteBuffer.allocate(HEADER_BYTES + cipher.size).order(ByteOrder.BIG_ENDIAN).apply {
put(MAGIC) put(MAGIC)
@ -88,29 +92,94 @@ object CryptoEnvelope {
if (cipher.size < ABYTES) throw CryptoException("Daten unvollständig") if (cipher.size < ABYTES) throw CryptoException("Daten unvollständig")
val key = deriveKey(passphrase, salt, ops, mem) val key = deriveKey(passphrase, salt, ops, mem)
val plain = ByteArray(cipher.size - ABYTES) return try {
val plainLen = LongArray(1) xChaChaAead(forEncryption = false, key = key, nonce = nonce, input = cipher)
val ok = sodium.cryptoAeadXChaCha20Poly1305IetfDecrypt( } catch (e: Exception) {
plain, plainLen, throw CryptoException("Entschlüsselung fehlgeschlagen (falsche Passphrase?)")
null, }
cipher, cipher.size.toLong(), }
ByteArray(0), 0L,
nonce, key, /**
) * XChaCha20-Poly1305-IETF via Bouncy Castle: HChaCha20 derives a subkey from
if (!ok) throw CryptoException("Entschlüsselung fehlgeschlagen (falsche Passphrase?)") * the 32-byte key and the first 16 nonce bytes, then ChaCha20-Poly1305 (IETF)
return plain * 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 { 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 key = ByteArray(KEY_BYTES)
val ok = sodium.cryptoPwHash( generator.generateBytes(passphrase.toByteArray(Charsets.UTF_8), key)
key, KEY_BYTES,
pw, pw.size,
salt, ops, NativeLong(mem),
PwHash.Alg.PWHASH_ALG_ARGON2ID13,
)
if (!ok) throw CryptoException("Schlüsselableitung fehlgeschlagen")
return 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.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.umt.tracker.domain.MediaType
import com.umt.tracker.ui.detail.DetailScreen 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.library.LibraryScreen
import com.umt.tracker.ui.search.SearchScreen
import com.umt.tracker.ui.settings.SettingsScreen import com.umt.tracker.ui.settings.SettingsScreen
import com.umt.tracker.ui.stats.StatsScreen
private object Routes { private object Routes {
const val LIBRARY = "library" const val LIBRARY = "library"
const val DETAIL = "detail/{itemId}" const val DETAIL = "detail/{itemId}"
const val EDIT = "edit?itemId={itemId}"
const val SEARCH = "search/{type}"
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val STATS = "stats"
fun detail(id: Long) = "detail/$id" fun detail(id: Long) = "detail/$id"
fun edit(id: Long = 0) = "edit?itemId=$id"
fun search(type: MediaType) = "search/${type.name}"
} }
@Composable @Composable
@ -25,18 +34,57 @@ fun UmtNavHost() {
composable(Routes.LIBRARY) { composable(Routes.LIBRARY) {
LibraryScreen( LibraryScreen(
onItemClick = { id -> navController.navigate(Routes.detail(id)) }, onItemClick = { id -> navController.navigate(Routes.detail(id)) },
onAddItem = { navController.navigate(Routes.edit()) },
onOpenStats = { navController.navigate(Routes.STATS) },
onOpenSettings = { navController.navigate(Routes.SETTINGS) }, onOpenSettings = { navController.navigate(Routes.SETTINGS) },
) )
} }
composable(Routes.SETTINGS) { composable(Routes.SETTINGS) {
SettingsScreen(onBack = { navController.popBackStack() }) SettingsScreen(onBack = { navController.popBackStack() })
} }
composable(Routes.STATS) {
StatsScreen(onBack = { navController.popBackStack() })
}
composable( composable(
route = Routes.DETAIL, route = Routes.DETAIL,
arguments = listOf(navArgument("itemId") { type = NavType.LongType }), arguments = listOf(navArgument("itemId") { type = NavType.LongType }),
) { backStackEntry -> ) { backStackEntry ->
val id = backStackEntry.arguments?.getLong("itemId") ?: return@composable 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 package com.umt.tracker.ui.components
import androidx.compose.foundation.background 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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape 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.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -28,3 +45,64 @@ fun TagChip(
.padding(horizontal = 8.dp, vertical = 3.dp), .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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle 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.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore 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.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Star import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@ -30,12 +34,15 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -48,11 +55,14 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.umt.tracker.domain.MediaItem import com.umt.tracker.domain.MediaItem
import com.umt.tracker.domain.Segment import com.umt.tracker.domain.Segment
import com.umt.tracker.domain.WatchStatus
import com.umt.tracker.domain.accentArgb import com.umt.tracker.domain.accentArgb
import com.umt.tracker.domain.label import com.umt.tracker.domain.label
import com.umt.tracker.domain.labelSingular import com.umt.tracker.domain.labelSingular
import com.umt.tracker.domain.segmentLabel import com.umt.tracker.domain.segmentLabel
import com.umt.tracker.domain.unitLabel import com.umt.tracker.domain.unitLabel
import com.umt.tracker.ui.components.LabeledDropdown
import com.umt.tracker.ui.components.StarRatingInput
import com.umt.tracker.ui.components.TagChip import com.umt.tracker.ui.components.TagChip
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -60,9 +70,12 @@ import com.umt.tracker.ui.components.TagChip
fun DetailScreen( fun DetailScreen(
itemId: Long, itemId: Long,
onBack: () -> Unit, onBack: () -> Unit,
onEdit: () -> Unit,
onDeleted: () -> Unit,
viewModel: DetailViewModel = viewModel(factory = DetailViewModel.factory(itemId)), viewModel: DetailViewModel = viewModel(factory = DetailViewModel.factory(itemId)),
) { ) {
val item by viewModel.item.collectAsStateWithLifecycle() val item by viewModel.item.collectAsStateWithLifecycle()
var confirmDelete by remember { mutableStateOf(false) }
Scaffold( Scaffold(
topBar = { topBar = {
@ -73,6 +86,22 @@ fun DetailScreen(
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") 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( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
titleContentColor = MaterialTheme.colorScheme.onBackground, titleContentColor = MaterialTheme.colorScheme.onBackground,
@ -86,13 +115,34 @@ fun DetailScreen(
Text("Nicht gefunden", color = MaterialTheme.colorScheme.onSurfaceVariant) Text("Nicht gefunden", color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} else { } 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 @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. // Seasons/volumes start collapsed for overview, mirroring the desktop app.
val expanded = remember { mutableStateMapOf<Int, Boolean>() } val expanded = remember { mutableStateMapOf<Int, Boolean>() }
@ -148,24 +198,34 @@ private fun DetailContent(item: MediaItem, modifier: Modifier = Modifier) {
modifier = Modifier.padding(top = 8.dp), 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) { if (item.hasSegments) {
item { item {
Column { Column {
@ -189,6 +249,10 @@ private fun DetailContent(item: MediaItem, modifier: Modifier = Modifier) {
typeUnitLabel = item.type.unitLabel, typeUnitLabel = item.type.unitLabel,
expanded = expanded[segment.number] == true, expanded = expanded[segment.number] == true,
onToggle = { expanded[segment.number] = !(expanded[segment.number] ?: false) }, 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, typeUnitLabel: String,
expanded: Boolean, expanded: Boolean,
onToggle: () -> Unit, onToggle: () -> Unit,
onToggleSegment: (Boolean) -> Unit,
onToggleUnit: (Int, Boolean) -> Unit,
) { ) {
val allWatched = segment.units.isNotEmpty() && segment.units.all { it.watched }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -252,7 +319,17 @@ private fun SegmentRow(
.padding(12.dp), .padding(12.dp),
verticalAlignment = Alignment.CenterVertically, 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}" } val name = segment.title.ifBlank { "$typeSegmentLabel ${segment.number}" }
Text( Text(
text = name, text = name,
@ -278,6 +355,7 @@ private fun SegmentRow(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onToggleUnit(unit.number, !unit.watched) }
.padding(horizontal = 12.dp, vertical = 6.dp), .padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {

View file

@ -6,11 +6,16 @@ import androidx.lifecycle.viewModelScope
import com.umt.tracker.Graph import com.umt.tracker.Graph
import com.umt.tracker.data.LibraryRepository import com.umt.tracker.data.LibraryRepository
import com.umt.tracker.domain.MediaItem import com.umt.tracker.domain.MediaItem
import com.umt.tracker.domain.WatchStatus
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn 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?> = val item: StateFlow<MediaItem?> =
repo.observeItem(itemId).stateIn( repo.observeItem(itemId).stateIn(
@ -19,6 +24,27 @@ class DetailViewModel(repo: LibraryRepository, itemId: Long) : ViewModel() {
initialValue = null, 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 { companion object {
fun factory(itemId: Long) = object : ViewModelProvider.Factory { fun factory(itemId: Long) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding 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.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons 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.FileDownload
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Sync 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.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -45,6 +64,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.umt.tracker.domain.MediaType 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.domain.labelPlural
import com.umt.tracker.ui.components.MediaCard import com.umt.tracker.ui.components.MediaCard
@ -52,6 +73,8 @@ import com.umt.tracker.ui.components.MediaCard
@Composable @Composable
fun LibraryScreen( fun LibraryScreen(
onItemClick: (Long) -> Unit, onItemClick: (Long) -> Unit,
onAddItem: () -> Unit,
onOpenStats: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
viewModel: LibraryViewModel = viewModel(factory = LibraryViewModel.Factory), viewModel: LibraryViewModel = viewModel(factory = LibraryViewModel.Factory),
) { ) {
@ -59,6 +82,7 @@ fun LibraryScreen(
val message by viewModel.messages.collectAsStateWithLifecycle() val message by viewModel.messages.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
var showFilters by remember { mutableStateOf(false) }
// System file picker → hand the chosen JSON to the view model for import. // System file picker → hand the chosen JSON to the view model for import.
val picker = rememberLauncherForActivityResult( val picker = rememberLauncherForActivityResult(
@ -78,6 +102,11 @@ fun LibraryScreen(
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = {
FloatingActionButton(onClick = onAddItem) {
Icon(Icons.Filled.Add, contentDescription = "Eintrag hinzufügen")
}
},
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Media Tracker") }, title = { Text("Media Tracker") },
@ -87,6 +116,18 @@ fun LibraryScreen(
Icon(Icons.Filled.Sync, contentDescription = "Jetzt synchronisieren") 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", "*/*")) }) { IconButton(onClick = { picker.launch(arrayOf("application/json", "*/*")) }) {
Icon(Icons.Filled.FileDownload, contentDescription = "Bibliothek importieren") 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) @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.data.sync.SyncSettings
import com.umt.tracker.domain.MediaItem import com.umt.tracker.domain.MediaItem
import com.umt.tracker.domain.MediaType import com.umt.tracker.domain.MediaType
import com.umt.tracker.domain.WatchStatus
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -19,10 +20,41 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch 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( data class LibraryUiState(
val items: List<MediaItem> = emptyList(), val items: List<MediaItem> = emptyList(),
val query: String = "", val query: String = "",
val typeFilter: MediaType? = null, val typeFilter: MediaType? = null,
val filter: LibraryFilter = LibraryFilter(),
val allGenres: List<String> = emptyList(),
val allFranchises: List<String> = emptyList(),
val loading: Boolean = true, val loading: Boolean = true,
val syncEnabled: Boolean = false, val syncEnabled: Boolean = false,
) )
@ -35,21 +67,29 @@ class LibraryViewModel(
private val query = MutableStateFlow("") private val query = MutableStateFlow("")
private val typeFilter = MutableStateFlow<MediaType?>(null) private val typeFilter = MutableStateFlow<MediaType?>(null)
private val filter = MutableStateFlow(LibraryFilter())
/** One-shot messages (e.g. import result) for a snackbar. */ /** One-shot messages (e.g. import result) for a snackbar. */
val messages = MutableStateFlow<String?>(null) val messages = MutableStateFlow<String?>(null)
val uiState: StateFlow<LibraryUiState> = val uiState: StateFlow<LibraryUiState> =
combine(repo.observeLibrary(), query, typeFilter) { items, q, type -> combine(repo.observeLibrary(), query, typeFilter, filter) { items, q, type, f ->
val filtered = items.filter { item -> val filtered = items
(type == null || item.type == type) && .filter { it.matches(q, type, f) }
(q.isBlank() || item.title.contains(q, ignoreCase = true) || .sortedWith(f.comparator())
item.originalTitle.contains(q, ignoreCase = true)) // 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( LibraryUiState(
items = filtered, items = filtered,
query = q, query = q,
typeFilter = type, typeFilter = type,
filter = f,
allGenres = genres,
allFranchises = franchises,
loading = false, loading = false,
syncEnabled = syncSettings.isCloud && syncSettings.isConfigured, syncEnabled = syncSettings.isCloud && syncSettings.isConfigured,
) )
@ -59,6 +99,30 @@ class LibraryViewModel(
initialValue = LibraryUiState(), 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) { fun onQueryChange(value: String) {
query.value = value query.value = value
} }
@ -67,6 +131,17 @@ class LibraryViewModel(
typeFilter.value = type 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?) { fun import(uri: Uri, openStream: (Uri) -> java.io.InputStream?) {
viewModelScope.launch { viewModelScope.launch {
val stream = openStream(uri) 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.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@ -31,6 +33,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -98,6 +101,17 @@ fun SettingsScreen(
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(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) Text("Speicherort", style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip( FilterChip(
@ -144,6 +158,43 @@ fun SettingsScreen(
modifier = Modifier.fillMaxWidth(), 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) { if (state.storageMode == StorageMode.CLOUD) {
Text( Text(
"Deine Bibliothek wird Ende-zu-Ende verschlüsselt. Die Passphrase " + "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.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.umt.tracker.Graph import com.umt.tracker.Graph
import com.umt.tracker.data.AppPrefs
import com.umt.tracker.data.sync.StorageMode import com.umt.tracker.data.sync.StorageMode
import com.umt.tracker.data.sync.SyncClient import com.umt.tracker.data.sync.SyncClient
import com.umt.tracker.data.sync.SyncResult import com.umt.tracker.data.sync.SyncResult
@ -20,6 +21,10 @@ data class SettingsUiState(
val bearerToken: String = "", val bearerToken: String = "",
val passphrase: String = "", val passphrase: String = "",
val revision: Long = 0, val revision: Long = 0,
val darkMode: Boolean = true,
val tmdbApiKey: String = "",
val rawgApiKey: String = "",
val language: String = "de",
val busy: Boolean = false, val busy: Boolean = false,
val message: String? = null, val message: String? = null,
/** Set when a push hit a 409; the UI offers overwrite vs. pull. */ /** Set when a push hit a 409; the UI offers overwrite vs. pull. */
@ -29,6 +34,7 @@ data class SettingsUiState(
class SettingsViewModel( class SettingsViewModel(
private val settings: SyncSettings, private val settings: SyncSettings,
private val client: SyncClient, private val client: SyncClient,
private val prefs: AppPrefs,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow( private val _state = MutableStateFlow(
@ -38,6 +44,10 @@ class SettingsViewModel(
bearerToken = settings.bearerToken, bearerToken = settings.bearerToken,
passphrase = settings.passphrase, passphrase = settings.passphrase,
revision = settings.revision, revision = settings.revision,
darkMode = prefs.darkMode.value,
tmdbApiKey = prefs.tmdbApiKey,
rawgApiKey = prefs.rawgApiKey,
language = prefs.language,
) )
) )
val state: StateFlow<SettingsUiState> = _state.asStateFlow() val state: StateFlow<SettingsUiState> = _state.asStateFlow()
@ -62,6 +72,26 @@ class SettingsViewModel(
_state.update { it.copy(passphrase = value) } _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 pull() = run { client.pull() }
fun push() = run { client.push(force = false) } fun push() = run { client.push(force = false) }
@ -94,7 +124,7 @@ class SettingsViewModel(
val Factory = object : ViewModelProvider.Factory { val Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = 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" room = "2.6.1"
serialization = "1.7.3" serialization = "1.7.3"
coil = "2.7.0" coil = "2.7.0"
lazysodium = "5.1.0" bouncycastle = "1.78.1"
jna = "5.13.0"
securityCrypto = "1.1.0-alpha06" securityCrypto = "1.1.0-alpha06"
[libraries] [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" } 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" } 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" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
lazysodium-android = { group = "com.goterl", name = "lazysodium-android", version.ref = "lazysodium" } bouncycastle = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" }
jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
[plugins] [plugins]