feat(android): reach desktop feature parity and fix 16 KB page-size crash
Swap lazysodium/JNA for a pure-Java Bouncy Castle crypto envelope so the APK ships no native .so files (resolves the Android 15 16 KB alignment crash) while staying byte-compatible with the desktop libsodium sync. Bring the Android app up to the desktop feature set: full item CRUD with an add/edit screen and FAB, inline editing on the detail view (status, rating, favorite, per-unit/segment watched toggles, delete), online metadata search (TMDB/OpenLibrary/AniList/RAWG) that pre-fills the editor, library filtering and sorting, a statistics view, and settings polish for dark mode and provider API keys/language. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
de353f0218
commit
c0389bd8d2
24 changed files with 2162 additions and 75 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
46
android/app/src/main/java/com/umt/tracker/data/AppPrefs.kt
Normal file
46
android/app/src/main/java/com/umt/tracker/data/AppPrefs.kt
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package com.umt.tracker.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Non-sensitive app preferences (theme, metadata-provider keys, query language).
|
||||
* Sync secrets live in the encrypted [com.umt.tracker.data.sync.SyncSettings];
|
||||
* these values are harmless in plain SharedPreferences. Dark mode is exposed as a
|
||||
* StateFlow so the Compose theme recomposes the moment it changes.
|
||||
*/
|
||||
class AppPrefs(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("umt_app_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
private val _darkMode = MutableStateFlow(prefs.getBoolean(KEY_DARK, true))
|
||||
val darkMode: StateFlow<Boolean> = _darkMode
|
||||
|
||||
fun setDarkMode(value: Boolean) {
|
||||
prefs.edit().putBoolean(KEY_DARK, value).apply()
|
||||
_darkMode.value = value
|
||||
}
|
||||
|
||||
var tmdbApiKey: String
|
||||
get() = prefs.getString(KEY_TMDB, "") ?: ""
|
||||
set(value) = prefs.edit().putString(KEY_TMDB, value.trim()).apply()
|
||||
|
||||
var rawgApiKey: String
|
||||
get() = prefs.getString(KEY_RAWG, "") ?: ""
|
||||
set(value) = prefs.edit().putString(KEY_RAWG, value.trim()).apply()
|
||||
|
||||
/** ISO language code used for provider queries (e.g. "de"). */
|
||||
var language: String
|
||||
get() = prefs.getString(KEY_LANG, "de") ?: "de"
|
||||
set(value) = prefs.edit().putString(KEY_LANG, value.trim().ifBlank { "de" }).apply()
|
||||
|
||||
private companion object {
|
||||
const val KEY_DARK = "dark_mode"
|
||||
const val KEY_TMDB = "tmdb_api_key"
|
||||
const val KEY_RAWG = "rawg_api_key"
|
||||
const val KEY_LANG = "language"
|
||||
}
|
||||
}
|
||||
|
|
@ -9,11 +9,14 @@ import com.umt.tracker.data.local.MediaDao
|
|||
import com.umt.tracker.data.local.toDomain
|
||||
import com.umt.tracker.data.local.toEntity
|
||||
import com.umt.tracker.domain.MediaItem
|
||||
import com.umt.tracker.domain.MediaType
|
||||
import com.umt.tracker.domain.WatchStatus
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.InputStream
|
||||
import java.time.Instant
|
||||
|
||||
/** Outcome of a JSON import, surfaced to the UI for a confirmation message. */
|
||||
sealed interface ImportResult {
|
||||
|
|
@ -21,6 +24,16 @@ sealed interface ImportResult {
|
|||
data class Failure(val message: String) : ImportResult
|
||||
}
|
||||
|
||||
/** Aggregate counts shown in the statistics view, mirroring the desktop sidebar. */
|
||||
data class LibraryStats(
|
||||
val total: Int = 0,
|
||||
val completed: Int = 0,
|
||||
val inProgress: Int = 0,
|
||||
val planned: Int = 0,
|
||||
val favorites: Int = 0,
|
||||
val perType: Map<MediaType, Int> = emptyMap(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Single source of truth for the library. Backed by Room; the only way data
|
||||
* enters today is a JSON import produced by the desktop app.
|
||||
|
|
@ -35,6 +48,97 @@ class LibraryRepository(
|
|||
fun observeItem(id: Long): Flow<MediaItem?> =
|
||||
dao.observeById(id).map { it?.toDomain() }
|
||||
|
||||
suspend fun getItem(id: Long): MediaItem? = withContext(Dispatchers.IO) {
|
||||
dao.getById(id)?.toDomain()
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a new item (id = 0) or updates an existing one. Stamps dateAdded on
|
||||
* first save and dateCompleted whenever the status flips to Completed. Returns
|
||||
* the row id (useful right after creating a brand-new item).
|
||||
*/
|
||||
suspend fun save(item: MediaItem): Long = withContext(Dispatchers.IO) {
|
||||
val now = Instant.now().toString()
|
||||
val stamped = item.copy(
|
||||
dateAdded = item.dateAdded.ifBlank { now },
|
||||
dateCompleted = when {
|
||||
item.status == WatchStatus.COMPLETED && item.dateCompleted.isBlank() -> now
|
||||
item.status != WatchStatus.COMPLETED -> ""
|
||||
else -> item.dateCompleted
|
||||
},
|
||||
)
|
||||
dao.upsert(stamped.toEntity())
|
||||
}
|
||||
|
||||
suspend fun delete(id: Long) = withContext(Dispatchers.IO) {
|
||||
dao.deleteById(id)
|
||||
}
|
||||
|
||||
suspend fun setFavorite(id: Long, favorite: Boolean) = updateItem(id) { it.copy(favorite = favorite) }
|
||||
|
||||
suspend fun setRating(id: Long, rating: Int) = updateItem(id) { it.copy(rating = rating.coerceIn(0, 10)) }
|
||||
|
||||
suspend fun setStatus(id: Long, status: WatchStatus) = updateItem(id) { it.copy(status = status) }
|
||||
|
||||
/** Marks one unit (episode/chapter) watched or not, by its (segment, unit) number. */
|
||||
suspend fun setUnitWatched(id: Long, segmentNumber: Int, unitNumber: Int, watched: Boolean) =
|
||||
updateItem(id) { item ->
|
||||
val now = Instant.now().toString()
|
||||
item.copy(segments = item.segments.map { seg ->
|
||||
if (seg.number != segmentNumber) seg
|
||||
else seg.copy(units = seg.units.map { u ->
|
||||
if (u.number != unitNumber) u
|
||||
else u.copy(watched = watched, watchedDate = if (watched) now else "")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Marks every unit in a segment watched or not at once. */
|
||||
suspend fun setSegmentWatched(id: Long, segmentNumber: Int, watched: Boolean) =
|
||||
updateItem(id) { item ->
|
||||
val now = Instant.now().toString()
|
||||
item.copy(segments = item.segments.map { seg ->
|
||||
if (seg.number != segmentNumber) seg
|
||||
else seg.copy(units = seg.units.map {
|
||||
it.copy(watched = watched, watchedDate = if (watched) now else "")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private suspend inline fun updateItem(id: Long, transform: (MediaItem) -> MediaItem) {
|
||||
val current = getItem(id) ?: return
|
||||
save(transform(current))
|
||||
}
|
||||
|
||||
/** Distinct sorted genres across the whole library, for filter chips. */
|
||||
suspend fun allGenres(): List<String> = withContext(Dispatchers.IO) {
|
||||
dao.getAll().flatMap { it.toDomain().genres }.distinct().sorted()
|
||||
}
|
||||
|
||||
suspend fun allTags(): List<String> = withContext(Dispatchers.IO) {
|
||||
dao.getAll().flatMap { it.toDomain().tags }.distinct().sorted()
|
||||
}
|
||||
|
||||
/** Franchises that group at least two items, matching the desktop filter. */
|
||||
suspend fun allFranchises(): List<String> = withContext(Dispatchers.IO) {
|
||||
dao.getAll().map { it.toDomain().franchise }
|
||||
.filter { it.isNotBlank() }
|
||||
.groupingBy { it }.eachCount()
|
||||
.filterValues { it >= 2 }.keys.sorted()
|
||||
}
|
||||
|
||||
suspend fun stats(): LibraryStats = withContext(Dispatchers.IO) {
|
||||
val items = dao.getAll().map { it.toDomain() }
|
||||
LibraryStats(
|
||||
total = items.size,
|
||||
completed = items.count { it.status == WatchStatus.COMPLETED },
|
||||
inProgress = items.count { it.status == WatchStatus.IN_PROGRESS },
|
||||
planned = items.count { it.status == WatchStatus.PLAN_TO_WATCH },
|
||||
favorites = items.count { it.favorite },
|
||||
perType = items.groupingBy { it.type }.eachCount(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the whole library with the contents of [stream]. Import is
|
||||
* all-or-nothing: a parse error leaves the existing data untouched.
|
||||
|
|
|
|||
|
|
@ -19,15 +19,25 @@ interface MediaDao {
|
|||
@Query("SELECT * FROM media ORDER BY title COLLATE NOCASE")
|
||||
suspend fun getAll(): List<MediaEntity>
|
||||
|
||||
@Query("SELECT * FROM media WHERE id = :id")
|
||||
suspend fun getById(id: Long): MediaEntity?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM media")
|
||||
suspend fun count(): Int
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(items: List<MediaEntity>)
|
||||
|
||||
/** Inserts a new row (id = 0) or replaces an existing one; returns its id. */
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(item: MediaEntity): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(item: MediaEntity)
|
||||
|
||||
@Query("DELETE FROM media WHERE id = :id")
|
||||
suspend fun deleteById(id: Long)
|
||||
|
||||
@Query("DELETE FROM media")
|
||||
suspend fun clear()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,305 @@
|
|||
package com.umt.tracker.providers
|
||||
|
||||
import com.umt.tracker.data.AppPrefs
|
||||
import com.umt.tracker.data.sync.SyncSettings
|
||||
import com.umt.tracker.domain.MediaType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* Routes an online metadata search to the right provider for a [MediaType],
|
||||
* mirroring the desktop ProviderManager: TMDB for films/series (direct key or via
|
||||
* the proxy server), OpenLibrary for books, AniList for manga, RAWG for games.
|
||||
* All calls are plain HttpURLConnection on the IO dispatcher — no extra deps.
|
||||
*/
|
||||
class ProviderManager(
|
||||
private val prefs: AppPrefs,
|
||||
private val sync: SyncSettings,
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/** Whether a search for [type] can run with the current configuration. */
|
||||
fun isConfigured(type: MediaType): Boolean = when (type) {
|
||||
MediaType.MOVIE, MediaType.SERIES -> sync.serverUrl.isNotBlank() || prefs.tmdbApiKey.isNotBlank()
|
||||
MediaType.GAME -> prefs.rawgApiKey.isNotBlank()
|
||||
MediaType.BOOK, MediaType.MANGA -> true
|
||||
}
|
||||
|
||||
/** Human-readable hint shown when [isConfigured] is false. */
|
||||
fun configHint(type: MediaType): String = when (type) {
|
||||
MediaType.MOVIE, MediaType.SERIES ->
|
||||
"Für Film-/Serien-Suche bitte einen TMDB-Key oder Proxy-Server in den Einstellungen hinterlegen."
|
||||
MediaType.GAME ->
|
||||
"Für die Spiele-Suche bitte einen kostenlosen RAWG-API-Key in den Einstellungen hinterlegen (rawg.io/apidocs)."
|
||||
else -> ""
|
||||
}
|
||||
|
||||
suspend fun search(query: String, type: MediaType): SearchOutcome = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
when (type) {
|
||||
MediaType.MOVIE, MediaType.SERIES -> searchTmdb(query, type)
|
||||
MediaType.BOOK -> searchOpenLibrary(query)
|
||||
MediaType.MANGA -> searchAniList(query)
|
||||
MediaType.GAME -> searchRawg(query)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
SearchOutcome.Error("Netzwerkfehler: ${e.message}")
|
||||
} catch (e: Exception) {
|
||||
SearchOutcome.Error("Suche fehlgeschlagen: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** Enriches a chosen result. Only TMDB series gain seasons + genres. */
|
||||
suspend fun fetchDetails(partial: SearchResult): SearchResult = withContext(Dispatchers.IO) {
|
||||
if (partial.type != MediaType.SERIES) return@withContext partial
|
||||
runCatching { tmdbSeriesDetails(partial) }.getOrDefault(partial)
|
||||
}
|
||||
|
||||
// ---- TMDB (films + series) -------------------------------------------------
|
||||
|
||||
private fun tmdbBase(): Pair<String, String?> {
|
||||
val proxy = sync.serverUrl.trim().trimEnd('/')
|
||||
return if (proxy.isNotBlank()) "$proxy/3" to sync.bearerToken
|
||||
else "https://api.themoviedb.org/3" to null
|
||||
}
|
||||
|
||||
private fun tmdbGet(path: String, extra: Map<String, String> = emptyMap()): JsonElement {
|
||||
val (base, bearer) = tmdbBase()
|
||||
val params = buildMap {
|
||||
// In proxy mode the server injects the real key; never leak ours.
|
||||
if (bearer == null) put("api_key", prefs.tmdbApiKey)
|
||||
put("language", prefs.language)
|
||||
putAll(extra)
|
||||
}
|
||||
val url = URL(base + path + "?" + params.toQuery())
|
||||
val conn = (url.openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 30_000
|
||||
setRequestProperty("User-Agent", UA)
|
||||
if (bearer != null && bearer.isNotBlank()) setRequestProperty("Authorization", "Bearer $bearer")
|
||||
}
|
||||
return json.parseToJsonElement(conn.readBody())
|
||||
}
|
||||
|
||||
private fun searchTmdb(query: String, type: MediaType): SearchOutcome {
|
||||
if (!isConfigured(type)) return SearchOutcome.Error(configHint(type))
|
||||
val movie = type == MediaType.MOVIE
|
||||
val path = if (movie) "/search/movie" else "/search/tv"
|
||||
val root = tmdbGet(path, mapOf("query" to query, "include_adult" to "false"))
|
||||
val results = root.jsonObject["results"]?.jsonArray.orEmpty().map { el ->
|
||||
val o = el.jsonObject
|
||||
val original = if (movie) o.str("original_title") else o.str("original_name")
|
||||
val date = if (movie) o.str("release_date") else o.str("first_air_date")
|
||||
val poster = o.str("poster_path")
|
||||
SearchResult(
|
||||
type = type,
|
||||
source = "TMDB",
|
||||
externalId = o.int("id").toString(),
|
||||
title = if (movie) o.str("title") else o.str("name"),
|
||||
originalTitle = original,
|
||||
year = date.take(4).toIntOrNull() ?: 0,
|
||||
overview = o.str("overview"),
|
||||
coverUrl = if (poster.isNotBlank()) TMDB_IMG + poster else "",
|
||||
externalRating = o.dbl("vote_average"),
|
||||
localizedTitles = if (original.isNotBlank()) mapOf("original" to original) else emptyMap(),
|
||||
)
|
||||
}
|
||||
return SearchOutcome.Success(results)
|
||||
}
|
||||
|
||||
private fun tmdbSeriesDetails(partial: SearchResult): SearchResult {
|
||||
val o = tmdbGet("/tv/${partial.externalId}").jsonObject
|
||||
val genres = o["genres"]?.jsonArray.orEmpty().mapNotNull { it.jsonObject.str("name").ifBlank { null } }
|
||||
val segments = o["seasons"]?.jsonArray.orEmpty().mapNotNull { sv ->
|
||||
val so = sv.jsonObject
|
||||
val num = so.int("season_number")
|
||||
if (num <= 0) null // skip "Specials"
|
||||
else ResultSegment(number = num, title = so.str("name"), unitCount = so.int("episode_count"))
|
||||
}
|
||||
return partial.copy(genres = genres, segments = segments)
|
||||
}
|
||||
|
||||
// ---- OpenLibrary (books) ---------------------------------------------------
|
||||
|
||||
private fun searchOpenLibrary(query: String): SearchOutcome {
|
||||
val params = mapOf(
|
||||
"q" to query,
|
||||
"limit" to "25",
|
||||
"fields" to "title,author_name,first_publish_year,cover_i,subject,first_sentence,key",
|
||||
)
|
||||
val url = URL("https://openlibrary.org/search.json?" + params.toQuery())
|
||||
val conn = (url.openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"; connectTimeout = 15_000; readTimeout = 30_000
|
||||
setRequestProperty("User-Agent", UA)
|
||||
}
|
||||
val docs = json.parseToJsonElement(conn.readBody()).jsonObject["docs"]?.jsonArray.orEmpty()
|
||||
val results = docs.map { el ->
|
||||
val o = el.jsonObject
|
||||
val authors = o["author_name"]?.jsonArray.orEmpty().mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
val firstSentence = o["first_sentence"]?.jsonArray.orEmpty().mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
val overview = buildString {
|
||||
if (authors.isNotEmpty()) append("von ${authors.first()}")
|
||||
if (firstSentence.isNotEmpty()) {
|
||||
if (isNotEmpty()) append("\n\n")
|
||||
append(firstSentence.first())
|
||||
}
|
||||
}
|
||||
val coverId = o.int("cover_i")
|
||||
SearchResult(
|
||||
type = MediaType.BOOK,
|
||||
source = "OpenLibrary",
|
||||
title = o.str("title"),
|
||||
year = o.int("first_publish_year"),
|
||||
externalId = o.str("key"),
|
||||
overview = overview,
|
||||
genres = o["subject"]?.jsonArray.orEmpty().take(6).mapNotNull { it.jsonPrimitive.contentOrNull },
|
||||
coverUrl = if (coverId > 0) "https://covers.openlibrary.org/b/id/$coverId-L.jpg" else "",
|
||||
)
|
||||
}
|
||||
return SearchOutcome.Success(results)
|
||||
}
|
||||
|
||||
// ---- AniList (manga) -------------------------------------------------------
|
||||
|
||||
private fun searchAniList(query: String): SearchOutcome {
|
||||
val gql = """
|
||||
query (${'$'}q: String) {
|
||||
Page(perPage: 25) {
|
||||
media(search: ${'$'}q, type: MANGA, sort: SEARCH_MATCH) {
|
||||
id title { romaji english native } description(asHtml: false)
|
||||
coverImage { large } genres chapters volumes startDate { year } averageScore
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val body = buildJsonObject {
|
||||
put("query", gql)
|
||||
put("variables", buildJsonObject { put("q", query) })
|
||||
}
|
||||
val conn = (URL("https://graphql.anilist.co").openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"; doOutput = true; connectTimeout = 15_000; readTimeout = 30_000
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
setRequestProperty("Accept", "application/json")
|
||||
setRequestProperty("User-Agent", UA)
|
||||
}
|
||||
conn.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) }
|
||||
val media = json.parseToJsonElement(conn.readBody())
|
||||
.jsonObject["data"]?.jsonObject
|
||||
?.get("Page")?.jsonObject
|
||||
?.get("media")?.jsonArray.orEmpty()
|
||||
val results = media.map { el ->
|
||||
val o = el.jsonObject
|
||||
val t = o["title"]?.jsonObject ?: JsonObject(emptyMap())
|
||||
val romaji = t.str("romaji"); val english = t.str("english"); val native = t.str("native")
|
||||
val localized = buildMap {
|
||||
if (romaji.isNotBlank()) put("romaji", romaji)
|
||||
if (english.isNotBlank()) put("en", english)
|
||||
if (native.isNotBlank()) put("ja", native)
|
||||
}
|
||||
val desc = o.str("description")
|
||||
.replace("<br>", "\n")
|
||||
.replace(Regex("<[^>]*>"), "")
|
||||
val chapters = o.int("chapters"); val volumes = o.int("volumes")
|
||||
val segments = buildMangaSegments(chapters, volumes)
|
||||
SearchResult(
|
||||
type = MediaType.MANGA,
|
||||
source = "AniList",
|
||||
externalId = o.int("id").toString(),
|
||||
title = english.ifBlank { romaji.ifBlank { native } },
|
||||
originalTitle = native,
|
||||
localizedTitles = localized,
|
||||
overview = desc,
|
||||
year = o["startDate"]?.jsonObject?.int("year") ?: 0,
|
||||
externalRating = o.dbl("averageScore") / 10.0,
|
||||
genres = o["genres"]?.jsonArray.orEmpty().mapNotNull { it.jsonPrimitive.contentOrNull },
|
||||
coverUrl = o["coverImage"]?.jsonObject?.str("large") ?: "",
|
||||
segments = segments,
|
||||
)
|
||||
}
|
||||
return SearchOutcome.Success(results)
|
||||
}
|
||||
|
||||
private fun buildMangaSegments(chapters: Int, volumes: Int): List<ResultSegment> = when {
|
||||
volumes > 0 && chapters > 0 -> {
|
||||
val base = chapters / volumes
|
||||
val rem = chapters % volumes
|
||||
(1..volumes).map { vol ->
|
||||
ResultSegment(number = vol, title = "Band $vol", unitCount = base + if (vol <= rem) 1 else 0)
|
||||
}
|
||||
}
|
||||
chapters > 0 -> listOf(ResultSegment(number = 1, title = "Kapitel", unitCount = chapters))
|
||||
volumes > 0 -> (1..volumes).map { ResultSegment(number = it, title = "Band $it", unitCount = 0) }
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
// ---- RAWG (games) ----------------------------------------------------------
|
||||
|
||||
private fun searchRawg(query: String): SearchOutcome {
|
||||
val key = prefs.rawgApiKey.trim()
|
||||
if (key.isBlank()) return SearchOutcome.Error(configHint(MediaType.GAME))
|
||||
val params = mapOf("key" to key, "search" to query, "page_size" to "25")
|
||||
val url = URL("https://api.rawg.io/api/games?" + params.toQuery())
|
||||
val conn = (url.openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"; connectTimeout = 15_000; readTimeout = 30_000
|
||||
setRequestProperty("User-Agent", UA)
|
||||
}
|
||||
val games = json.parseToJsonElement(conn.readBody()).jsonObject["results"]?.jsonArray.orEmpty()
|
||||
val results = games.map { el ->
|
||||
val o = el.jsonObject
|
||||
SearchResult(
|
||||
type = MediaType.GAME,
|
||||
source = "RAWG",
|
||||
title = o.str("name"),
|
||||
externalId = o.int("id").toString(),
|
||||
year = o.str("released").take(4).toIntOrNull() ?: 0,
|
||||
coverUrl = o.str("background_image"),
|
||||
externalRating = o.dbl("rating"),
|
||||
genres = o["genres"]?.jsonArray.orEmpty().mapNotNull { it.jsonObject.str("name").ifBlank { null } },
|
||||
)
|
||||
}
|
||||
return SearchOutcome.Success(results)
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
private fun HttpURLConnection.readBody(): String {
|
||||
val ok = responseCode in 200..299
|
||||
val stream = if (ok) inputStream else (errorStream ?: inputStream)
|
||||
val text = stream.bufferedReader().use { it.readText() }
|
||||
if (!ok) throw IOException("HTTP $responseCode")
|
||||
return text
|
||||
}
|
||||
|
||||
private fun Map<String, String>.toQuery(): String =
|
||||
entries.joinToString("&") { (k, v) ->
|
||||
"${URLEncoder.encode(k, "UTF-8")}=${URLEncoder.encode(v, "UTF-8")}"
|
||||
}
|
||||
|
||||
private fun JsonObject.str(key: String): String = this[key]?.jsonPrimitive?.contentOrNull ?: ""
|
||||
private fun JsonObject.int(key: String): Int = this[key]?.jsonPrimitive?.intOrNull ?: 0
|
||||
private fun JsonObject.dbl(key: String): Double = this[key]?.jsonPrimitive?.doubleOrNull ?: 0.0
|
||||
|
||||
private companion object {
|
||||
const val UA = "UltimateMediaTracker-Android/1.0"
|
||||
const val TMDB_IMG = "https://image.tmdb.org/t/p/w500"
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList())
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package com.umt.tracker.providers
|
||||
|
||||
import com.umt.tracker.domain.MediaItem
|
||||
import com.umt.tracker.domain.MediaType
|
||||
import com.umt.tracker.domain.ProgressUnit
|
||||
import com.umt.tracker.domain.Segment
|
||||
|
||||
/** A season/volume layout hint from a provider, used to pre-build the unit tree. */
|
||||
data class ResultSegment(
|
||||
val number: Int,
|
||||
val title: String = "",
|
||||
val unitCount: Int = 0,
|
||||
)
|
||||
|
||||
/** One hit from an online metadata search, mirroring the desktop SearchResult. */
|
||||
data class SearchResult(
|
||||
val type: MediaType = MediaType.MOVIE,
|
||||
val title: String = "",
|
||||
val originalTitle: String = "",
|
||||
val overview: String = "",
|
||||
val coverUrl: String = "",
|
||||
val year: Int = 0,
|
||||
val externalId: String = "",
|
||||
val source: String = "",
|
||||
val genres: List<String> = emptyList(),
|
||||
val localizedTitles: Map<String, String> = emptyMap(),
|
||||
val segments: List<ResultSegment> = emptyList(),
|
||||
val externalRating: Double = 0.0,
|
||||
)
|
||||
|
||||
/** Result of a search request, surfaced to the UI. */
|
||||
sealed interface SearchOutcome {
|
||||
data class Success(val results: List<SearchResult>) : SearchOutcome
|
||||
data class Error(val message: String) : SearchOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fresh [MediaItem] from a chosen search result, expanding each
|
||||
* [ResultSegment] into a numbered list of unwatched units so the user lands on a
|
||||
* ready-to-track entry. The provider rating (0..10) becomes the initial rating.
|
||||
*/
|
||||
fun SearchResult.toMediaItem(): MediaItem = MediaItem(
|
||||
type = type,
|
||||
title = title,
|
||||
originalTitle = originalTitle,
|
||||
localizedTitles = localizedTitles,
|
||||
coverUrl = coverUrl,
|
||||
genres = genres,
|
||||
overview = overview,
|
||||
year = year,
|
||||
rating = externalRating.toInt().coerceIn(0, 10),
|
||||
segments = segments.map { seg ->
|
||||
Segment(
|
||||
number = seg.number,
|
||||
title = seg.title,
|
||||
units = (1..seg.unitCount).map { ProgressUnit(number = it) },
|
||||
)
|
||||
},
|
||||
externalId = externalId,
|
||||
externalSource = source,
|
||||
)
|
||||
|
|
@ -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 }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,28 @@
|
|||
package com.umt.tracker.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
|
@ -28,3 +45,64 @@ fun TagChip(
|
|||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** A read-only text field that opens a dropdown of [options] when tapped. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun <T> LabeledDropdown(
|
||||
label: String,
|
||||
selected: T,
|
||||
options: List<T>,
|
||||
optionLabel: (T) -> String,
|
||||
onSelect: (T) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
modifier = modifier,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = optionLabel(selected),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(label) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor()
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(optionLabel(option)) },
|
||||
onClick = { onSelect(option); expanded = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Interactive 0..10 star rating. Tapping the same star again clears to 0. */
|
||||
@Composable
|
||||
fun StarRatingInput(
|
||||
rating: Int,
|
||||
onRatingChange: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(modifier) {
|
||||
// Ten half-units rendered as five tappable stars (each = 2 points).
|
||||
for (star in 1..5) {
|
||||
val value = star * 2
|
||||
Icon(
|
||||
imageVector = if (rating >= value) Icons.Filled.Star else Icons.Filled.StarBorder,
|
||||
contentDescription = "$value",
|
||||
tint = Color(0xFFFFD60A),
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clickable { onRatingChange(if (rating == value) value - 1 else value) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,10 +19,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
|
|
@ -30,12 +34,15 @@ import androidx.compose.material3.LinearProgressIndicator
|
|||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
|
@ -48,11 +55,14 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||
import coil.compose.AsyncImage
|
||||
import com.umt.tracker.domain.MediaItem
|
||||
import com.umt.tracker.domain.Segment
|
||||
import com.umt.tracker.domain.WatchStatus
|
||||
import com.umt.tracker.domain.accentArgb
|
||||
import com.umt.tracker.domain.label
|
||||
import com.umt.tracker.domain.labelSingular
|
||||
import com.umt.tracker.domain.segmentLabel
|
||||
import com.umt.tracker.domain.unitLabel
|
||||
import com.umt.tracker.ui.components.LabeledDropdown
|
||||
import com.umt.tracker.ui.components.StarRatingInput
|
||||
import com.umt.tracker.ui.components.TagChip
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
|
@ -60,9 +70,12 @@ import com.umt.tracker.ui.components.TagChip
|
|||
fun DetailScreen(
|
||||
itemId: Long,
|
||||
onBack: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDeleted: () -> Unit,
|
||||
viewModel: DetailViewModel = viewModel(factory = DetailViewModel.factory(itemId)),
|
||||
) {
|
||||
val item by viewModel.item.collectAsStateWithLifecycle()
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
|
|
@ -73,6 +86,22 @@ fun DetailScreen(
|
|||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
val fav = item?.favorite == true
|
||||
IconButton(onClick = viewModel::toggleFavorite) {
|
||||
Icon(
|
||||
imageVector = if (fav) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
|
||||
contentDescription = "Favorit",
|
||||
tint = if (fav) Color(0xFFFF375F) else MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onEdit) {
|
||||
Icon(Icons.Filled.Edit, contentDescription = "Bearbeiten")
|
||||
}
|
||||
IconButton(onClick = { confirmDelete = true }) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = "Löschen")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
titleContentColor = MaterialTheme.colorScheme.onBackground,
|
||||
|
|
@ -86,13 +115,34 @@ fun DetailScreen(
|
|||
Text("Nicht gefunden", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
} else {
|
||||
DetailContent(current, Modifier.padding(padding))
|
||||
DetailContent(current, viewModel, Modifier.padding(padding))
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmDelete) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmDelete = false },
|
||||
title = { Text("Eintrag löschen?") },
|
||||
text = { Text("Dieser Eintrag wird dauerhaft entfernt.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
confirmDelete = false
|
||||
viewModel.delete(onDeleted)
|
||||
}) { Text("Löschen") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmDelete = false }) { Text("Abbrechen") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailContent(item: MediaItem, modifier: Modifier = Modifier) {
|
||||
private fun DetailContent(
|
||||
item: MediaItem,
|
||||
viewModel: DetailViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Seasons/volumes start collapsed for overview, mirroring the desktop app.
|
||||
val expanded = remember { mutableStateMapOf<Int, Boolean>() }
|
||||
|
||||
|
|
@ -148,24 +198,34 @@ private fun DetailContent(item: MediaItem, modifier: Modifier = Modifier) {
|
|||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
if (item.rating > 0) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Star, null, tint = Color(0xFFFFD60A))
|
||||
Text(
|
||||
text = "${item.rating} / 10",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
LabeledDropdown(
|
||||
label = "Status",
|
||||
selected = item.status,
|
||||
options = WatchStatus.entries,
|
||||
optionLabel = { it.label },
|
||||
onSelect = viewModel::setStatus,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Column {
|
||||
Text(
|
||||
text = "Bewertung",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
StarRatingInput(
|
||||
rating = item.rating,
|
||||
onRatingChange = viewModel::setRating,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (item.hasSegments) {
|
||||
item {
|
||||
Column {
|
||||
|
|
@ -189,6 +249,10 @@ private fun DetailContent(item: MediaItem, modifier: Modifier = Modifier) {
|
|||
typeUnitLabel = item.type.unitLabel,
|
||||
expanded = expanded[segment.number] == true,
|
||||
onToggle = { expanded[segment.number] = !(expanded[segment.number] ?: false) },
|
||||
onToggleSegment = { watched -> viewModel.setSegmentWatched(segment.number, watched) },
|
||||
onToggleUnit = { unitNumber, watched ->
|
||||
viewModel.setUnitWatched(segment.number, unitNumber, watched)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -238,7 +302,10 @@ private fun SegmentRow(
|
|||
typeUnitLabel: String,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
onToggleSegment: (Boolean) -> Unit,
|
||||
onToggleUnit: (Int, Boolean) -> Unit,
|
||||
) {
|
||||
val allWatched = segment.units.isNotEmpty() && segment.units.all { it.watched }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -252,7 +319,17 @@ private fun SegmentRow(
|
|||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Tapping the leading icon flips the whole segment watched/unwatched.
|
||||
Icon(
|
||||
imageVector = if (allWatched) Icons.Filled.CheckCircle
|
||||
else Icons.Filled.RadioButtonUnchecked,
|
||||
contentDescription = "Alle markieren",
|
||||
tint = if (allWatched) Color(0xFF30D158) else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clickable { onToggleSegment(!allWatched) },
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) {
|
||||
val name = segment.title.ifBlank { "$typeSegmentLabel ${segment.number}" }
|
||||
Text(
|
||||
text = name,
|
||||
|
|
@ -278,6 +355,7 @@ private fun SegmentRow(
|
|||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onToggleUnit(unit.number, !unit.watched) }
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -6,11 +6,16 @@ import androidx.lifecycle.viewModelScope
|
|||
import com.umt.tracker.Graph
|
||||
import com.umt.tracker.data.LibraryRepository
|
||||
import com.umt.tracker.domain.MediaItem
|
||||
import com.umt.tracker.domain.WatchStatus
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class DetailViewModel(repo: LibraryRepository, itemId: Long) : ViewModel() {
|
||||
class DetailViewModel(
|
||||
private val repo: LibraryRepository,
|
||||
private val itemId: Long,
|
||||
) : ViewModel() {
|
||||
|
||||
val item: StateFlow<MediaItem?> =
|
||||
repo.observeItem(itemId).stateIn(
|
||||
|
|
@ -19,6 +24,27 @@ class DetailViewModel(repo: LibraryRepository, itemId: Long) : ViewModel() {
|
|||
initialValue = null,
|
||||
)
|
||||
|
||||
fun toggleFavorite() = launch { repo.setFavorite(itemId, !(item.value?.favorite ?: false)) }
|
||||
|
||||
fun setStatus(status: WatchStatus) = launch { repo.setStatus(itemId, status) }
|
||||
|
||||
fun setRating(rating: Int) = launch { repo.setRating(itemId, rating) }
|
||||
|
||||
fun setUnitWatched(segmentNumber: Int, unitNumber: Int, watched: Boolean) =
|
||||
launch { repo.setUnitWatched(itemId, segmentNumber, unitNumber, watched) }
|
||||
|
||||
fun setSegmentWatched(segmentNumber: Int, watched: Boolean) =
|
||||
launch { repo.setSegmentWatched(itemId, segmentNumber, watched) }
|
||||
|
||||
fun delete(onDone: () -> Unit) = launch {
|
||||
repo.delete(itemId)
|
||||
onDone()
|
||||
}
|
||||
|
||||
private inline fun launch(crossinline block: suspend () -> Unit) {
|
||||
viewModelScope.launch { block() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(itemId: Long) = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
|
|
|
|||
276
android/app/src/main/java/com/umt/tracker/ui/edit/EditScreen.kt
Normal file
276
android/app/src/main/java/com/umt/tracker/ui/edit/EditScreen.kt
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
package com.umt.tracker.ui.edit
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.umt.tracker.domain.MediaType
|
||||
import com.umt.tracker.domain.WatchStatus
|
||||
import com.umt.tracker.domain.label
|
||||
import com.umt.tracker.domain.labelSingular
|
||||
import com.umt.tracker.domain.segmentLabel
|
||||
import com.umt.tracker.domain.unitLabel
|
||||
import com.umt.tracker.ui.components.LabeledDropdown
|
||||
import com.umt.tracker.ui.components.StarRatingInput
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditScreen(
|
||||
itemId: Long,
|
||||
onBack: () -> Unit,
|
||||
onSaved: (Long) -> Unit,
|
||||
onSearch: (MediaType) -> Unit,
|
||||
viewModel: EditViewModel = viewModel(factory = EditViewModel.factory(itemId)),
|
||||
) {
|
||||
val form by viewModel.form.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(if (form.isNew) "Neuer Eintrag" else "Bearbeiten") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = {
|
||||
scope.launch { viewModel.save()?.let(onSaved) }
|
||||
}) { Text("Speichern") }
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
titleContentColor = MaterialTheme.colorScheme.onBackground,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
LabeledDropdown(
|
||||
label = "Typ",
|
||||
selected = form.type,
|
||||
options = MediaType.entries,
|
||||
optionLabel = { it.labelSingular },
|
||||
onSelect = { t -> viewModel.update { it.copy(type = t) } },
|
||||
)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { onSearch(form.type) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.Search, contentDescription = null)
|
||||
Text("Online suchen & übernehmen", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
|
||||
field("Titel", form.title) { v -> viewModel.update { it.copy(title = v) } }
|
||||
field("Originaltitel", form.originalTitle) { v -> viewModel.update { it.copy(originalTitle = v) } }
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(
|
||||
value = form.yearText,
|
||||
onValueChange = { v -> viewModel.update { it.copy(yearText = v.filter(Char::isDigit)) } },
|
||||
label = { Text("Jahr") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.width(120.dp),
|
||||
)
|
||||
LabeledDropdown(
|
||||
label = "Status",
|
||||
selected = form.status,
|
||||
options = WatchStatus.entries,
|
||||
optionLabel = { it.label },
|
||||
onSelect = { s -> viewModel.update { it.copy(status = s) } },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
Text("Bewertung", style = MaterialTheme.typography.labelLarge)
|
||||
StarRatingInput(
|
||||
rating = form.rating,
|
||||
onRatingChange = { r -> viewModel.update { it.copy(rating = r) } },
|
||||
)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Switch(
|
||||
checked = form.favorite,
|
||||
onCheckedChange = { c -> viewModel.update { it.copy(favorite = c) } },
|
||||
)
|
||||
Text("Favorit", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
|
||||
field("Cover-URL", form.coverUrl) { v -> viewModel.update { it.copy(coverUrl = v) } }
|
||||
field("Genres (Komma-getrennt)", form.genresText) { v -> viewModel.update { it.copy(genresText = v) } }
|
||||
field("Tags (Komma-getrennt)", form.tagsText) { v -> viewModel.update { it.copy(tagsText = v) } }
|
||||
field("Reihe / Franchise", form.franchise) { v -> viewModel.update { it.copy(franchise = v) } }
|
||||
field("Beschreibung", form.overview, minLines = 3) { v -> viewModel.update { it.copy(overview = v) } }
|
||||
field("Notizen", form.notes, minLines = 3) { v -> viewModel.update { it.copy(notes = v) } }
|
||||
|
||||
// Segments / units (seasons & episodes, volumes & chapters, ...)
|
||||
Text(
|
||||
"${form.type.segmentLabel}n & ${form.type.unitLabel}n",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
form.segments.forEach { segment ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = segment.title,
|
||||
onValueChange = { viewModel.setSegmentTitle(segment.number, it) },
|
||||
label = { Text("${form.type.segmentLabel} ${segment.number}") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = { viewModel.removeSegment(segment.number) }) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Entfernen")
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = segment.units.size.toString(),
|
||||
onValueChange = {
|
||||
viewModel.setUnitCount(segment.number, it.filter(Char::isDigit).toIntOrNull() ?: 0)
|
||||
},
|
||||
label = { Text("Anzahl ${form.type.unitLabel}n") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.width(200.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = viewModel::addSegment) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Text("${form.type.segmentLabel} hinzufügen", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
|
||||
// Localized titles
|
||||
Text("Lokalisierte Titel", style = MaterialTheme.typography.titleMedium)
|
||||
form.localizedTitles.forEachIndexed { index, (key, value) ->
|
||||
KeyValueRow(
|
||||
key = key, value = value,
|
||||
keyLabel = "Sprache", valueLabel = "Titel",
|
||||
onKey = { viewModel.setLocalizedTitle(index, it, value) },
|
||||
onValue = { viewModel.setLocalizedTitle(index, key, it) },
|
||||
onRemove = { viewModel.removeLocalizedTitle(index) },
|
||||
)
|
||||
}
|
||||
TextButton(onClick = viewModel::addLocalizedTitle) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Text("Titel hinzufügen", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
|
||||
// Custom fields
|
||||
Text("Eigene Felder", style = MaterialTheme.typography.titleMedium)
|
||||
form.customFields.forEachIndexed { index, (key, value) ->
|
||||
KeyValueRow(
|
||||
key = key, value = value,
|
||||
keyLabel = "Feld", valueLabel = "Wert",
|
||||
onKey = { viewModel.setCustomField(index, it, value) },
|
||||
onValue = { viewModel.setCustomField(index, key, it) },
|
||||
onRemove = { viewModel.removeCustomField(index) },
|
||||
)
|
||||
}
|
||||
TextButton(onClick = viewModel::addCustomField) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Text("Feld hinzufügen", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { scope.launch { viewModel.save()?.let(onSaved) } },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) { Text("Speichern") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun field(
|
||||
label: String,
|
||||
value: String,
|
||||
minLines: Int = 1,
|
||||
onChange: (String) -> Unit,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onChange,
|
||||
label = { Text(label) },
|
||||
singleLine = minLines == 1,
|
||||
minLines = minLines,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeyValueRow(
|
||||
key: String,
|
||||
value: String,
|
||||
keyLabel: String,
|
||||
valueLabel: String,
|
||||
onKey: (String) -> Unit,
|
||||
onValue: (String) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = key, onValueChange = onKey,
|
||||
label = { Text(keyLabel) }, singleLine = true,
|
||||
modifier = Modifier.width(120.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = value, onValueChange = onValue,
|
||||
label = { Text(valueLabel) }, singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Entfernen")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
package com.umt.tracker.ui.edit
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.umt.tracker.Graph
|
||||
import com.umt.tracker.data.LibraryRepository
|
||||
import com.umt.tracker.domain.MediaItem
|
||||
import com.umt.tracker.domain.MediaType
|
||||
import com.umt.tracker.domain.ProgressUnit
|
||||
import com.umt.tracker.domain.Segment
|
||||
import com.umt.tracker.domain.WatchStatus
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Editable form mirror of a [MediaItem]. Collection fields are held as separate
|
||||
* editable rows (genres/tags as one comma string; localized titles and custom
|
||||
* fields as key/value pairs) and folded back into a MediaItem on save.
|
||||
*/
|
||||
data class EditFormState(
|
||||
val id: Long = 0,
|
||||
val type: MediaType = MediaType.MOVIE,
|
||||
val title: String = "",
|
||||
val originalTitle: String = "",
|
||||
val yearText: String = "",
|
||||
val status: WatchStatus = WatchStatus.PLAN_TO_WATCH,
|
||||
val rating: Int = 0,
|
||||
val favorite: Boolean = false,
|
||||
val genresText: String = "",
|
||||
val tagsText: String = "",
|
||||
val franchise: String = "",
|
||||
val coverUrl: String = "",
|
||||
val overview: String = "",
|
||||
val notes: String = "",
|
||||
val segments: List<Segment> = emptyList(),
|
||||
val localizedTitles: List<Pair<String, String>> = emptyList(),
|
||||
val customFields: List<Pair<String, String>> = emptyList(),
|
||||
val externalId: String = "",
|
||||
val externalSource: String = "",
|
||||
val dateAdded: String = "",
|
||||
val dateCompleted: String = "",
|
||||
val loading: Boolean = true,
|
||||
) {
|
||||
val isNew: Boolean get() = id == 0L
|
||||
}
|
||||
|
||||
class EditViewModel(
|
||||
private val repo: LibraryRepository,
|
||||
private val itemId: Long,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _form = MutableStateFlow(EditFormState())
|
||||
val form: StateFlow<EditFormState> = _form.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val existing = if (itemId > 0) repo.getItem(itemId) else null
|
||||
val draft = if (itemId <= 0) Graph.editDraft else null
|
||||
Graph.editDraft = null
|
||||
_form.value = (existing ?: draft)?.toForm() ?: EditFormState(loading = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun update(transform: (EditFormState) -> EditFormState) {
|
||||
_form.value = transform(_form.value)
|
||||
}
|
||||
|
||||
// -- segment helpers ---------------------------------------------------------
|
||||
|
||||
fun addSegment() = update { s ->
|
||||
val nextNum = (s.segments.maxOfOrNull { it.number } ?: 0) + 1
|
||||
s.copy(segments = s.segments + Segment(number = nextNum, units = emptyList()))
|
||||
}
|
||||
|
||||
fun removeSegment(number: Int) = update { s ->
|
||||
s.copy(segments = s.segments.filterNot { it.number == number })
|
||||
}
|
||||
|
||||
fun setSegmentTitle(number: Int, title: String) = update { s ->
|
||||
s.copy(segments = s.segments.map { if (it.number == number) it.copy(title = title) else it })
|
||||
}
|
||||
|
||||
/** Grows or shrinks a segment's unit list, preserving the watched flags kept. */
|
||||
fun setUnitCount(number: Int, count: Int) = update { s ->
|
||||
s.copy(segments = s.segments.map { seg ->
|
||||
if (seg.number != number) seg
|
||||
else {
|
||||
val target = count.coerceIn(0, 999)
|
||||
val units = when {
|
||||
target <= seg.units.size -> seg.units.take(target)
|
||||
else -> seg.units + (seg.units.size + 1..target).map { ProgressUnit(number = it) }
|
||||
}
|
||||
seg.copy(units = units)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// -- localized titles / custom fields ---------------------------------------
|
||||
|
||||
fun addLocalizedTitle() = update { it.copy(localizedTitles = it.localizedTitles + ("" to "")) }
|
||||
fun removeLocalizedTitle(index: Int) = update { it.copy(localizedTitles = it.localizedTitles.filterIndexed { i, _ -> i != index }) }
|
||||
fun setLocalizedTitle(index: Int, key: String, value: String) = update {
|
||||
it.copy(localizedTitles = it.localizedTitles.mapIndexed { i, p -> if (i == index) key to value else p })
|
||||
}
|
||||
|
||||
fun addCustomField() = update { it.copy(customFields = it.customFields + ("" to "")) }
|
||||
fun removeCustomField(index: Int) = update { it.copy(customFields = it.customFields.filterIndexed { i, _ -> i != index }) }
|
||||
fun setCustomField(index: Int, key: String, value: String) = update {
|
||||
it.copy(customFields = it.customFields.mapIndexed { i, p -> if (i == index) key to value else p })
|
||||
}
|
||||
|
||||
/** Persists the form. Returns the saved item id, or null if the title is empty. */
|
||||
suspend fun save(): Long? {
|
||||
val s = _form.value
|
||||
if (s.title.isBlank()) return null
|
||||
return repo.save(s.toMediaItem())
|
||||
}
|
||||
|
||||
private fun EditFormState.toMediaItem(): MediaItem = MediaItem(
|
||||
id = id,
|
||||
type = type,
|
||||
title = title.trim(),
|
||||
originalTitle = originalTitle.trim(),
|
||||
localizedTitles = localizedTitles.filter { it.first.isNotBlank() }.toMap(),
|
||||
coverUrl = coverUrl.trim(),
|
||||
genres = genresText.splitList(),
|
||||
tags = tagsText.splitList(),
|
||||
franchise = franchise.trim(),
|
||||
status = status,
|
||||
rating = rating,
|
||||
favorite = favorite,
|
||||
year = yearText.trim().toIntOrNull() ?: 0,
|
||||
overview = overview.trim(),
|
||||
notes = notes.trim(),
|
||||
segments = segments,
|
||||
customFields = customFields.filter { it.first.isNotBlank() }.toMap(),
|
||||
dateAdded = dateAdded,
|
||||
dateCompleted = dateCompleted,
|
||||
externalId = externalId,
|
||||
externalSource = externalSource,
|
||||
)
|
||||
|
||||
private fun MediaItem.toForm(): EditFormState = EditFormState(
|
||||
id = id,
|
||||
type = type,
|
||||
title = title,
|
||||
originalTitle = originalTitle,
|
||||
yearText = if (year > 0) year.toString() else "",
|
||||
status = status,
|
||||
rating = rating,
|
||||
favorite = favorite,
|
||||
genresText = genres.joinToString(", "),
|
||||
tagsText = tags.joinToString(", "),
|
||||
franchise = franchise,
|
||||
coverUrl = coverUrl,
|
||||
overview = overview,
|
||||
notes = notes,
|
||||
segments = segments,
|
||||
localizedTitles = localizedTitles.toList(),
|
||||
customFields = customFields.toList(),
|
||||
externalId = externalId,
|
||||
externalSource = externalSource,
|
||||
dateAdded = dateAdded,
|
||||
dateCompleted = dateCompleted,
|
||||
loading = false,
|
||||
)
|
||||
|
||||
private fun String.splitList(): List<String> =
|
||||
split(',', ';').map { it.trim() }.filter { it.isNotEmpty() }
|
||||
|
||||
companion object {
|
||||
fun factory(itemId: Long) = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
EditViewModel(Graph.repository, itemId) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,38 +5,57 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.BarChart
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
|
@ -45,6 +64,8 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.umt.tracker.domain.MediaType
|
||||
import com.umt.tracker.domain.WatchStatus
|
||||
import com.umt.tracker.domain.label
|
||||
import com.umt.tracker.domain.labelPlural
|
||||
import com.umt.tracker.ui.components.MediaCard
|
||||
|
||||
|
|
@ -52,6 +73,8 @@ import com.umt.tracker.ui.components.MediaCard
|
|||
@Composable
|
||||
fun LibraryScreen(
|
||||
onItemClick: (Long) -> Unit,
|
||||
onAddItem: () -> Unit,
|
||||
onOpenStats: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
viewModel: LibraryViewModel = viewModel(factory = LibraryViewModel.Factory),
|
||||
) {
|
||||
|
|
@ -59,6 +82,7 @@ fun LibraryScreen(
|
|||
val message by viewModel.messages.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var showFilters by remember { mutableStateOf(false) }
|
||||
|
||||
// System file picker → hand the chosen JSON to the view model for import.
|
||||
val picker = rememberLauncherForActivityResult(
|
||||
|
|
@ -78,6 +102,11 @@ fun LibraryScreen(
|
|||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = onAddItem) {
|
||||
Icon(Icons.Filled.Add, contentDescription = "Eintrag hinzufügen")
|
||||
}
|
||||
},
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Media Tracker") },
|
||||
|
|
@ -87,6 +116,18 @@ fun LibraryScreen(
|
|||
Icon(Icons.Filled.Sync, contentDescription = "Jetzt synchronisieren")
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { showFilters = true }) {
|
||||
BadgedBox(badge = {
|
||||
if (state.filter.activeCount > 0) {
|
||||
Badge { Text(state.filter.activeCount.toString()) }
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Filled.FilterList, contentDescription = "Filter & Sortierung")
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onOpenStats) {
|
||||
Icon(Icons.Filled.BarChart, contentDescription = "Statistik")
|
||||
}
|
||||
IconButton(onClick = { picker.launch(arrayOf("application/json", "*/*")) }) {
|
||||
Icon(Icons.Filled.FileDownload, contentDescription = "Bibliothek importieren")
|
||||
}
|
||||
|
|
@ -142,6 +183,17 @@ fun LibraryScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showFilters) {
|
||||
FilterSheet(
|
||||
filter = state.filter,
|
||||
allGenres = state.allGenres,
|
||||
allFranchises = state.allFranchises,
|
||||
onChange = viewModel::updateFilter,
|
||||
onClear = viewModel::clearFilters,
|
||||
onDismiss = { showFilters = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
|
@ -194,3 +246,133 @@ private fun EmptyState(onImport: () -> Unit) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun FilterSheet(
|
||||
filter: LibraryFilter,
|
||||
allGenres: List<String>,
|
||||
allFranchises: List<String>,
|
||||
onChange: ((LibraryFilter) -> LibraryFilter) -> Unit,
|
||||
onClear: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Filter & Sortierung",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onClear) { Text("Zurücksetzen") }
|
||||
}
|
||||
|
||||
SectionTitle("Sortieren nach")
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SortMode.entries.forEach { mode ->
|
||||
FilterChip(
|
||||
selected = filter.sortMode == mode,
|
||||
onClick = { onChange { it.copy(sortMode = mode) } },
|
||||
label = { Text(mode.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Absteigend", modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurface)
|
||||
Switch(
|
||||
checked = filter.sortDescending,
|
||||
onCheckedChange = { v -> onChange { it.copy(sortDescending = v) } },
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
SectionTitle("Status")
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = filter.status == null,
|
||||
onClick = { onChange { it.copy(status = null) } },
|
||||
label = { Text("Alle") },
|
||||
)
|
||||
WatchStatus.entries.forEach { st ->
|
||||
FilterChip(
|
||||
selected = filter.status == st,
|
||||
onClick = { onChange { it.copy(status = if (it.status == st) null else st) } },
|
||||
label = { Text(st.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Nur Favoriten", modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurface)
|
||||
Switch(
|
||||
checked = filter.favoritesOnly,
|
||||
onCheckedChange = { v -> onChange { it.copy(favoritesOnly = v) } },
|
||||
)
|
||||
}
|
||||
|
||||
SectionTitle("Mindestbewertung: ${if (filter.minRating > 0) filter.minRating else "–"}")
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
listOf(0, 2, 4, 6, 8, 10).forEach { r ->
|
||||
FilterChip(
|
||||
selected = filter.minRating == r,
|
||||
onClick = { onChange { it.copy(minRating = r) } },
|
||||
label = { Text(if (r == 0) "Alle" else "$r+") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (allGenres.isNotEmpty()) {
|
||||
SectionTitle("Genres")
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
allGenres.forEach { g ->
|
||||
FilterChip(
|
||||
selected = g in filter.genres,
|
||||
onClick = {
|
||||
onChange {
|
||||
val next = if (g in it.genres) it.genres - g else it.genres + g
|
||||
it.copy(genres = next)
|
||||
}
|
||||
},
|
||||
label = { Text(g) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allFranchises.isNotEmpty()) {
|
||||
SectionTitle("Reihe / Franchise")
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
allFranchises.forEach { f ->
|
||||
FilterChip(
|
||||
selected = filter.franchise == f,
|
||||
onClick = { onChange { it.copy(franchise = if (it.franchise == f) null else f) } },
|
||||
label = { Text(f) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.umt.tracker.data.sync.SyncResult
|
|||
import com.umt.tracker.data.sync.SyncSettings
|
||||
import com.umt.tracker.domain.MediaItem
|
||||
import com.umt.tracker.domain.MediaType
|
||||
import com.umt.tracker.domain.WatchStatus
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
|
@ -19,10 +20,41 @@ import kotlinx.coroutines.flow.combine
|
|||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Sort options for the library grid, mirroring the desktop sort modes. */
|
||||
enum class SortMode(val label: String) {
|
||||
DATE_ADDED("Hinzugefügt"),
|
||||
TITLE("Titel"),
|
||||
RATING("Bewertung"),
|
||||
YEAR("Jahr"),
|
||||
PROGRESS("Fortschritt"),
|
||||
}
|
||||
|
||||
/** Active filter/sort selection, mirroring the desktop FilterCriteria. */
|
||||
data class LibraryFilter(
|
||||
val status: WatchStatus? = null,
|
||||
val favoritesOnly: Boolean = false,
|
||||
val genres: Set<String> = emptySet(),
|
||||
val franchise: String? = null,
|
||||
val minRating: Int = 0,
|
||||
val sortMode: SortMode = SortMode.DATE_ADDED,
|
||||
val sortDescending: Boolean = true,
|
||||
) {
|
||||
/** Number of active narrowing filters (sort excluded), for a badge. */
|
||||
val activeCount: Int
|
||||
get() = (if (status != null) 1 else 0) +
|
||||
(if (favoritesOnly) 1 else 0) +
|
||||
genres.size +
|
||||
(if (franchise != null) 1 else 0) +
|
||||
(if (minRating > 0) 1 else 0)
|
||||
}
|
||||
|
||||
data class LibraryUiState(
|
||||
val items: List<MediaItem> = emptyList(),
|
||||
val query: String = "",
|
||||
val typeFilter: MediaType? = null,
|
||||
val filter: LibraryFilter = LibraryFilter(),
|
||||
val allGenres: List<String> = emptyList(),
|
||||
val allFranchises: List<String> = emptyList(),
|
||||
val loading: Boolean = true,
|
||||
val syncEnabled: Boolean = false,
|
||||
)
|
||||
|
|
@ -35,21 +67,29 @@ class LibraryViewModel(
|
|||
|
||||
private val query = MutableStateFlow("")
|
||||
private val typeFilter = MutableStateFlow<MediaType?>(null)
|
||||
private val filter = MutableStateFlow(LibraryFilter())
|
||||
|
||||
/** One-shot messages (e.g. import result) for a snackbar. */
|
||||
val messages = MutableStateFlow<String?>(null)
|
||||
|
||||
val uiState: StateFlow<LibraryUiState> =
|
||||
combine(repo.observeLibrary(), query, typeFilter) { items, q, type ->
|
||||
val filtered = items.filter { item ->
|
||||
(type == null || item.type == type) &&
|
||||
(q.isBlank() || item.title.contains(q, ignoreCase = true) ||
|
||||
item.originalTitle.contains(q, ignoreCase = true))
|
||||
}
|
||||
combine(repo.observeLibrary(), query, typeFilter, filter) { items, q, type, f ->
|
||||
val filtered = items
|
||||
.filter { it.matches(q, type, f) }
|
||||
.sortedWith(f.comparator())
|
||||
// Facets reflect the whole library, not the filtered view.
|
||||
val genres = items.flatMap { it.genres }.distinct().sorted()
|
||||
val franchises = items.map { it.franchise }
|
||||
.filter { it.isNotBlank() }
|
||||
.groupingBy { it }.eachCount()
|
||||
.filterValues { it >= 2 }.keys.sorted()
|
||||
LibraryUiState(
|
||||
items = filtered,
|
||||
query = q,
|
||||
typeFilter = type,
|
||||
filter = f,
|
||||
allGenres = genres,
|
||||
allFranchises = franchises,
|
||||
loading = false,
|
||||
syncEnabled = syncSettings.isCloud && syncSettings.isConfigured,
|
||||
)
|
||||
|
|
@ -59,6 +99,30 @@ class LibraryViewModel(
|
|||
initialValue = LibraryUiState(),
|
||||
)
|
||||
|
||||
private fun MediaItem.matches(q: String, type: MediaType?, f: LibraryFilter): Boolean {
|
||||
if (type != null && this.type != type) return false
|
||||
if (q.isNotBlank() && !title.contains(q, true) &&
|
||||
!originalTitle.contains(q, true) && !overview.contains(q, true)
|
||||
) return false
|
||||
if (f.status != null && status != f.status) return false
|
||||
if (f.favoritesOnly && !favorite) return false
|
||||
if (f.minRating > 0 && rating < f.minRating) return false
|
||||
if (f.genres.isNotEmpty() && !f.genres.all { it in genres }) return false
|
||||
if (f.franchise != null && franchise != f.franchise) return false
|
||||
return true
|
||||
}
|
||||
|
||||
private fun LibraryFilter.comparator(): Comparator<MediaItem> {
|
||||
val base: Comparator<MediaItem> = when (sortMode) {
|
||||
SortMode.TITLE -> compareBy { it.title.lowercase() }
|
||||
SortMode.RATING -> compareBy { it.rating }
|
||||
SortMode.YEAR -> compareBy { it.year }
|
||||
SortMode.PROGRESS -> compareBy { it.progress }
|
||||
SortMode.DATE_ADDED -> compareBy { it.dateAdded }
|
||||
}
|
||||
return if (sortDescending) base.reversed() else base
|
||||
}
|
||||
|
||||
fun onQueryChange(value: String) {
|
||||
query.value = value
|
||||
}
|
||||
|
|
@ -67,6 +131,17 @@ class LibraryViewModel(
|
|||
typeFilter.value = type
|
||||
}
|
||||
|
||||
fun updateFilter(transform: (LibraryFilter) -> LibraryFilter) {
|
||||
filter.value = transform(filter.value)
|
||||
}
|
||||
|
||||
fun clearFilters() {
|
||||
filter.value = LibraryFilter(
|
||||
sortMode = filter.value.sortMode,
|
||||
sortDescending = filter.value.sortDescending,
|
||||
)
|
||||
}
|
||||
|
||||
fun import(uri: Uri, openStream: (Uri) -> java.io.InputStream?) {
|
||||
viewModelScope.launch {
|
||||
val stream = openStream(uri)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package com.umt.tracker.ui.search
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.umt.tracker.Graph
|
||||
import com.umt.tracker.providers.ProviderManager
|
||||
import com.umt.tracker.providers.SearchOutcome
|
||||
import com.umt.tracker.providers.SearchResult
|
||||
import com.umt.tracker.providers.toMediaItem
|
||||
import com.umt.tracker.domain.MediaType
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SearchUiState(
|
||||
val type: MediaType = MediaType.MOVIE,
|
||||
val query: String = "",
|
||||
val results: List<SearchResult> = emptyList(),
|
||||
val loading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val hint: String = "",
|
||||
val searched: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Drives the online metadata search for a single [MediaType]. On select it
|
||||
* enriches the chosen hit (e.g. TMDB series seasons), stashes the resulting draft
|
||||
* item in [Graph.editDraft], and hands its type back so navigation can open the
|
||||
* edit screen pre-filled.
|
||||
*/
|
||||
class SearchViewModel(
|
||||
private val providers: ProviderManager,
|
||||
type: MediaType,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(
|
||||
SearchUiState(type = type, hint = if (providers.isConfigured(type)) "" else providers.configHint(type)),
|
||||
)
|
||||
val state: StateFlow<SearchUiState> = _state.asStateFlow()
|
||||
|
||||
fun onQueryChange(value: String) {
|
||||
_state.value = _state.value.copy(query = value)
|
||||
}
|
||||
|
||||
fun search() {
|
||||
val s = _state.value
|
||||
if (s.query.isBlank() || s.loading) return
|
||||
_state.value = s.copy(loading = true, error = null)
|
||||
viewModelScope.launch {
|
||||
_state.value = when (val outcome = providers.search(s.query.trim(), s.type)) {
|
||||
is SearchOutcome.Success ->
|
||||
_state.value.copy(loading = false, results = outcome.results, searched = true)
|
||||
is SearchOutcome.Error ->
|
||||
_state.value.copy(loading = false, error = outcome.message, results = emptyList(), searched = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Enriches and stages the chosen result; invokes [onReady] with its id (0 = new). */
|
||||
fun choose(result: SearchResult, onReady: () -> Unit) {
|
||||
_state.value = _state.value.copy(loading = true)
|
||||
viewModelScope.launch {
|
||||
val full = providers.fetchDetails(result)
|
||||
Graph.editDraft = full.toMediaItem()
|
||||
_state.value = _state.value.copy(loading = false)
|
||||
onReady()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(type: MediaType) = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
SearchViewModel(Graph.providers, type) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 " +
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.umt.tracker.Graph
|
||||
import com.umt.tracker.data.AppPrefs
|
||||
import com.umt.tracker.data.sync.StorageMode
|
||||
import com.umt.tracker.data.sync.SyncClient
|
||||
import com.umt.tracker.data.sync.SyncResult
|
||||
|
|
@ -20,6 +21,10 @@ data class SettingsUiState(
|
|||
val bearerToken: String = "",
|
||||
val passphrase: String = "",
|
||||
val revision: Long = 0,
|
||||
val darkMode: Boolean = true,
|
||||
val tmdbApiKey: String = "",
|
||||
val rawgApiKey: String = "",
|
||||
val language: String = "de",
|
||||
val busy: Boolean = false,
|
||||
val message: String? = null,
|
||||
/** Set when a push hit a 409; the UI offers overwrite vs. pull. */
|
||||
|
|
@ -29,6 +34,7 @@ data class SettingsUiState(
|
|||
class SettingsViewModel(
|
||||
private val settings: SyncSettings,
|
||||
private val client: SyncClient,
|
||||
private val prefs: AppPrefs,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(
|
||||
|
|
@ -38,6 +44,10 @@ class SettingsViewModel(
|
|||
bearerToken = settings.bearerToken,
|
||||
passphrase = settings.passphrase,
|
||||
revision = settings.revision,
|
||||
darkMode = prefs.darkMode.value,
|
||||
tmdbApiKey = prefs.tmdbApiKey,
|
||||
rawgApiKey = prefs.rawgApiKey,
|
||||
language = prefs.language,
|
||||
)
|
||||
)
|
||||
val state: StateFlow<SettingsUiState> = _state.asStateFlow()
|
||||
|
|
@ -62,6 +72,26 @@ class SettingsViewModel(
|
|||
_state.update { it.copy(passphrase = value) }
|
||||
}
|
||||
|
||||
fun onDarkMode(value: Boolean) {
|
||||
prefs.setDarkMode(value)
|
||||
_state.update { it.copy(darkMode = value) }
|
||||
}
|
||||
|
||||
fun onTmdbApiKey(value: String) {
|
||||
prefs.tmdbApiKey = value
|
||||
_state.update { it.copy(tmdbApiKey = value) }
|
||||
}
|
||||
|
||||
fun onRawgApiKey(value: String) {
|
||||
prefs.rawgApiKey = value
|
||||
_state.update { it.copy(rawgApiKey = value) }
|
||||
}
|
||||
|
||||
fun onLanguage(value: String) {
|
||||
prefs.language = value
|
||||
_state.update { it.copy(language = value) }
|
||||
}
|
||||
|
||||
fun pull() = run { client.pull() }
|
||||
|
||||
fun push() = run { client.push(force = false) }
|
||||
|
|
@ -94,7 +124,7 @@ class SettingsViewModel(
|
|||
val Factory = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
SettingsViewModel(Graph.syncSettings, Graph.syncClient) as T
|
||||
SettingsViewModel(Graph.syncSettings, Graph.syncClient, Graph.appPrefs) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.umt.tracker.ui.stats
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.umt.tracker.Graph
|
||||
import com.umt.tracker.data.LibraryRepository
|
||||
import com.umt.tracker.data.LibraryStats
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class StatsViewModel(private val repo: LibraryRepository) : ViewModel() {
|
||||
|
||||
private val _stats = MutableStateFlow<LibraryStats?>(null)
|
||||
val stats: StateFlow<LibraryStats?> = _stats.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch { _stats.value = repo.stats() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
StatsViewModel(Graph.repository) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue