diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 62e4b6c..512fa5d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -68,12 +68,9 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(libs.coil.compose) - // lazysodium-android pulls JNA as a plain jar; on Android we need the aar - // (it carries the native .so), so exclude the transitive jar and add the aar. - implementation(libs.lazysodium.android) { - exclude(group = "net.java.dev.jna", module = "jna") - } - implementation(libs.jna) { artifact { type = "aar" } } + // Bouncy Castle (pure Java) provides Argon2id + ChaCha20-Poly1305 with no + // native .so, so the app stays compatible with Android 15's 16 KB page size. + implementation(libs.bouncycastle) implementation(libs.androidx.security.crypto) debugImplementation(libs.androidx.ui.tooling) diff --git a/android/app/src/main/java/com/umt/tracker/Graph.kt b/android/app/src/main/java/com/umt/tracker/Graph.kt index 82fe125..a6c75ad 100644 --- a/android/app/src/main/java/com/umt/tracker/Graph.kt +++ b/android/app/src/main/java/com/umt/tracker/Graph.kt @@ -1,10 +1,13 @@ package com.umt.tracker import android.content.Context +import com.umt.tracker.data.AppPrefs import com.umt.tracker.data.LibraryRepository import com.umt.tracker.data.local.UmtDatabase import com.umt.tracker.data.sync.SyncClient import com.umt.tracker.data.sync.SyncSettings +import com.umt.tracker.domain.MediaItem +import com.umt.tracker.providers.ProviderManager /** * Tiny manual service locator. Keeps the scaffold dependency-injection-free while @@ -17,6 +20,17 @@ object Graph { private set lateinit var syncClient: SyncClient private set + lateinit var appPrefs: AppPrefs + private set + lateinit var providers: ProviderManager + private set + + /** + * One-shot hand-off of a pre-filled item from the online search into the edit + * screen. Navigation can only carry primitives, so the chosen result waits + * here until the edit screen consumes it. + */ + var editDraft: MediaItem? = null fun provide(context: Context) { val app = context.applicationContext @@ -24,5 +38,7 @@ object Graph { repository = LibraryRepository(app, db.mediaDao()) syncSettings = SyncSettings(app) syncClient = SyncClient(repository, syncSettings) + appPrefs = AppPrefs(app) + providers = ProviderManager(appPrefs, syncSettings) } } diff --git a/android/app/src/main/java/com/umt/tracker/MainActivity.kt b/android/app/src/main/java/com/umt/tracker/MainActivity.kt index 08fe34b..dc8f0e8 100644 --- a/android/app/src/main/java/com/umt/tracker/MainActivity.kt +++ b/android/app/src/main/java/com/umt/tracker/MainActivity.kt @@ -6,7 +6,9 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.umt.tracker.ui.UmtNavHost import com.umt.tracker.ui.theme.UmtTheme @@ -15,7 +17,8 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() super.onCreate(savedInstanceState) setContent { - UmtTheme { + val darkTheme by Graph.appPrefs.darkMode.collectAsStateWithLifecycle() + UmtTheme(darkTheme = darkTheme) { Surface(modifier = Modifier.fillMaxSize()) { UmtNavHost() } diff --git a/android/app/src/main/java/com/umt/tracker/data/AppPrefs.kt b/android/app/src/main/java/com/umt/tracker/data/AppPrefs.kt new file mode 100644 index 0000000..fb9aa1f --- /dev/null +++ b/android/app/src/main/java/com/umt/tracker/data/AppPrefs.kt @@ -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 = _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" + } +} diff --git a/android/app/src/main/java/com/umt/tracker/data/LibraryRepository.kt b/android/app/src/main/java/com/umt/tracker/data/LibraryRepository.kt index 3243874..c9685e6 100644 --- a/android/app/src/main/java/com/umt/tracker/data/LibraryRepository.kt +++ b/android/app/src/main/java/com/umt/tracker/data/LibraryRepository.kt @@ -9,11 +9,14 @@ import com.umt.tracker.data.local.MediaDao import com.umt.tracker.data.local.toDomain import com.umt.tracker.data.local.toEntity import com.umt.tracker.domain.MediaItem +import com.umt.tracker.domain.MediaType +import com.umt.tracker.domain.WatchStatus import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import java.io.InputStream +import java.time.Instant /** Outcome of a JSON import, surfaced to the UI for a confirmation message. */ sealed interface ImportResult { @@ -21,6 +24,16 @@ sealed interface ImportResult { data class Failure(val message: String) : ImportResult } +/** Aggregate counts shown in the statistics view, mirroring the desktop sidebar. */ +data class LibraryStats( + val total: Int = 0, + val completed: Int = 0, + val inProgress: Int = 0, + val planned: Int = 0, + val favorites: Int = 0, + val perType: Map = emptyMap(), +) + /** * Single source of truth for the library. Backed by Room; the only way data * enters today is a JSON import produced by the desktop app. @@ -35,6 +48,97 @@ class LibraryRepository( fun observeItem(id: Long): Flow = 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 = withContext(Dispatchers.IO) { + dao.getAll().flatMap { it.toDomain().genres }.distinct().sorted() + } + + suspend fun allTags(): List = 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 = withContext(Dispatchers.IO) { + dao.getAll().map { it.toDomain().franchise } + .filter { it.isNotBlank() } + .groupingBy { it }.eachCount() + .filterValues { it >= 2 }.keys.sorted() + } + + suspend fun stats(): LibraryStats = withContext(Dispatchers.IO) { + val items = dao.getAll().map { it.toDomain() } + LibraryStats( + total = items.size, + completed = items.count { it.status == WatchStatus.COMPLETED }, + inProgress = items.count { it.status == WatchStatus.IN_PROGRESS }, + planned = items.count { it.status == WatchStatus.PLAN_TO_WATCH }, + favorites = items.count { it.favorite }, + perType = items.groupingBy { it.type }.eachCount(), + ) + } + /** * Replaces the whole library with the contents of [stream]. Import is * all-or-nothing: a parse error leaves the existing data untouched. diff --git a/android/app/src/main/java/com/umt/tracker/data/local/MediaDao.kt b/android/app/src/main/java/com/umt/tracker/data/local/MediaDao.kt index d63b6e6..9a32ff0 100644 --- a/android/app/src/main/java/com/umt/tracker/data/local/MediaDao.kt +++ b/android/app/src/main/java/com/umt/tracker/data/local/MediaDao.kt @@ -19,15 +19,25 @@ interface MediaDao { @Query("SELECT * FROM media ORDER BY title COLLATE NOCASE") suspend fun getAll(): List + @Query("SELECT * FROM media WHERE id = :id") + suspend fun getById(id: Long): MediaEntity? + @Query("SELECT COUNT(*) FROM media") suspend fun count(): Int @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(items: List) + /** Inserts a new row (id = 0) or replaces an existing one; returns its id. */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(item: MediaEntity): Long + @Update suspend fun update(item: MediaEntity) + @Query("DELETE FROM media WHERE id = :id") + suspend fun deleteById(id: Long) + @Query("DELETE FROM media") suspend fun clear() } diff --git a/android/app/src/main/java/com/umt/tracker/data/sync/CryptoEnvelope.kt b/android/app/src/main/java/com/umt/tracker/data/sync/CryptoEnvelope.kt index f156006..9c11781 100644 --- a/android/app/src/main/java/com/umt/tracker/data/sync/CryptoEnvelope.kt +++ b/android/app/src/main/java/com/umt/tracker/data/sync/CryptoEnvelope.kt @@ -1,11 +1,13 @@ package com.umt.tracker.data.sync -import com.goterl.lazysodium.LazySodiumAndroid -import com.goterl.lazysodium.SodiumAndroid -import com.goterl.lazysodium.interfaces.PwHash -import com.sun.jna.NativeLong +import org.bouncycastle.crypto.generators.Argon2BytesGenerator +import org.bouncycastle.crypto.modes.ChaCha20Poly1305 +import org.bouncycastle.crypto.params.AEADParameters +import org.bouncycastle.crypto.params.Argon2Parameters +import org.bouncycastle.crypto.params.KeyParameter import java.nio.ByteBuffer import java.nio.ByteOrder +import java.security.SecureRandom /** * Versioned, zero-knowledge encryption envelope shared byte-for-byte with the @@ -20,21 +22,29 @@ import java.nio.ByteOrder * KDF: Argon2id (libsodium crypto_pwhash, ALG 1.3) → 32-byte key. * AEAD: XChaCha20-Poly1305-IETF, no associated data. * + * Implemented with the Bouncy Castle lightweight API (pure Java) so the app ships + * no native .so files — this keeps it compatible with Android 15's mandatory 16 KB + * memory page size, which lazysodium/JNA's prebuilt natives did not satisfy. + * + * libsodium's XChaCha20-Poly1305 is not packaged in Bouncy Castle, so it is built + * from the two primitives BC does provide: HChaCha20 derives a subkey from the + * passphrase key and the first 16 nonce bytes, then ChaCha20-Poly1305 (IETF, with + * a 12-byte nonce of four zero bytes followed by the remaining 8 nonce bytes) + * does the AEAD. This reproduces libsodium's construction exactly. + * * The opslimit/memlimit are stored in the header so a snapshot stays decryptable * even if the defaults below ever change. Both platforms must agree on this * exact layout and the Argon2id algorithm so either can decrypt the other's data. */ object CryptoEnvelope { - private val sodium = LazySodiumAndroid(SodiumAndroid()) - private val MAGIC = byteArrayOf('U'.code.toByte(), 'M'.code.toByte(), 'T'.code.toByte(), 'S'.code.toByte()) private const val VERSION: Byte = 1 private const val SALT_BYTES = 16 // crypto_pwhash_SALTBYTES private const val NONCE_BYTES = 24 // crypto_aead_xchacha20poly1305_ietf_NPUBBYTES private const val KEY_BYTES = 32 // crypto_aead_xchacha20poly1305_ietf_KEYBYTES - private const val ABYTES = 16 // crypto_aead_xchacha20poly1305_ietf_ABYTES + private const val ABYTES = 16 // crypto_aead_xchacha20poly1305_ietf_ABYTES (Poly1305 tag) // libsodium INTERACTIVE limits — a balance of security and phone CPU cost. private const val OPSLIMIT = 2L // crypto_pwhash_OPSLIMIT_INTERACTIVE @@ -42,23 +52,17 @@ object CryptoEnvelope { private const val HEADER_BYTES = 4 + 1 + 4 + 4 + SALT_BYTES + NONCE_BYTES + private val random = SecureRandom() + class CryptoException(message: String) : Exception(message) /** Encrypts [plaintext] under [passphrase] and returns the full envelope. */ fun encrypt(plaintext: ByteArray, passphrase: String): ByteArray { - val salt = sodium.randomBytesBuf(SALT_BYTES) - val nonce = sodium.randomBytesBuf(NONCE_BYTES) + val salt = ByteArray(SALT_BYTES).also { random.nextBytes(it) } + val nonce = ByteArray(NONCE_BYTES).also { random.nextBytes(it) } val key = deriveKey(passphrase, salt, OPSLIMIT, MEMLIMIT) - val cipher = ByteArray(plaintext.size + ABYTES) - val cipherLen = LongArray(1) - val ok = sodium.cryptoAeadXChaCha20Poly1305IetfEncrypt( - cipher, cipherLen, - plaintext, plaintext.size.toLong(), - ByteArray(0), 0L, - null, nonce, key, - ) - if (!ok) throw CryptoException("Verschlüsselung fehlgeschlagen") + val cipher = xChaChaAead(forEncryption = true, key = key, nonce = nonce, input = plaintext) return ByteBuffer.allocate(HEADER_BYTES + cipher.size).order(ByteOrder.BIG_ENDIAN).apply { put(MAGIC) @@ -88,29 +92,94 @@ object CryptoEnvelope { if (cipher.size < ABYTES) throw CryptoException("Daten unvollständig") val key = deriveKey(passphrase, salt, ops, mem) - val plain = ByteArray(cipher.size - ABYTES) - val plainLen = LongArray(1) - val ok = sodium.cryptoAeadXChaCha20Poly1305IetfDecrypt( - plain, plainLen, - null, - cipher, cipher.size.toLong(), - ByteArray(0), 0L, - nonce, key, - ) - if (!ok) throw CryptoException("Entschlüsselung fehlgeschlagen (falsche Passphrase?)") - return plain + return try { + xChaChaAead(forEncryption = false, key = key, nonce = nonce, input = cipher) + } catch (e: Exception) { + throw CryptoException("Entschlüsselung fehlgeschlagen (falsche Passphrase?)") + } + } + + /** + * XChaCha20-Poly1305-IETF via Bouncy Castle: HChaCha20 derives a subkey from + * the 32-byte key and the first 16 nonce bytes, then ChaCha20-Poly1305 (IETF) + * runs with a 12-byte nonce = 0x00000000 ‖ nonce[16:24]. + */ + private fun xChaChaAead(forEncryption: Boolean, key: ByteArray, nonce: ByteArray, input: ByteArray): ByteArray { + val subkey = hChaCha20(key, nonce.copyOfRange(0, 16)) + val chachaNonce = ByteArray(12) + System.arraycopy(nonce, 16, chachaNonce, 4, 8) + + val aead = ChaCha20Poly1305() + aead.init(forEncryption, AEADParameters(KeyParameter(subkey), ABYTES * 8, chachaNonce)) + val out = ByteArray(aead.getOutputSize(input.size)) + val written = aead.processBytes(input, 0, input.size, out, 0) + aead.doFinal(out, written) + return out } private fun deriveKey(passphrase: String, salt: ByteArray, ops: Long, mem: Long): ByteArray { - val pw = passphrase.toByteArray(Charsets.UTF_8) + val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) + .withVersion(Argon2Parameters.ARGON2_VERSION_13) + .withIterations(ops.toInt()) + .withMemoryAsKB((mem / 1024L).toInt()) + .withParallelism(1) + .withSalt(salt) + .build() + val generator = Argon2BytesGenerator().apply { init(params) } val key = ByteArray(KEY_BYTES) - val ok = sodium.cryptoPwHash( - key, KEY_BYTES, - pw, pw.size, - salt, ops, NativeLong(mem), - PwHash.Alg.PWHASH_ALG_ARGON2ID13, - ) - if (!ok) throw CryptoException("Schlüsselableitung fehlgeschlagen") + generator.generateBytes(passphrase.toByteArray(Charsets.UTF_8), key) return key } + + /** + * HChaCha20: maps a 32-byte key and a 16-byte input to a 32-byte subkey using + * the ChaCha20 permutation (10 double rounds) over the standard state, taking + * the first and last four words of the permuted state without the usual final + * addition. Matches RFC draft-irtf-cfrg-xchacha and libsodium. + */ + private fun hChaCha20(key: ByteArray, input: ByteArray): ByteArray { + val state = IntArray(16) + state[0] = 0x61707865 + state[1] = 0x3320646e + state[2] = 0x79622d32 + state[3] = 0x6b206574 + for (i in 0 until 8) state[4 + i] = leInt(key, i * 4) + for (i in 0 until 4) state[12 + i] = leInt(input, i * 4) + + repeat(10) { + quarterRound(state, 0, 4, 8, 12) + quarterRound(state, 1, 5, 9, 13) + quarterRound(state, 2, 6, 10, 14) + quarterRound(state, 3, 7, 11, 15) + quarterRound(state, 0, 5, 10, 15) + quarterRound(state, 1, 6, 11, 12) + quarterRound(state, 2, 7, 8, 13) + quarterRound(state, 3, 4, 9, 14) + } + + val out = ByteArray(32) + for (i in 0 until 4) putLeInt(out, i * 4, state[i]) + for (i in 0 until 4) putLeInt(out, 16 + i * 4, state[12 + i]) + return out + } + + private fun quarterRound(s: IntArray, a: Int, b: Int, c: Int, d: Int) { + s[a] += s[b]; s[d] = Integer.rotateLeft(s[d] xor s[a], 16) + s[c] += s[d]; s[b] = Integer.rotateLeft(s[b] xor s[c], 12) + s[a] += s[b]; s[d] = Integer.rotateLeft(s[d] xor s[a], 8) + s[c] += s[d]; s[b] = Integer.rotateLeft(s[b] xor s[c], 7) + } + + private fun leInt(b: ByteArray, off: Int): Int = + (b[off].toInt() and 0xFF) or + ((b[off + 1].toInt() and 0xFF) shl 8) or + ((b[off + 2].toInt() and 0xFF) shl 16) or + ((b[off + 3].toInt() and 0xFF) shl 24) + + private fun putLeInt(b: ByteArray, off: Int, v: Int) { + b[off] = (v and 0xFF).toByte() + b[off + 1] = ((v ushr 8) and 0xFF).toByte() + b[off + 2] = ((v ushr 16) and 0xFF).toByte() + b[off + 3] = ((v ushr 24) and 0xFF).toByte() + } } diff --git a/android/app/src/main/java/com/umt/tracker/providers/ProviderManager.kt b/android/app/src/main/java/com/umt/tracker/providers/ProviderManager.kt new file mode 100644 index 0000000..9de8595 --- /dev/null +++ b/android/app/src/main/java/com/umt/tracker/providers/ProviderManager.kt @@ -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 { + 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 = 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("
", "\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 = 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.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()) diff --git a/android/app/src/main/java/com/umt/tracker/providers/SearchResult.kt b/android/app/src/main/java/com/umt/tracker/providers/SearchResult.kt new file mode 100644 index 0000000..e6281da --- /dev/null +++ b/android/app/src/main/java/com/umt/tracker/providers/SearchResult.kt @@ -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 = emptyList(), + val localizedTitles: Map = emptyMap(), + val segments: List = emptyList(), + val externalRating: Double = 0.0, +) + +/** Result of a search request, surfaced to the UI. */ +sealed interface SearchOutcome { + data class Success(val results: List) : 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, +) diff --git a/android/app/src/main/java/com/umt/tracker/ui/UmtNavHost.kt b/android/app/src/main/java/com/umt/tracker/ui/UmtNavHost.kt index 2acdee3..2b80ceb 100644 --- a/android/app/src/main/java/com/umt/tracker/ui/UmtNavHost.kt +++ b/android/app/src/main/java/com/umt/tracker/ui/UmtNavHost.kt @@ -6,15 +6,24 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import com.umt.tracker.domain.MediaType import com.umt.tracker.ui.detail.DetailScreen +import com.umt.tracker.ui.edit.EditScreen import com.umt.tracker.ui.library.LibraryScreen +import com.umt.tracker.ui.search.SearchScreen import com.umt.tracker.ui.settings.SettingsScreen +import com.umt.tracker.ui.stats.StatsScreen private object Routes { const val LIBRARY = "library" const val DETAIL = "detail/{itemId}" + const val EDIT = "edit?itemId={itemId}" + const val SEARCH = "search/{type}" const val SETTINGS = "settings" + const val STATS = "stats" fun detail(id: Long) = "detail/$id" + fun edit(id: Long = 0) = "edit?itemId=$id" + fun search(type: MediaType) = "search/${type.name}" } @Composable @@ -25,18 +34,57 @@ fun UmtNavHost() { composable(Routes.LIBRARY) { LibraryScreen( onItemClick = { id -> navController.navigate(Routes.detail(id)) }, + onAddItem = { navController.navigate(Routes.edit()) }, + onOpenStats = { navController.navigate(Routes.STATS) }, onOpenSettings = { navController.navigate(Routes.SETTINGS) }, ) } composable(Routes.SETTINGS) { SettingsScreen(onBack = { navController.popBackStack() }) } + composable(Routes.STATS) { + StatsScreen(onBack = { navController.popBackStack() }) + } composable( route = Routes.DETAIL, arguments = listOf(navArgument("itemId") { type = NavType.LongType }), ) { backStackEntry -> val id = backStackEntry.arguments?.getLong("itemId") ?: return@composable - DetailScreen(itemId = id, onBack = { navController.popBackStack() }) + DetailScreen( + itemId = id, + onBack = { navController.popBackStack() }, + onEdit = { navController.navigate(Routes.edit(id)) }, + onDeleted = { navController.popBackStack() }, + ) + } + composable( + route = Routes.EDIT, + arguments = listOf(navArgument("itemId") { type = NavType.LongType; defaultValue = 0L }), + ) { backStackEntry -> + val id = backStackEntry.arguments?.getLong("itemId") ?: 0L + EditScreen( + itemId = id, + onBack = { navController.popBackStack() }, + onSaved = { navController.popBackStack() }, + onSearch = { type -> navController.navigate(Routes.search(type)) }, + ) + } + composable( + route = Routes.SEARCH, + arguments = listOf(navArgument("type") { type = NavType.StringType }), + ) { backStackEntry -> + val typeName = backStackEntry.arguments?.getString("type") ?: MediaType.MOVIE.name + val type = runCatching { MediaType.valueOf(typeName) }.getOrDefault(MediaType.MOVIE) + SearchScreen( + type = type, + onBack = { navController.popBackStack() }, + onPicked = { + // Replace the current edit (if any) with a fresh, draft-filled one. + navController.navigate(Routes.edit()) { + popUpTo(Routes.EDIT) { inclusive = true } + } + }, + ) } } } diff --git a/android/app/src/main/java/com/umt/tracker/ui/components/Common.kt b/android/app/src/main/java/com/umt/tracker/ui/components/Common.kt index 60023d1..7cdc738 100644 --- a/android/app/src/main/java/com/umt/tracker/ui/components/Common.kt +++ b/android/app/src/main/java/com/umt/tracker/ui/components/Common.kt @@ -1,11 +1,28 @@ package com.umt.tracker.ui.components import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.StarBorder +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -28,3 +45,64 @@ fun TagChip( .padding(horizontal = 8.dp, vertical = 3.dp), ) } + +/** A read-only text field that opens a dropdown of [options] when tapped. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LabeledDropdown( + label: String, + selected: T, + options: List, + 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) }, + ) + } + } +} diff --git a/android/app/src/main/java/com/umt/tracker/ui/detail/DetailScreen.kt b/android/app/src/main/java/com/umt/tracker/ui/detail/DetailScreen.kt index 35e5861..cc1a609 100644 --- a/android/app/src/main/java/com/umt/tracker/ui/detail/DetailScreen.kt +++ b/android/app/src/main/java/com/umt/tracker/ui/detail/DetailScreen.kt @@ -19,10 +19,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.RadioButtonUnchecked -import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -30,12 +34,15 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -48,11 +55,14 @@ import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.umt.tracker.domain.MediaItem import com.umt.tracker.domain.Segment +import com.umt.tracker.domain.WatchStatus import com.umt.tracker.domain.accentArgb import com.umt.tracker.domain.label import com.umt.tracker.domain.labelSingular import com.umt.tracker.domain.segmentLabel import com.umt.tracker.domain.unitLabel +import com.umt.tracker.ui.components.LabeledDropdown +import com.umt.tracker.ui.components.StarRatingInput import com.umt.tracker.ui.components.TagChip @OptIn(ExperimentalMaterial3Api::class) @@ -60,9 +70,12 @@ import com.umt.tracker.ui.components.TagChip fun DetailScreen( itemId: Long, onBack: () -> Unit, + onEdit: () -> Unit, + onDeleted: () -> Unit, viewModel: DetailViewModel = viewModel(factory = DetailViewModel.factory(itemId)), ) { val item by viewModel.item.collectAsStateWithLifecycle() + var confirmDelete by remember { mutableStateOf(false) } Scaffold( topBar = { @@ -73,6 +86,22 @@ fun DetailScreen( Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") } }, + actions = { + val fav = item?.favorite == true + IconButton(onClick = viewModel::toggleFavorite) { + Icon( + imageVector = if (fav) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, + contentDescription = "Favorit", + tint = if (fav) Color(0xFFFF375F) else MaterialTheme.colorScheme.onBackground, + ) + } + IconButton(onClick = onEdit) { + Icon(Icons.Filled.Edit, contentDescription = "Bearbeiten") + } + IconButton(onClick = { confirmDelete = true }) { + Icon(Icons.Filled.Delete, contentDescription = "Löschen") + } + }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.background, titleContentColor = MaterialTheme.colorScheme.onBackground, @@ -86,13 +115,34 @@ fun DetailScreen( Text("Nicht gefunden", color = MaterialTheme.colorScheme.onSurfaceVariant) } } else { - DetailContent(current, Modifier.padding(padding)) + DetailContent(current, viewModel, Modifier.padding(padding)) } } + + if (confirmDelete) { + AlertDialog( + onDismissRequest = { confirmDelete = false }, + title = { Text("Eintrag löschen?") }, + text = { Text("Dieser Eintrag wird dauerhaft entfernt.") }, + confirmButton = { + TextButton(onClick = { + confirmDelete = false + viewModel.delete(onDeleted) + }) { Text("Löschen") } + }, + dismissButton = { + TextButton(onClick = { confirmDelete = false }) { Text("Abbrechen") } + }, + ) + } } @Composable -private fun DetailContent(item: MediaItem, modifier: Modifier = Modifier) { +private fun DetailContent( + item: MediaItem, + viewModel: DetailViewModel, + modifier: Modifier = Modifier, +) { // Seasons/volumes start collapsed for overview, mirroring the desktop app. val expanded = remember { mutableStateMapOf() } @@ -148,24 +198,34 @@ private fun DetailContent(item: MediaItem, modifier: Modifier = Modifier) { modifier = Modifier.padding(top = 8.dp), ) } - if (item.rating > 0) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(top = 4.dp), - ) { - Icon(Icons.Filled.Star, null, tint = Color(0xFFFFD60A)) - Text( - text = "${item.rating} / 10", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 4.dp), - ) - } - } } } } + item { + LabeledDropdown( + label = "Status", + selected = item.status, + options = WatchStatus.entries, + optionLabel = { it.label }, + onSelect = viewModel::setStatus, + ) + } + + item { + Column { + Text( + text = "Bewertung", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + StarRatingInput( + rating = item.rating, + onRatingChange = viewModel::setRating, + ) + } + } + if (item.hasSegments) { item { Column { @@ -189,6 +249,10 @@ private fun DetailContent(item: MediaItem, modifier: Modifier = Modifier) { typeUnitLabel = item.type.unitLabel, expanded = expanded[segment.number] == true, onToggle = { expanded[segment.number] = !(expanded[segment.number] ?: false) }, + onToggleSegment = { watched -> viewModel.setSegmentWatched(segment.number, watched) }, + onToggleUnit = { unitNumber, watched -> + viewModel.setUnitWatched(segment.number, unitNumber, watched) + }, ) } } @@ -238,7 +302,10 @@ private fun SegmentRow( typeUnitLabel: String, expanded: Boolean, onToggle: () -> Unit, + onToggleSegment: (Boolean) -> Unit, + onToggleUnit: (Int, Boolean) -> Unit, ) { + val allWatched = segment.units.isNotEmpty() && segment.units.all { it.watched } Column( modifier = Modifier .fillMaxWidth() @@ -252,7 +319,17 @@ private fun SegmentRow( .padding(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.weight(1f)) { + // Tapping the leading icon flips the whole segment watched/unwatched. + Icon( + imageVector = if (allWatched) Icons.Filled.CheckCircle + else Icons.Filled.RadioButtonUnchecked, + contentDescription = "Alle markieren", + tint = if (allWatched) Color(0xFF30D158) else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(24.dp) + .clickable { onToggleSegment(!allWatched) }, + ) + Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) { val name = segment.title.ifBlank { "$typeSegmentLabel ${segment.number}" } Text( text = name, @@ -278,6 +355,7 @@ private fun SegmentRow( Row( modifier = Modifier .fillMaxWidth() + .clickable { onToggleUnit(unit.number, !unit.watched) } .padding(horizontal = 12.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/android/app/src/main/java/com/umt/tracker/ui/detail/DetailViewModel.kt b/android/app/src/main/java/com/umt/tracker/ui/detail/DetailViewModel.kt index 41d8b84..0a08aae 100644 --- a/android/app/src/main/java/com/umt/tracker/ui/detail/DetailViewModel.kt +++ b/android/app/src/main/java/com/umt/tracker/ui/detail/DetailViewModel.kt @@ -6,11 +6,16 @@ import androidx.lifecycle.viewModelScope import com.umt.tracker.Graph import com.umt.tracker.data.LibraryRepository import com.umt.tracker.domain.MediaItem +import com.umt.tracker.domain.WatchStatus import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch -class DetailViewModel(repo: LibraryRepository, itemId: Long) : ViewModel() { +class DetailViewModel( + private val repo: LibraryRepository, + private val itemId: Long, +) : ViewModel() { val item: StateFlow = repo.observeItem(itemId).stateIn( @@ -19,6 +24,27 @@ class DetailViewModel(repo: LibraryRepository, itemId: Long) : ViewModel() { initialValue = null, ) + fun toggleFavorite() = launch { repo.setFavorite(itemId, !(item.value?.favorite ?: false)) } + + fun setStatus(status: WatchStatus) = launch { repo.setStatus(itemId, status) } + + fun setRating(rating: Int) = launch { repo.setRating(itemId, rating) } + + fun setUnitWatched(segmentNumber: Int, unitNumber: Int, watched: Boolean) = + launch { repo.setUnitWatched(itemId, segmentNumber, unitNumber, watched) } + + fun setSegmentWatched(segmentNumber: Int, watched: Boolean) = + launch { repo.setSegmentWatched(itemId, segmentNumber, watched) } + + fun delete(onDone: () -> Unit) = launch { + repo.delete(itemId) + onDone() + } + + private inline fun launch(crossinline block: suspend () -> Unit) { + viewModelScope.launch { block() } + } + companion object { fun factory(itemId: Long) = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") diff --git a/android/app/src/main/java/com/umt/tracker/ui/edit/EditScreen.kt b/android/app/src/main/java/com/umt/tracker/ui/edit/EditScreen.kt new file mode 100644 index 0000000..f5f5879 --- /dev/null +++ b/android/app/src/main/java/com/umt/tracker/ui/edit/EditScreen.kt @@ -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") + } + } +} diff --git a/android/app/src/main/java/com/umt/tracker/ui/edit/EditViewModel.kt b/android/app/src/main/java/com/umt/tracker/ui/edit/EditViewModel.kt new file mode 100644 index 0000000..cc227e8 --- /dev/null +++ b/android/app/src/main/java/com/umt/tracker/ui/edit/EditViewModel.kt @@ -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 = emptyList(), + val localizedTitles: List> = emptyList(), + val customFields: List> = 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 = _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 = + split(',', ';').map { it.trim() }.filter { it.isNotEmpty() } + + companion object { + fun factory(itemId: Long) = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + EditViewModel(Graph.repository, itemId) as T + } + } +} diff --git a/android/app/src/main/java/com/umt/tracker/ui/library/LibraryScreen.kt b/android/app/src/main/java/com/umt/tracker/ui/library/LibraryScreen.kt index 02c94cf..4036db3 100644 --- a/android/app/src/main/java/com/umt/tracker/ui/library/LibraryScreen.kt +++ b/android/app/src/main/java/com/umt/tracker/ui/library/LibraryScreen.kt @@ -5,38 +5,57 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.BarChart import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -45,6 +64,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.umt.tracker.domain.MediaType +import com.umt.tracker.domain.WatchStatus +import com.umt.tracker.domain.label import com.umt.tracker.domain.labelPlural import com.umt.tracker.ui.components.MediaCard @@ -52,6 +73,8 @@ import com.umt.tracker.ui.components.MediaCard @Composable fun LibraryScreen( onItemClick: (Long) -> Unit, + onAddItem: () -> Unit, + onOpenStats: () -> Unit, onOpenSettings: () -> Unit, viewModel: LibraryViewModel = viewModel(factory = LibraryViewModel.Factory), ) { @@ -59,6 +82,7 @@ fun LibraryScreen( val message by viewModel.messages.collectAsStateWithLifecycle() val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } + var showFilters by remember { mutableStateOf(false) } // System file picker → hand the chosen JSON to the view model for import. val picker = rememberLauncherForActivityResult( @@ -78,6 +102,11 @@ fun LibraryScreen( Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, + floatingActionButton = { + FloatingActionButton(onClick = onAddItem) { + Icon(Icons.Filled.Add, contentDescription = "Eintrag hinzufügen") + } + }, topBar = { TopAppBar( title = { Text("Media Tracker") }, @@ -87,6 +116,18 @@ fun LibraryScreen( Icon(Icons.Filled.Sync, contentDescription = "Jetzt synchronisieren") } } + IconButton(onClick = { showFilters = true }) { + BadgedBox(badge = { + if (state.filter.activeCount > 0) { + Badge { Text(state.filter.activeCount.toString()) } + } + }) { + Icon(Icons.Filled.FilterList, contentDescription = "Filter & Sortierung") + } + } + IconButton(onClick = onOpenStats) { + Icon(Icons.Filled.BarChart, contentDescription = "Statistik") + } IconButton(onClick = { picker.launch(arrayOf("application/json", "*/*")) }) { Icon(Icons.Filled.FileDownload, contentDescription = "Bibliothek importieren") } @@ -142,6 +183,17 @@ fun LibraryScreen( } } } + + if (showFilters) { + FilterSheet( + filter = state.filter, + allGenres = state.allGenres, + allFranchises = state.allFranchises, + onChange = viewModel::updateFilter, + onClear = viewModel::clearFilters, + onDismiss = { showFilters = false }, + ) + } } @OptIn(ExperimentalMaterial3Api::class) @@ -194,3 +246,133 @@ private fun EmptyState(onImport: () -> Unit) { } } } + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun FilterSheet( + filter: LibraryFilter, + allGenres: List, + allFranchises: List, + 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), + ) +} diff --git a/android/app/src/main/java/com/umt/tracker/ui/library/LibraryViewModel.kt b/android/app/src/main/java/com/umt/tracker/ui/library/LibraryViewModel.kt index 6572a1c..0ec334c 100644 --- a/android/app/src/main/java/com/umt/tracker/ui/library/LibraryViewModel.kt +++ b/android/app/src/main/java/com/umt/tracker/ui/library/LibraryViewModel.kt @@ -12,6 +12,7 @@ import com.umt.tracker.data.sync.SyncResult import com.umt.tracker.data.sync.SyncSettings import com.umt.tracker.domain.MediaItem import com.umt.tracker.domain.MediaType +import com.umt.tracker.domain.WatchStatus import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -19,10 +20,41 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +/** Sort options for the library grid, mirroring the desktop sort modes. */ +enum class SortMode(val label: String) { + DATE_ADDED("Hinzugefügt"), + TITLE("Titel"), + RATING("Bewertung"), + YEAR("Jahr"), + PROGRESS("Fortschritt"), +} + +/** Active filter/sort selection, mirroring the desktop FilterCriteria. */ +data class LibraryFilter( + val status: WatchStatus? = null, + val favoritesOnly: Boolean = false, + val genres: Set = emptySet(), + val franchise: String? = null, + val minRating: Int = 0, + val sortMode: SortMode = SortMode.DATE_ADDED, + val sortDescending: Boolean = true, +) { + /** Number of active narrowing filters (sort excluded), for a badge. */ + val activeCount: Int + get() = (if (status != null) 1 else 0) + + (if (favoritesOnly) 1 else 0) + + genres.size + + (if (franchise != null) 1 else 0) + + (if (minRating > 0) 1 else 0) +} + data class LibraryUiState( val items: List = emptyList(), val query: String = "", val typeFilter: MediaType? = null, + val filter: LibraryFilter = LibraryFilter(), + val allGenres: List = emptyList(), + val allFranchises: List = emptyList(), val loading: Boolean = true, val syncEnabled: Boolean = false, ) @@ -35,21 +67,29 @@ class LibraryViewModel( private val query = MutableStateFlow("") private val typeFilter = MutableStateFlow(null) + private val filter = MutableStateFlow(LibraryFilter()) /** One-shot messages (e.g. import result) for a snackbar. */ val messages = MutableStateFlow(null) val uiState: StateFlow = - combine(repo.observeLibrary(), query, typeFilter) { items, q, type -> - val filtered = items.filter { item -> - (type == null || item.type == type) && - (q.isBlank() || item.title.contains(q, ignoreCase = true) || - item.originalTitle.contains(q, ignoreCase = true)) - } + combine(repo.observeLibrary(), query, typeFilter, filter) { items, q, type, f -> + val filtered = items + .filter { it.matches(q, type, f) } + .sortedWith(f.comparator()) + // Facets reflect the whole library, not the filtered view. + val genres = items.flatMap { it.genres }.distinct().sorted() + val franchises = items.map { it.franchise } + .filter { it.isNotBlank() } + .groupingBy { it }.eachCount() + .filterValues { it >= 2 }.keys.sorted() LibraryUiState( items = filtered, query = q, typeFilter = type, + filter = f, + allGenres = genres, + allFranchises = franchises, loading = false, syncEnabled = syncSettings.isCloud && syncSettings.isConfigured, ) @@ -59,6 +99,30 @@ class LibraryViewModel( initialValue = LibraryUiState(), ) + private fun MediaItem.matches(q: String, type: MediaType?, f: LibraryFilter): Boolean { + if (type != null && this.type != type) return false + if (q.isNotBlank() && !title.contains(q, true) && + !originalTitle.contains(q, true) && !overview.contains(q, true) + ) return false + if (f.status != null && status != f.status) return false + if (f.favoritesOnly && !favorite) return false + if (f.minRating > 0 && rating < f.minRating) return false + if (f.genres.isNotEmpty() && !f.genres.all { it in genres }) return false + if (f.franchise != null && franchise != f.franchise) return false + return true + } + + private fun LibraryFilter.comparator(): Comparator { + val base: Comparator = when (sortMode) { + SortMode.TITLE -> compareBy { it.title.lowercase() } + SortMode.RATING -> compareBy { it.rating } + SortMode.YEAR -> compareBy { it.year } + SortMode.PROGRESS -> compareBy { it.progress } + SortMode.DATE_ADDED -> compareBy { it.dateAdded } + } + return if (sortDescending) base.reversed() else base + } + fun onQueryChange(value: String) { query.value = value } @@ -67,6 +131,17 @@ class LibraryViewModel( typeFilter.value = type } + fun updateFilter(transform: (LibraryFilter) -> LibraryFilter) { + filter.value = transform(filter.value) + } + + fun clearFilters() { + filter.value = LibraryFilter( + sortMode = filter.value.sortMode, + sortDescending = filter.value.sortDescending, + ) + } + fun import(uri: Uri, openStream: (Uri) -> java.io.InputStream?) { viewModelScope.launch { val stream = openStream(uri) diff --git a/android/app/src/main/java/com/umt/tracker/ui/search/SearchScreen.kt b/android/app/src/main/java/com/umt/tracker/ui/search/SearchScreen.kt new file mode 100644 index 0000000..d747166 --- /dev/null +++ b/android/app/src/main/java/com/umt/tracker/ui/search/SearchScreen.kt @@ -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, + ) + } +} diff --git a/android/app/src/main/java/com/umt/tracker/ui/search/SearchViewModel.kt b/android/app/src/main/java/com/umt/tracker/ui/search/SearchViewModel.kt new file mode 100644 index 0000000..ab4a5a7 --- /dev/null +++ b/android/app/src/main/java/com/umt/tracker/ui/search/SearchViewModel.kt @@ -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 = 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 = _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 create(modelClass: Class): T = + SearchViewModel(Graph.providers, type) as T + } + } +} diff --git a/android/app/src/main/java/com/umt/tracker/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/umt/tracker/ui/settings/SettingsScreen.kt index feacfae..9ea3339 100644 --- a/android/app/src/main/java/com/umt/tracker/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/umt/tracker/ui/settings/SettingsScreen.kt @@ -20,9 +20,11 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -31,6 +33,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp @@ -98,6 +101,17 @@ fun SettingsScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { + Text("Darstellung", style = MaterialTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Dunkles Design", modifier = Modifier.weight(1f)) + Switch( + checked = state.darkMode, + onCheckedChange = viewModel::onDarkMode, + ) + } + + HorizontalDivider() + Text("Speicherort", style = MaterialTheme.typography.titleMedium) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip( @@ -144,6 +158,43 @@ fun SettingsScreen( modifier = Modifier.fillMaxWidth(), ) + HorizontalDivider() + + Text("Metadaten-Provider", style = MaterialTheme.typography.titleMedium) + Text( + "Optionale eigene API-Schlüssel. TMDB (Filme/Serien) wird nur benötigt, " + + "wenn kein Proxy-Server gesetzt ist. RAWG ist für die Spiele-Suche nötig " + + "(kostenlos über rawg.io/apidocs). Bücher (OpenLibrary) und Manga (AniList) " + + "brauchen keinen Schlüssel.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = state.tmdbApiKey, + onValueChange = viewModel::onTmdbApiKey, + label = { Text("TMDB API-Key") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.rawgApiKey, + onValueChange = viewModel::onRawgApiKey, + label = { Text("RAWG API-Key") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.language, + onValueChange = viewModel::onLanguage, + label = { Text("Sprache der Metadaten (z. B. de)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + HorizontalDivider() + if (state.storageMode == StorageMode.CLOUD) { Text( "Deine Bibliothek wird Ende-zu-Ende verschlüsselt. Die Passphrase " + diff --git a/android/app/src/main/java/com/umt/tracker/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/com/umt/tracker/ui/settings/SettingsViewModel.kt index 84be1d9..133f1bb 100644 --- a/android/app/src/main/java/com/umt/tracker/ui/settings/SettingsViewModel.kt +++ b/android/app/src/main/java/com/umt/tracker/ui/settings/SettingsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.umt.tracker.Graph +import com.umt.tracker.data.AppPrefs import com.umt.tracker.data.sync.StorageMode import com.umt.tracker.data.sync.SyncClient import com.umt.tracker.data.sync.SyncResult @@ -20,6 +21,10 @@ data class SettingsUiState( val bearerToken: String = "", val passphrase: String = "", val revision: Long = 0, + val darkMode: Boolean = true, + val tmdbApiKey: String = "", + val rawgApiKey: String = "", + val language: String = "de", val busy: Boolean = false, val message: String? = null, /** Set when a push hit a 409; the UI offers overwrite vs. pull. */ @@ -29,6 +34,7 @@ data class SettingsUiState( class SettingsViewModel( private val settings: SyncSettings, private val client: SyncClient, + private val prefs: AppPrefs, ) : ViewModel() { private val _state = MutableStateFlow( @@ -38,6 +44,10 @@ class SettingsViewModel( bearerToken = settings.bearerToken, passphrase = settings.passphrase, revision = settings.revision, + darkMode = prefs.darkMode.value, + tmdbApiKey = prefs.tmdbApiKey, + rawgApiKey = prefs.rawgApiKey, + language = prefs.language, ) ) val state: StateFlow = _state.asStateFlow() @@ -62,6 +72,26 @@ class SettingsViewModel( _state.update { it.copy(passphrase = value) } } + fun onDarkMode(value: Boolean) { + prefs.setDarkMode(value) + _state.update { it.copy(darkMode = value) } + } + + fun onTmdbApiKey(value: String) { + prefs.tmdbApiKey = value + _state.update { it.copy(tmdbApiKey = value) } + } + + fun onRawgApiKey(value: String) { + prefs.rawgApiKey = value + _state.update { it.copy(rawgApiKey = value) } + } + + fun onLanguage(value: String) { + prefs.language = value + _state.update { it.copy(language = value) } + } + fun pull() = run { client.pull() } fun push() = run { client.push(force = false) } @@ -94,7 +124,7 @@ class SettingsViewModel( val Factory = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = - SettingsViewModel(Graph.syncSettings, Graph.syncClient) as T + SettingsViewModel(Graph.syncSettings, Graph.syncClient, Graph.appPrefs) as T } } } diff --git a/android/app/src/main/java/com/umt/tracker/ui/stats/StatsScreen.kt b/android/app/src/main/java/com/umt/tracker/ui/stats/StatsScreen.kt new file mode 100644 index 0000000..3a0be21 --- /dev/null +++ b/android/app/src/main/java/com/umt/tracker/ui/stats/StatsScreen.kt @@ -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, + ) + } +} diff --git a/android/app/src/main/java/com/umt/tracker/ui/stats/StatsViewModel.kt b/android/app/src/main/java/com/umt/tracker/ui/stats/StatsViewModel.kt new file mode 100644 index 0000000..b47ee58 --- /dev/null +++ b/android/app/src/main/java/com/umt/tracker/ui/stats/StatsViewModel.kt @@ -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(null) + val stats: StateFlow = _stats.asStateFlow() + + init { + viewModelScope.launch { _stats.value = repo.stats() } + } + + companion object { + val Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + StatsViewModel(Graph.repository) as T + } + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 5482281..8e1e5f4 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -12,8 +12,7 @@ navigation = "2.7.7" room = "2.6.1" serialization = "1.7.3" coil = "2.7.0" -lazysodium = "5.1.0" -jna = "5.13.0" +bouncycastle = "1.78.1" securityCrypto = "1.1.0-alpha06" [libraries] @@ -35,8 +34,7 @@ androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } -lazysodium-android = { group = "com.goterl", name = "lazysodium-android", version.ref = "lazysodium" } -jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" } +bouncycastle = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" } androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } [plugins]