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)) }
|
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) }
|
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. */
|
/** Marks one unit (episode/chapter) watched or not, by its (segment, unit) number. */
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.umt.tracker.ui
|
package com.umt.tracker.ui
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
|
|
@ -18,12 +19,14 @@ 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 EDIT = "edit?itemId={itemId}"
|
||||||
const val SEARCH = "search/{type}"
|
const val SEARCH = "search/{type}?coverFor={coverFor}&q={q}"
|
||||||
const val SETTINGS = "settings"
|
const val SETTINGS = "settings"
|
||||||
const val STATS = "stats"
|
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 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
|
@Composable
|
||||||
|
|
@ -54,6 +57,9 @@ fun UmtNavHost() {
|
||||||
itemId = id,
|
itemId = id,
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
onEdit = { navController.navigate(Routes.edit(id)) },
|
onEdit = { navController.navigate(Routes.edit(id)) },
|
||||||
|
onChangeCover = { type, title ->
|
||||||
|
navController.navigate(Routes.changeCover(type, id, title))
|
||||||
|
},
|
||||||
onDeleted = { navController.popBackStack() },
|
onDeleted = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -71,17 +77,31 @@ fun UmtNavHost() {
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Routes.SEARCH,
|
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 ->
|
) { 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 type = runCatching { MediaType.valueOf(typeName) }.getOrDefault(MediaType.MOVIE)
|
||||||
|
val coverFor = args?.getLong("coverFor") ?: 0L
|
||||||
|
val query = args?.getString("q").orEmpty()
|
||||||
SearchScreen(
|
SearchScreen(
|
||||||
type = type,
|
type = type,
|
||||||
|
initialQuery = query,
|
||||||
|
coverForItemId = coverFor,
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
onPicked = {
|
onPicked = {
|
||||||
// Replace the current edit (if any) with a fresh, draft-filled one.
|
if (coverFor > 0) {
|
||||||
navController.navigate(Routes.edit()) {
|
// Cover-only change: just return to the detail screen.
|
||||||
popUpTo(Routes.EDIT) { inclusive = true }
|
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.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||||
|
import androidx.compose.material.icons.filled.Image
|
||||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
|
@ -54,6 +55,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
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.MediaType
|
||||||
import com.umt.tracker.domain.Segment
|
import com.umt.tracker.domain.Segment
|
||||||
import com.umt.tracker.domain.WatchStatus
|
import com.umt.tracker.domain.WatchStatus
|
||||||
import com.umt.tracker.domain.accentArgb
|
import com.umt.tracker.domain.accentArgb
|
||||||
|
|
@ -71,6 +73,7 @@ fun DetailScreen(
|
||||||
itemId: Long,
|
itemId: Long,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onEdit: () -> Unit,
|
onEdit: () -> Unit,
|
||||||
|
onChangeCover: (MediaType, String) -> Unit,
|
||||||
onDeleted: () -> Unit,
|
onDeleted: () -> Unit,
|
||||||
viewModel: DetailViewModel = viewModel(factory = DetailViewModel.factory(itemId)),
|
viewModel: DetailViewModel = viewModel(factory = DetailViewModel.factory(itemId)),
|
||||||
) {
|
) {
|
||||||
|
|
@ -115,7 +118,12 @@ fun DetailScreen(
|
||||||
Text("Nicht gefunden", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text("Nicht gefunden", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
} else {
|
} 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(
|
private fun DetailContent(
|
||||||
item: MediaItem,
|
item: MediaItem,
|
||||||
viewModel: DetailViewModel,
|
viewModel: DetailViewModel,
|
||||||
|
onChangeCover: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
// Seasons/volumes start collapsed for overview, mirroring the desktop app.
|
// Seasons/volumes start collapsed for overview, mirroring the desktop app.
|
||||||
|
|
@ -153,21 +162,38 @@ private fun DetailContent(
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Row {
|
Row {
|
||||||
Box(
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
modifier = Modifier
|
Box(
|
||||||
.width(120.dp)
|
modifier = Modifier
|
||||||
.height(180.dp)
|
.width(120.dp)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.height(180.dp)
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
.clip(RoundedCornerShape(12.dp))
|
||||||
contentAlignment = Alignment.Center,
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
) {
|
.clickable(onClick = onChangeCover),
|
||||||
if (item.coverUrl.isNotBlank()) {
|
contentAlignment = Alignment.Center,
|
||||||
AsyncImage(
|
) {
|
||||||
model = item.coverUrl,
|
if (item.coverUrl.isNotBlank()) {
|
||||||
contentDescription = item.title,
|
AsyncImage(
|
||||||
contentScale = ContentScale.Crop,
|
model = item.coverUrl,
|
||||||
modifier = Modifier.fillMaxSize(),
|
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)) {
|
Column(modifier = Modifier.padding(start = 16.dp)) {
|
||||||
|
|
|
||||||
|
|
@ -51,14 +51,18 @@ fun SearchScreen(
|
||||||
type: MediaType,
|
type: MediaType,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onPicked: () -> 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()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("${type.labelPlural} suchen") },
|
title = { Text(if (state.coverMode) "Cover ändern" else "${type.labelPlural} suchen") },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
|
||||||
|
|
|
||||||
|
|
@ -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.LibraryRepository
|
||||||
import com.umt.tracker.providers.ProviderManager
|
import com.umt.tracker.providers.ProviderManager
|
||||||
import com.umt.tracker.providers.SearchOutcome
|
import com.umt.tracker.providers.SearchOutcome
|
||||||
import com.umt.tracker.providers.SearchResult
|
import com.umt.tracker.providers.SearchResult
|
||||||
|
|
@ -22,24 +23,40 @@ data class SearchUiState(
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val hint: String = "",
|
val hint: String = "",
|
||||||
val searched: Boolean = false,
|
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
|
* Drives the online metadata search for a single [MediaType]. In the default
|
||||||
* enriches the chosen hit (e.g. TMDB series seasons), stashes the resulting draft
|
* (add) mode a chosen hit is enriched (e.g. TMDB series seasons), stashed as a
|
||||||
* item in [Graph.editDraft], and hands its type back so navigation can open the
|
* draft in [Graph.editDraft], and the edit screen opens pre-filled. In cover mode
|
||||||
* edit screen pre-filled.
|
* ([coverForItemId] > 0) only the chosen hit's cover URL is applied to that
|
||||||
|
* existing item — mirroring the desktop "Cover ändern…" action.
|
||||||
*/
|
*/
|
||||||
class SearchViewModel(
|
class SearchViewModel(
|
||||||
private val providers: ProviderManager,
|
private val providers: ProviderManager,
|
||||||
|
private val repo: LibraryRepository,
|
||||||
type: MediaType,
|
type: MediaType,
|
||||||
|
initialQuery: String,
|
||||||
|
private val coverForItemId: Long,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _state = MutableStateFlow(
|
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()
|
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) {
|
fun onQueryChange(value: String) {
|
||||||
_state.value = _state.value.copy(query = value)
|
_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) {
|
fun choose(result: SearchResult, onReady: () -> Unit) {
|
||||||
_state.value = _state.value.copy(loading = true)
|
_state.value = _state.value.copy(loading = true)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val full = providers.fetchDetails(result)
|
if (coverForItemId > 0) {
|
||||||
Graph.editDraft = full.toMediaItem()
|
repo.setCover(coverForItemId, result.coverUrl)
|
||||||
|
} else {
|
||||||
|
val full = providers.fetchDetails(result)
|
||||||
|
Graph.editDraft = full.toMediaItem()
|
||||||
|
}
|
||||||
_state.value = _state.value.copy(loading = false)
|
_state.value = _state.value.copy(loading = false)
|
||||||
onReady()
|
onReady()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun factory(type: MediaType) = object : ViewModelProvider.Factory {
|
fun factory(
|
||||||
|
type: MediaType,
|
||||||
|
initialQuery: String = "",
|
||||||
|
coverForItemId: Long = 0,
|
||||||
|
) = 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 =
|
||||||
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