diff --git a/android/app/src/main/java/com/umt/tracker/data/LibraryRepository.kt b/android/app/src/main/java/com/umt/tracker/data/LibraryRepository.kt index c9685e6..ec3f088 100644 --- a/android/app/src/main/java/com/umt/tracker/data/LibraryRepository.kt +++ b/android/app/src/main/java/com/umt/tracker/data/LibraryRepository.kt @@ -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. */ diff --git a/android/app/src/main/java/com/umt/tracker/ui/UmtNavHost.kt b/android/app/src/main/java/com/umt/tracker/ui/UmtNavHost.kt index 2b80ceb..f23769b 100644 --- a/android/app/src/main/java/com/umt/tracker/ui/UmtNavHost.kt +++ b/android/app/src/main/java/com/umt/tracker/ui/UmtNavHost.kt @@ -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 } + } } }, ) diff --git a/android/app/src/main/java/com/umt/tracker/ui/detail/DetailScreen.kt b/android/app/src/main/java/com/umt/tracker/ui/detail/DetailScreen.kt index cc1a609..d9d7c19 100644 --- a/android/app/src/main/java/com/umt/tracker/ui/detail/DetailScreen.kt +++ b/android/app/src/main/java/com/umt/tracker/ui/detail/DetailScreen.kt @@ -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)) { diff --git a/android/app/src/main/java/com/umt/tracker/ui/search/SearchScreen.kt b/android/app/src/main/java/com/umt/tracker/ui/search/SearchScreen.kt index d747166..5395042 100644 --- a/android/app/src/main/java/com/umt/tracker/ui/search/SearchScreen.kt +++ b/android/app/src/main/java/com/umt/tracker/ui/search/SearchScreen.kt @@ -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") diff --git a/android/app/src/main/java/com/umt/tracker/ui/search/SearchViewModel.kt b/android/app/src/main/java/com/umt/tracker/ui/search/SearchViewModel.kt index ab4a5a7..3074f15 100644 --- a/android/app/src/main/java/com/umt/tracker/ui/search/SearchViewModel.kt +++ b/android/app/src/main/java/com/umt/tracker/ui/search/SearchViewModel.kt @@ -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 = _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 create(modelClass: Class): T = - SearchViewModel(Graph.providers, type) as T + SearchViewModel(Graph.providers, Graph.repository, type, initialQuery, coverForItemId) as T } } }