feat(android): add "change cover" action on the detail screen

Mirrors the desktop DetailDialog's "Cover ändern…" button: opens the
online search pre-filled with the item's title and auto-searches, but
choosing a hit only replaces the item's cover URL instead of staging a
full edit draft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tronax 2026-07-14 21:59:22 +02:00
parent c0389bd8d2
commit 85f5c6dd4e
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
5 changed files with 116 additions and 34 deletions

View file

@ -78,6 +78,9 @@ class LibraryRepository(
suspend fun setRating(id: Long, rating: Int) = updateItem(id) { it.copy(rating = rating.coerceIn(0, 10)) }
/** Replaces the item's cover with a remote image URL (from a metadata provider). */
suspend fun setCover(id: Long, coverUrl: String) = updateItem(id) { it.copy(coverUrl = coverUrl) }
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. */

View file

@ -1,5 +1,6 @@
package com.umt.tracker.ui
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
@ -18,12 +19,14 @@ private object Routes {
const val LIBRARY = "library"
const val DETAIL = "detail/{itemId}"
const val EDIT = "edit?itemId={itemId}"
const val SEARCH = "search/{type}"
const val SEARCH = "search/{type}?coverFor={coverFor}&q={q}"
const val SETTINGS = "settings"
const val STATS = "stats"
fun detail(id: Long) = "detail/$id"
fun edit(id: Long = 0) = "edit?itemId=$id"
fun search(type: MediaType) = "search/${type.name}"
fun search(type: MediaType) = "search/${type.name}?coverFor=0&q="
fun changeCover(type: MediaType, itemId: Long, title: String) =
"search/${type.name}?coverFor=$itemId&q=${Uri.encode(title)}"
}
@Composable
@ -54,6 +57,9 @@ fun UmtNavHost() {
itemId = id,
onBack = { navController.popBackStack() },
onEdit = { navController.navigate(Routes.edit(id)) },
onChangeCover = { type, title ->
navController.navigate(Routes.changeCover(type, id, title))
},
onDeleted = { navController.popBackStack() },
)
}
@ -71,17 +77,31 @@ fun UmtNavHost() {
}
composable(
route = Routes.SEARCH,
arguments = listOf(navArgument("type") { type = NavType.StringType }),
arguments = listOf(
navArgument("type") { type = NavType.StringType },
navArgument("coverFor") { type = NavType.LongType; defaultValue = 0L },
navArgument("q") { type = NavType.StringType; defaultValue = "" },
),
) { backStackEntry ->
val typeName = backStackEntry.arguments?.getString("type") ?: MediaType.MOVIE.name
val args = backStackEntry.arguments
val typeName = args?.getString("type") ?: MediaType.MOVIE.name
val type = runCatching { MediaType.valueOf(typeName) }.getOrDefault(MediaType.MOVIE)
val coverFor = args?.getLong("coverFor") ?: 0L
val query = args?.getString("q").orEmpty()
SearchScreen(
type = type,
initialQuery = query,
coverForItemId = coverFor,
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 }
if (coverFor > 0) {
// Cover-only change: just return to the detail screen.
navController.popBackStack()
} else {
// Replace the current edit (if any) with a fresh, draft-filled one.
navController.navigate(Routes.edit()) {
popUpTo(Routes.EDIT) { inclusive = true }
}
}
},
)

View file

@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
@ -54,6 +55,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.umt.tracker.domain.MediaItem
import com.umt.tracker.domain.MediaType
import com.umt.tracker.domain.Segment
import com.umt.tracker.domain.WatchStatus
import com.umt.tracker.domain.accentArgb
@ -71,6 +73,7 @@ fun DetailScreen(
itemId: Long,
onBack: () -> Unit,
onEdit: () -> Unit,
onChangeCover: (MediaType, String) -> Unit,
onDeleted: () -> Unit,
viewModel: DetailViewModel = viewModel(factory = DetailViewModel.factory(itemId)),
) {
@ -115,7 +118,12 @@ fun DetailScreen(
Text("Nicht gefunden", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else {
DetailContent(current, viewModel, Modifier.padding(padding))
DetailContent(
item = current,
viewModel = viewModel,
onChangeCover = { onChangeCover(current.type, current.title) },
modifier = Modifier.padding(padding),
)
}
}
@ -141,6 +149,7 @@ fun DetailScreen(
private fun DetailContent(
item: MediaItem,
viewModel: DetailViewModel,
onChangeCover: () -> Unit,
modifier: Modifier = Modifier,
) {
// Seasons/volumes start collapsed for overview, mirroring the desktop app.
@ -153,21 +162,38 @@ private fun DetailContent(
) {
item {
Row {
Box(
modifier = Modifier
.width(120.dp)
.height(180.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (item.coverUrl.isNotBlank()) {
AsyncImage(
model = item.coverUrl,
contentDescription = item.title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.width(120.dp)
.height(180.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onChangeCover),
contentAlignment = Alignment.Center,
) {
if (item.coverUrl.isNotBlank()) {
AsyncImage(
model = item.coverUrl,
contentDescription = item.title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Icon(
Icons.Filled.Image,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextButton(onClick = onChangeCover) {
Icon(
Icons.Filled.Image,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Text("Cover ändern", modifier = Modifier.padding(start = 6.dp))
}
}
Column(modifier = Modifier.padding(start = 16.dp)) {

View file

@ -51,14 +51,18 @@ fun SearchScreen(
type: MediaType,
onBack: () -> Unit,
onPicked: () -> Unit,
viewModel: SearchViewModel = viewModel(factory = SearchViewModel.factory(type)),
initialQuery: String = "",
coverForItemId: Long = 0,
viewModel: SearchViewModel = viewModel(
factory = SearchViewModel.factory(type, initialQuery, coverForItemId),
),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("${type.labelPlural} suchen") },
title = { Text(if (state.coverMode) "Cover ändern" else "${type.labelPlural} suchen") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")

View file

@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.umt.tracker.Graph
import com.umt.tracker.data.LibraryRepository
import com.umt.tracker.providers.ProviderManager
import com.umt.tracker.providers.SearchOutcome
import com.umt.tracker.providers.SearchResult
@ -22,24 +23,40 @@ data class SearchUiState(
val error: String? = null,
val hint: String = "",
val searched: Boolean = false,
/** When true, a chosen hit only replaces the cover of [coverForItemId]. */
val coverMode: 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.
* Drives the online metadata search for a single [MediaType]. In the default
* (add) mode a chosen hit is enriched (e.g. TMDB series seasons), stashed as a
* draft in [Graph.editDraft], and the edit screen opens pre-filled. In cover mode
* ([coverForItemId] > 0) only the chosen hit's cover URL is applied to that
* existing item mirroring the desktop "Cover ändern…" action.
*/
class SearchViewModel(
private val providers: ProviderManager,
private val repo: LibraryRepository,
type: MediaType,
initialQuery: String,
private val coverForItemId: Long,
) : ViewModel() {
private val _state = MutableStateFlow(
SearchUiState(type = type, hint = if (providers.isConfigured(type)) "" else providers.configHint(type)),
SearchUiState(
type = type,
query = initialQuery,
hint = if (providers.isConfigured(type)) "" else providers.configHint(type),
coverMode = coverForItemId > 0,
),
)
val state: StateFlow<SearchUiState> = _state.asStateFlow()
init {
// Pre-filled title (cover mode) searches immediately, like the desktop dialog.
if (initialQuery.isNotBlank()) search()
}
fun onQueryChange(value: String) {
_state.value = _state.value.copy(query = value)
}
@ -58,22 +75,34 @@ class SearchViewModel(
}
}
/** Enriches and stages the chosen result; invokes [onReady] with its id (0 = new). */
/**
* Applies the chosen result: in cover mode it replaces the existing item's
* cover; otherwise it enriches and stages a draft for the edit screen. Invokes
* [onReady] once done.
*/
fun choose(result: SearchResult, onReady: () -> Unit) {
_state.value = _state.value.copy(loading = true)
viewModelScope.launch {
val full = providers.fetchDetails(result)
Graph.editDraft = full.toMediaItem()
if (coverForItemId > 0) {
repo.setCover(coverForItemId, result.coverUrl)
} else {
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 {
fun factory(
type: MediaType,
initialQuery: String = "",
coverForItemId: Long = 0,
) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
SearchViewModel(Graph.providers, type) as T
SearchViewModel(Graph.providers, Graph.repository, type, initialQuery, coverForItemId) as T
}
}
}