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