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:
parent
c0389bd8d2
commit
85f5c6dd4e
5 changed files with 116 additions and 34 deletions
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue