feat: add genre picker functionality to collection editor

This commit is contained in:
tapframe 2026-04-15 17:43:02 +05:30
parent 7727436272
commit 9902bbd3ef
2 changed files with 536 additions and 313 deletions

View file

@ -23,6 +23,7 @@ data class CollectionEditorUiState(
val editingFolder: CollectionFolder? = null, val editingFolder: CollectionFolder? = null,
val showFolderEditor: Boolean = false, val showFolderEditor: Boolean = false,
val showCatalogPicker: Boolean = false, val showCatalogPicker: Boolean = false,
val genrePickerSourceIndex: Int? = null,
) )
object CollectionEditorRepository { object CollectionEditorRepository {
@ -224,6 +225,7 @@ object CollectionEditorRepository {
editingFolder = folder.copy( editingFolder = folder.copy(
catalogSources = folder.catalogSources.toMutableList().apply { removeAt(index) }, catalogSources = folder.catalogSources.toMutableList().apply { removeAt(index) },
), ),
genrePickerSourceIndex = null,
) )
} }
@ -250,13 +252,29 @@ object CollectionEditorRepository {
} }
fun showCatalogPicker() { fun showCatalogPicker() {
_uiState.value = _uiState.value.copy(showCatalogPicker = true) _uiState.value = _uiState.value.copy(
showCatalogPicker = true,
genrePickerSourceIndex = null,
)
} }
fun hideCatalogPicker() { fun hideCatalogPicker() {
_uiState.value = _uiState.value.copy(showCatalogPicker = false) _uiState.value = _uiState.value.copy(showCatalogPicker = false)
} }
fun showGenrePicker(index: Int) {
val folder = _uiState.value.editingFolder ?: return
if (index !in folder.catalogSources.indices) return
_uiState.value = _uiState.value.copy(
genrePickerSourceIndex = index,
showCatalogPicker = false,
)
}
fun hideGenrePicker() {
_uiState.value = _uiState.value.copy(genrePickerSourceIndex = null)
}
fun saveFolderEdit() { fun saveFolderEdit() {
val folder = _uiState.value.editingFolder ?: return val folder = _uiState.value.editingFolder ?: return
val existing = _uiState.value.folders val existing = _uiState.value.folders
@ -270,6 +288,7 @@ object CollectionEditorRepository {
editingFolder = null, editingFolder = null,
showFolderEditor = false, showFolderEditor = false,
showCatalogPicker = false, showCatalogPicker = false,
genrePickerSourceIndex = null,
) )
} }
@ -278,6 +297,7 @@ object CollectionEditorRepository {
editingFolder = null, editingFolder = null,
showFolderEditor = false, showFolderEditor = false,
showCatalogPicker = false, showCatalogPicker = false,
genrePickerSourceIndex = null,
) )
} }

View file

@ -8,7 +8,6 @@ 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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -36,7 +35,6 @@ import androidx.compose.material3.FilterChip
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.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.SwitchDefaults
@ -57,6 +55,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import com.nuvio.app.core.ui.NuvioInputField import com.nuvio.app.core.ui.NuvioInputField
import com.nuvio.app.core.ui.NuvioModalBottomSheet
import com.nuvio.app.core.ui.NuvioPrimaryButton import com.nuvio.app.core.ui.NuvioPrimaryButton
import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioScreenHeader
@ -81,11 +80,49 @@ fun CollectionEditorScreen(
CollectionEditorRepository.initialize(collectionId) CollectionEditorRepository.initialize(collectionId)
} }
if (state.showFolderEditor && state.editingFolder != null) { val editingFolder = state.editingFolder
FolderEditorSheet( if (state.showFolderEditor && editingFolder != null) {
val genrePickerIndex = state.genrePickerSourceIndex
val genrePickerSource = genrePickerIndex?.let { editingFolder.catalogSources.getOrNull(it) }
val genrePickerCatalog = genrePickerSource?.let { source ->
state.availableCatalogs.find {
it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId
}
}
FolderEditorPage(
state = state, state = state,
onDismiss = { CollectionEditorRepository.cancelFolderEdit() }, onBack = { CollectionEditorRepository.cancelFolderEdit() },
) )
if (state.showCatalogPicker) {
CatalogPickerSheet(
availableCatalogs = state.availableCatalogs,
selectedSources = editingFolder.catalogSources,
onToggle = { CollectionEditorRepository.toggleCatalogSource(it) },
onDismiss = { CollectionEditorRepository.hideCatalogPicker() },
)
}
if (
genrePickerIndex != null &&
genrePickerSource != null &&
genrePickerCatalog != null &&
genrePickerCatalog.genreOptions.isNotEmpty()
) {
GenrePickerSheet(
title = genrePickerCatalog.catalogName,
selectedGenre = genrePickerSource.genre,
genreOptions = genrePickerCatalog.genreOptions,
allowAll = !genrePickerCatalog.genreRequired,
onSelect = {
CollectionEditorRepository.updateCatalogSourceGenre(genrePickerIndex, it)
CollectionEditorRepository.hideGenrePicker()
},
onDismiss = { CollectionEditorRepository.hideGenrePicker() },
)
}
return
} }
if (state.showCatalogPicker) { if (state.showCatalogPicker) {
@ -494,290 +531,203 @@ private fun FolderListItem(
} }
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun FolderEditorSheet( private fun FolderEditorPage(
state: CollectionEditorUiState, state: CollectionEditorUiState,
onDismiss: () -> Unit, onBack: () -> Unit,
) { ) {
val folder = state.editingFolder ?: return val folder = state.editingFolder ?: return
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val bottomInset = nuvioPlatformExtraBottomPadding
ModalBottomSheet( Box(modifier = Modifier.fillMaxSize()) {
onDismissRequest = onDismiss, NuvioScreen(modifier = Modifier.fillMaxSize()) {
sheetState = sheetState, stickyHeader {
containerColor = MaterialTheme.colorScheme.surface, NuvioScreenHeader(
) { title = if (state.folders.any { it.id == folder.id }) "Edit Folder" else "New Folder",
LazyColumn( onBack = onBack,
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item {
Text(
text = "Edit Folder",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
) )
} }
item { item {
NuvioInputField(
value = folder.title,
onValueChange = { CollectionEditorRepository.updateFolderTitle(it) },
placeholder = "Folder Title",
)
}
// Cover (emoji or image url)
item {
Column {
Text(
text = "Cover",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = folder.coverEmoji == null && folder.coverImageUrl == null,
onClick = { CollectionEditorRepository.clearFolderCover() },
label = { Text("None") },
)
FilterChip(
selected = folder.coverEmoji != null,
onClick = {
if (folder.coverEmoji == null) {
CollectionEditorRepository.updateFolderCoverEmoji("📁")
}
},
label = { Text("Emoji") },
)
FilterChip(
selected = folder.coverImageUrl != null,
onClick = {
if (folder.coverImageUrl == null) {
CollectionEditorRepository.updateFolderCoverImage("")
}
},
label = { Text("Image") },
)
}
if (folder.coverEmoji != null) {
Spacer(modifier = Modifier.height(8.dp))
NuvioInputField(
value = folder.coverEmoji,
onValueChange = { CollectionEditorRepository.updateFolderCoverEmoji(it) },
placeholder = "Emoji",
modifier = Modifier.width(100.dp),
)
}
if (folder.coverImageUrl != null) {
Spacer(modifier = Modifier.height(8.dp))
NuvioInputField(
value = folder.coverImageUrl,
onValueChange = { CollectionEditorRepository.updateFolderCoverImage(it) },
placeholder = "Image URL",
)
}
Spacer(modifier = Modifier.height(12.dp))
NuvioInputField(
value = folder.focusGifUrl.orEmpty(),
onValueChange = { CollectionEditorRepository.updateFolderFocusGifUrl(it) },
placeholder = "Always-play GIF URL (optional)",
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
CollectionEditorRepository.updateFolderFocusGifEnabled(!folder.focusGifEnabled)
}
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Show GIF When Configured",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
Switch(
checked = folder.focusGifEnabled,
onCheckedChange = { CollectionEditorRepository.updateFolderFocusGifEnabled(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
checkedTrackColor = MaterialTheme.colorScheme.primary,
),
)
}
}
}
// Tile Shape
item {
Column {
Text(
text = "Tile Shape",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(8.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PosterShape.entries.forEach { shape ->
FilterChip(
selected = folder.posterShape == shape,
onClick = { CollectionEditorRepository.updateFolderTileShape(shape) },
label = { Text(shape.name) },
leadingIcon = if (folder.posterShape == shape) {
{
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
} else null,
)
}
}
}
}
// Hide Title
item {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { CollectionEditorRepository.updateFolderHideTitle(!folder.hideTitle) }
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Hide Title",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
Switch(
checked = folder.hideTitle,
onCheckedChange = { CollectionEditorRepository.updateFolderHideTitle(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
checkedTrackColor = MaterialTheme.colorScheme.primary,
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant,
),
)
}
}
// Catalog Sources
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
NuvioSectionLabel(text = "CATALOG SOURCES")
TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text("Add")
}
}
}
itemsIndexed(folder.catalogSources) { index, source ->
val matchingCatalog = state.availableCatalogs.find {
it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId
}
val genreOptions = matchingCatalog?.genreOptions.orEmpty()
NuvioSurfaceCard { NuvioSurfaceCard {
Row( Text(
verticalAlignment = Alignment.CenterVertically, text = "Set the folder identity, presentation, and catalog sources with the same structure as the main collections editor.",
) { style = MaterialTheme.typography.bodyLarge,
Column(modifier = Modifier.weight(1f)) { color = MaterialTheme.colorScheme.onSurfaceVariant,
Text( )
text = "${source.catalogId} (${source.type})", }
style = MaterialTheme.typography.bodyLarge, }
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface, item {
maxLines = 1, FolderEditorSection(title = "BASICS") {
overflow = TextOverflow.Ellipsis, NuvioSurfaceCard {
) NuvioInputField(
Text( value = folder.title,
text = buildString { onValueChange = { CollectionEditorRepository.updateFolderTitle(it) },
append(source.addonId) placeholder = "Folder Title",
if (source.genre != null) append(" · ${source.genre}") )
}, }
style = MaterialTheme.typography.bodyMedium, }
color = MaterialTheme.colorScheme.onSurfaceVariant, }
maxLines = 1,
overflow = TextOverflow.Ellipsis, item {
) FolderEditorSection(title = "APPEARANCE") {
} NuvioSurfaceCard {
IconButton( Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
onClick = { CollectionEditorRepository.removeCatalogSource(index) }, Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
modifier = Modifier.size(36.dp), Text(
) { text = "Cover",
Icon( style = MaterialTheme.typography.bodyLarge,
imageVector = Icons.Rounded.Close, fontWeight = FontWeight.Medium,
contentDescription = "Remove", color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp), )
tint = MaterialTheme.colorScheme.error, FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = folder.coverEmoji == null && folder.coverImageUrl == null,
onClick = { CollectionEditorRepository.clearFolderCover() },
label = { Text("None") },
)
FilterChip(
selected = folder.coverEmoji != null,
onClick = {
if (folder.coverEmoji == null) {
CollectionEditorRepository.updateFolderCoverEmoji("📁")
}
},
label = { Text("Emoji") },
)
FilterChip(
selected = folder.coverImageUrl != null,
onClick = {
if (folder.coverImageUrl == null) {
CollectionEditorRepository.updateFolderCoverImage("")
}
},
label = { Text("Image") },
)
}
}
if (folder.coverEmoji != null) {
NuvioInputField(
value = folder.coverEmoji,
onValueChange = { CollectionEditorRepository.updateFolderCoverEmoji(it) },
placeholder = "Emoji",
modifier = Modifier.width(100.dp),
)
}
if (folder.coverImageUrl != null) {
NuvioInputField(
value = folder.coverImageUrl,
onValueChange = { CollectionEditorRepository.updateFolderCoverImage(it) },
placeholder = "Image URL",
)
}
NuvioInputField(
value = folder.focusGifUrl.orEmpty(),
onValueChange = { CollectionEditorRepository.updateFolderFocusGifUrl(it) },
placeholder = "Always-play GIF URL (optional)",
) )
} }
} }
if (genreOptions.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp)) NuvioSurfaceCard {
Text( Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
text = "Genre", Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
style = MaterialTheme.typography.bodyMedium, Text(
fontWeight = FontWeight.Medium, text = "Tile Shape",
color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodyLarge,
) fontWeight = FontWeight.Medium,
Spacer(modifier = Modifier.height(4.dp)) color = MaterialTheme.colorScheme.onSurface,
FlowRow( )
horizontalArrangement = Arrangement.spacedBy(6.dp), FlowRow(
verticalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { verticalArrangement = Arrangement.spacedBy(8.dp),
FilterChip( ) {
selected = source.genre == null, PosterShape.entries.forEach { shape ->
onClick = { CollectionEditorRepository.updateCatalogSourceGenre(index, null) }, FilterChip(
label = { Text("All") }, selected = folder.posterShape == shape,
leadingIcon = if (source.genre == null) { onClick = { CollectionEditorRepository.updateFolderTileShape(shape) },
{ label = { Text(shape.name) },
Icon( leadingIcon = if (folder.posterShape == shape) {
imageVector = Icons.Rounded.Check, {
contentDescription = null, Icon(
modifier = Modifier.size(18.dp), imageVector = Icons.Rounded.Check,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
} else null,
) )
} }
} else null, }
}
FolderEditorToggleRow(
title = "Show GIF When Configured",
subtitle = "Play the configured GIF instead of the static cover when available.",
checked = folder.focusGifEnabled,
onCheckedChange = { CollectionEditorRepository.updateFolderFocusGifEnabled(it) },
) )
genreOptions.forEach { genre ->
FilterChip( FolderEditorToggleRow(
selected = source.genre == genre, title = "Hide Title",
onClick = { CollectionEditorRepository.updateCatalogSourceGenre(index, genre) }, subtitle = "Only show the artwork or emoji for this folder tile.",
label = { Text(genre) }, checked = folder.hideTitle,
leadingIcon = if (source.genre == genre) { onCheckedChange = { CollectionEditorRepository.updateFolderHideTitle(it) },
{ )
Icon( }
imageVector = Icons.Rounded.Check, }
contentDescription = null, }
modifier = Modifier.size(18.dp), }
)
} item {
} else null, FolderEditorSection(
title = "CATALOG SOURCES",
actions = {
TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text("Add")
}
},
) {
if (folder.catalogSources.isEmpty()) {
NuvioSurfaceCard {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "No catalog sources yet",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Add catalogs from your installed addons to define what this folder shows.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
folder.catalogSources.forEachIndexed { index, source ->
FolderCatalogSourceCard(
source = source,
matchingCatalog = state.availableCatalogs.find {
it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId
},
onRemove = { CollectionEditorRepository.removeCatalogSource(index) },
onOpenGenrePicker = { CollectionEditorRepository.showGenrePicker(index) },
) )
} }
} }
@ -785,45 +735,30 @@ private fun FolderEditorSheet(
} }
} }
if (folder.catalogSources.isEmpty()) {
item {
Text(
text = "No catalog sources. Tap \"Add\" to select from installed addons.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp),
)
}
}
// Save / Cancel
item { item {
Column( Spacer(modifier = Modifier.height(96.dp + bottomInset))
modifier = Modifier }
.fillMaxWidth() }
.padding(top = 8.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), Surface(
) { modifier = Modifier
NuvioPrimaryButton( .align(Alignment.BottomCenter)
text = "Save Folder", .fillMaxWidth(),
enabled = folder.title.isNotBlank(), color = MaterialTheme.colorScheme.background.copy(alpha = 0.96f),
onClick = { CollectionEditorRepository.saveFolderEdit() }, tonalElevation = 6.dp,
) shadowElevation = 10.dp,
androidx.compose.material3.Button( ) {
onClick = { CollectionEditorRepository.cancelFolderEdit() }, Box(
modifier = Modifier.fillMaxWidth().height(52.dp), modifier = Modifier
shape = RoundedCornerShape(16.dp), .fillMaxWidth()
colors = ButtonDefaults.buttonColors( .padding(horizontal = 16.dp, vertical = 12.dp)
containerColor = MaterialTheme.colorScheme.surfaceVariant, .padding(bottom = bottomInset),
contentColor = MaterialTheme.colorScheme.onSurface, ) {
), NuvioPrimaryButton(
) { text = "Save Folder",
Text( enabled = folder.title.isNotBlank(),
text = "Cancel", onClick = { CollectionEditorRepository.saveFolderEdit() },
style = MaterialTheme.typography.titleMedium, )
)
}
}
} }
} }
} }
@ -839,7 +774,7 @@ private fun CatalogPickerSheet(
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet( NuvioModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
sheetState = sheetState, sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
@ -866,6 +801,12 @@ private fun CatalogPickerSheet(
Text("Done") Text("Done")
} }
} }
Text(
text = "Choose the addon catalogs this folder should aggregate.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
} }
val grouped = availableCatalogs.groupBy { it.addonName } val grouped = availableCatalogs.groupBy { it.addonName }
@ -936,3 +877,265 @@ private fun CatalogPickerSheet(
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun GenrePickerSheet(
title: String,
selectedGenre: String?,
genreOptions: List<String>,
allowAll: Boolean,
onSelect: (String?) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
NuvioModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
) {
LazyColumn(
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = "Genre Filter",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (allowAll) {
item {
GenrePickerOptionRow(
title = "All genres",
selected = selectedGenre == null,
onClick = { onSelect(null) },
)
}
}
itemsIndexed(genreOptions) { _, genre ->
GenrePickerOptionRow(
title = genre,
selected = selectedGenre == genre,
onClick = { onSelect(genre) },
)
}
item {
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
@Composable
private fun FolderEditorSection(
title: String,
actions: @Composable (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
NuvioSectionLabel(text = title)
actions?.invoke()
}
content()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FolderEditorToggleRow(
title: String,
subtitle: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onCheckedChange(!checked) },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
checkedTrackColor = MaterialTheme.colorScheme.primary,
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant,
),
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun FolderCatalogSourceCard(
source: CollectionCatalogSource,
matchingCatalog: AvailableCatalog?,
onRemove: () -> Unit,
onOpenGenrePicker: () -> Unit,
) {
val typeLabel = source.type.replaceFirstChar {
if (it.isLowerCase()) it.titlecase() else it.toString()
}
val metaLine = buildString {
append(typeLabel)
append(" · ${source.catalogId}")
}
val genreOptions = matchingCatalog?.genreOptions.orEmpty()
val selectedGenreLabel = source.genre ?: if (matchingCatalog?.genreRequired == true) "Select genre" else "All genres"
NuvioSurfaceCard {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = matchingCatalog?.catalogName ?: source.catalogId,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = matchingCatalog?.addonName ?: source.addonId,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(
onClick = onRemove,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = "Remove",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error,
)
}
}
Text(
text = metaLine,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (genreOptions.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpenGenrePicker),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = "Genre Filter",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = selectedGenreLabel,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onOpenGenrePicker) {
Text("Choose")
}
}
}
}
}
}
@Composable
private fun GenrePickerOptionRow(
title: String,
selected: Boolean,
onClick: () -> Unit,
) {
val bgColor = if (selected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
}
val borderColor = if (selected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
} else {
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(bgColor)
.border(1.dp, borderColor, RoundedCornerShape(10.dp))
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
if (selected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
}
}