ref: ui adjustments

This commit is contained in:
tapframe 2026-04-07 01:16:01 +05:30
parent 44bb940bc0
commit 9de4e30633
8 changed files with 766 additions and 720 deletions

View file

@ -1155,6 +1155,7 @@ private fun MainAppContent(
FolderDetailRepository.clear()
navController.popBackStack()
},
onCatalogClick = onCatalogClick,
onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
},

View file

@ -105,6 +105,7 @@ fun NuvioSurfaceCard(
fun NuvioScreenHeader(
title: String,
modifier: Modifier = Modifier,
includeStatusBarPadding: Boolean = true,
onBack: (() -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {},
) {
@ -113,7 +114,7 @@ fun NuvioScreenHeader(
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.padding(top = statusBarTop, bottom = 4.dp),
.padding(top = if (includeStatusBarPadding) statusBarTop else 0.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
) {

View file

@ -131,10 +131,12 @@ object CatalogRepository {
} else {
mergeCatalogItems(_uiState.value.items, page.items)
}
val supportsPagination = request.supportsPagination || page.rawItemCount >= CATALOG_PAGE_SIZE
val loadedNewItems = reset || mergedItems.size > current.items.size
_uiState.value = CatalogUiState(
items = mergedItems,
isLoading = false,
nextSkip = if (request.supportsPagination) page.nextSkip else null,
nextSkip = if (supportsPagination && loadedNewItems) page.nextSkip else null,
errorMessage = null,
)
},

View file

@ -11,20 +11,15 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ArrowDownward
import androidx.compose.material.icons.rounded.ArrowUpward
@ -32,17 +27,13 @@ import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
@ -60,6 +51,12 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.nuvio.app.core.ui.NuvioInputField
import com.nuvio.app.core.ui.NuvioPrimaryButton
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.NuvioSectionLabel
import com.nuvio.app.core.ui.NuvioSurfaceCard
import com.nuvio.app.features.home.PosterShape
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@ -74,8 +71,6 @@ fun CollectionEditorScreen(
CollectionEditorRepository.initialize(collectionId)
}
val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
if (state.showFolderEditor && state.editingFolder != null) {
FolderEditorSheet(
state = state,
@ -92,201 +87,171 @@ fun CollectionEditorScreen(
)
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentPadding = PaddingValues(
start = 16.dp,
top = 10.dp + statusBarTop,
end = 16.dp,
bottom = 18.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
NuvioScreen {
stickyHeader {
NuvioScreenHeader(
title = if (state.isNew) "New Collection" else "Edit Collection",
onBack = onBack,
)
}
// Title
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onBackground,
NuvioInputField(
value = state.title,
onValueChange = { CollectionEditorRepository.setTitle(it) },
placeholder = "Collection Title",
)
}
// Backdrop URL
item {
NuvioInputField(
value = state.backdropImageUrl,
onValueChange = { CollectionEditorRepository.setBackdropImageUrl(it) },
placeholder = "Backdrop Image URL (optional)",
)
}
// Pin to Top
item {
NuvioSurfaceCard {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { CollectionEditorRepository.setPinToTop(!state.pinToTop) },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = "Pin to Top",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Display this collection above regular catalog rows.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = state.pinToTop,
onCheckedChange = { CollectionEditorRepository.setPinToTop(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
checkedTrackColor = MaterialTheme.colorScheme.primary,
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant,
),
)
}
Text(
text = if (state.isNew) "New Collection" else "Edit Collection",
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.onBackground,
)
}
}
}
// Title
// View Mode
item {
OutlinedTextField(
value = state.title,
onValueChange = { CollectionEditorRepository.setTitle(it) },
label = { Text("Collection Title") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
// Backdrop URL
item {
OutlinedTextField(
value = state.backdropImageUrl,
onValueChange = { CollectionEditorRepository.setBackdropImageUrl(it) },
label = { Text("Backdrop Image URL (optional)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
// Pin to Top
item {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { CollectionEditorRepository.setPinToTop(!state.pinToTop) }
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
NuvioSurfaceCard {
Text(
text = "Pin to Top",
text = "View Mode",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Display this collection above regular catalog rows.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = state.pinToTop,
onCheckedChange = { CollectionEditorRepository.setPinToTop(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
checkedTrackColor = MaterialTheme.colorScheme.primary,
),
)
}
}
// View Mode
item {
Column {
Text(
text = "View Mode",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
FolderViewMode.entries.forEach { mode ->
FilterChip(
selected = state.viewMode == mode,
onClick = { CollectionEditorRepository.setViewMode(mode) },
label = {
Text(
when (mode) {
FolderViewMode.TABBED_GRID -> "Tabbed Grid"
FolderViewMode.ROWS -> "Rows"
FolderViewMode.FOLLOW_LAYOUT -> "Follow Layout"
}
)
},
leadingIcon = if (state.viewMode == mode) {
{
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
modifier = Modifier.size(18.dp),
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
FolderViewMode.entries
.filter { it != FolderViewMode.FOLLOW_LAYOUT }
.forEach { mode ->
FilterChip(
selected = state.viewMode == mode,
onClick = { CollectionEditorRepository.setViewMode(mode) },
label = {
Text(
when (mode) {
FolderViewMode.TABBED_GRID -> "Tabbed Grid"
FolderViewMode.ROWS -> "Rows"
FolderViewMode.FOLLOW_LAYOUT -> "Rows"
}
)
}
} else null,
},
leadingIcon = if (state.viewMode == mode) {
{
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
} else null,
)
}
}
}
}
// Show All Tab
item {
NuvioSurfaceCard {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { CollectionEditorRepository.setShowAllTab(!state.showAllTab) },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = "Show \"All\" Tab",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Combine all folder catalogs into a single tab.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = state.showAllTab,
onCheckedChange = { CollectionEditorRepository.setShowAllTab(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
checkedTrackColor = MaterialTheme.colorScheme.primary,
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant,
),
)
}
}
}
}
// Show All Tab
// Folders Section Header
item {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { CollectionEditorRepository.setShowAllTab(!state.showAllTab) }
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = "Show \"All\" Tab",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
text = "Combine all folder catalogs into a single tab.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = state.showAllTab,
onCheckedChange = { CollectionEditorRepository.setShowAllTab(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
checkedTrackColor = MaterialTheme.colorScheme.primary,
),
)
}
}
// Folders Section Header
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "FOLDERS",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
TextButton(onClick = { CollectionEditorRepository.addFolder() }) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text("Add Folder")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
NuvioSectionLabel(text = "FOLDERS")
TextButton(onClick = { CollectionEditorRepository.addFolder() }) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text("Add Folder")
}
}
}
}
// Folder Items
// Folder Items
itemsIndexed(
items = state.folders,
key = { _, folder -> folder.id },
@ -304,42 +269,36 @@ fun CollectionEditorScreen(
if (state.folders.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
contentAlignment = Alignment.Center,
NuvioSurfaceCard(
modifier = Modifier.padding(top = 8.dp),
) {
Text(
text = "No folders yet. Add one to get started.",
style = MaterialTheme.typography.bodyMedium,
text = "No folders yet",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Add one to get started.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Save button
// Save button
item {
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
if (CollectionEditorRepository.save()) {
onBack()
}
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
enabled = state.title.isNotBlank(),
) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
modifier = Modifier.size(18.dp),
Spacer(modifier = Modifier.height(8.dp))
NuvioPrimaryButton(
text = if (state.isNew) "Create Collection" else "Save Changes",
enabled = state.title.isNotBlank(),
onClick = {
if (CollectionEditorRepository.save()) {
onBack()
}
},
)
Spacer(modifier = Modifier.width(8.dp))
Text(if (state.isNew) "Create Collection" else "Save Changes")
}
}
}
}
@ -354,87 +313,80 @@ private fun FolderListItem(
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
),
shape = RoundedCornerShape(12.dp),
) {
Column(modifier = Modifier.padding(14.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
// Folder cover preview
if (folder.coverEmoji != null) {
Surface(
modifier = Modifier.size(40.dp),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
) {
Box(contentAlignment = Alignment.Center) {
Text(text = folder.coverEmoji, style = MaterialTheme.typography.titleLarge)
}
NuvioSurfaceCard {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
// Folder cover preview
if (folder.coverEmoji != null) {
Surface(
modifier = Modifier.size(40.dp),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
) {
Box(contentAlignment = Alignment.Center) {
Text(text = folder.coverEmoji, style = MaterialTheme.typography.titleLarge)
}
Spacer(modifier = Modifier.width(12.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = folder.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${folder.catalogSources.size} source${if (folder.catalogSources.size != 1) "s" else ""} · ${folder.posterShape.name}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.width(12.dp))
}
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(
onClick = onMoveUp,
enabled = index > 0,
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Rounded.ArrowUpward,
contentDescription = "Move up",
modifier = Modifier.size(16.dp).alpha(if (index > 0) 1f else 0.3f),
)
}
IconButton(
onClick = onMoveDown,
enabled = index < totalCount - 1,
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Rounded.ArrowDownward,
contentDescription = "Move down",
modifier = Modifier.size(16.dp).alpha(if (index < totalCount - 1) 1f else 0.3f),
)
}
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = onEdit, modifier = Modifier.size(32.dp)) {
Icon(
imageVector = Icons.Rounded.Edit,
contentDescription = "Edit",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
Icon(
imageVector = Icons.Rounded.Delete,
contentDescription = "Delete",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = folder.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${folder.catalogSources.size} source${if (folder.catalogSources.size != 1) "s" else ""} · ${folder.posterShape.name}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(
onClick = onMoveUp,
enabled = index > 0,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Rounded.ArrowUpward,
contentDescription = "Move up",
modifier = Modifier.size(20.dp).alpha(if (index > 0) 1f else 0.3f),
)
}
IconButton(
onClick = onMoveDown,
enabled = index < totalCount - 1,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Rounded.ArrowDownward,
contentDescription = "Move down",
modifier = Modifier.size(20.dp).alpha(if (index < totalCount - 1) 1f else 0.3f),
)
}
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = onEdit, modifier = Modifier.size(36.dp)) {
Icon(
imageVector = Icons.Rounded.Edit,
contentDescription = "Edit",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(onClick = onDelete, modifier = Modifier.size(36.dp)) {
Icon(
imageVector = Icons.Rounded.Delete,
contentDescription = "Delete",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
@ -463,16 +415,15 @@ private fun FolderEditorSheet(
text = "Edit Folder",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
}
item {
OutlinedTextField(
NuvioInputField(
value = folder.title,
onValueChange = { CollectionEditorRepository.updateFolderTitle(it) },
label = { Text("Folder Title") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = "Folder Title",
)
}
@ -483,6 +434,7 @@ private fun FolderEditorSheet(
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)) {
@ -512,22 +464,19 @@ private fun FolderEditorSheet(
}
if (folder.coverEmoji != null) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
NuvioInputField(
value = folder.coverEmoji,
onValueChange = { CollectionEditorRepository.updateFolderCoverEmoji(it) },
label = { Text("Emoji") },
placeholder = "Emoji",
modifier = Modifier.width(100.dp),
singleLine = true,
)
}
if (folder.coverImageUrl != null) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
NuvioInputField(
value = folder.coverImageUrl,
onValueChange = { CollectionEditorRepository.updateFolderCoverImage(it) },
label = { Text("Image URL") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = "Image URL",
)
}
}
@ -540,6 +489,7 @@ private fun FolderEditorSheet(
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)) {
@ -577,6 +527,7 @@ private fun FolderEditorSheet(
text = "Hide Title",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
Switch(
checked = folder.hideTitle,
@ -584,6 +535,8 @@ private fun FolderEditorSheet(
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
checkedTrackColor = MaterialTheme.colorScheme.primary,
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant,
),
)
}
@ -596,12 +549,7 @@ private fun FolderEditorSheet(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "CATALOG SOURCES",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
NuvioSectionLabel(text = "CATALOG SOURCES")
TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) {
Icon(
imageVector = Icons.Rounded.Add,
@ -615,28 +563,22 @@ private fun FolderEditorSheet(
}
itemsIndexed(folder.catalogSources) { index, source ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
),
shape = RoundedCornerShape(8.dp),
) {
NuvioSurfaceCard {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "${source.catalogId} (${source.type})",
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = source.addonId,
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@ -644,12 +586,12 @@ private fun FolderEditorSheet(
}
IconButton(
onClick = { CollectionEditorRepository.removeCatalogSource(index) },
modifier = Modifier.size(32.dp),
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = "Remove",
modifier = Modifier.size(16.dp),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error,
)
}
@ -661,7 +603,7 @@ private fun FolderEditorSheet(
item {
Text(
text = "No catalog sources. Tap \"Add\" to select from installed addons.",
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp),
)
@ -670,25 +612,30 @@ private fun FolderEditorSheet(
// Save / Cancel
item {
Row(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 24.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
TextButton(
onClick = { CollectionEditorRepository.cancelFolderEdit() },
modifier = Modifier.weight(1f),
) {
Text("Cancel")
}
Button(
onClick = { CollectionEditorRepository.saveFolderEdit() },
modifier = Modifier.weight(1f),
NuvioPrimaryButton(
text = "Save Folder",
enabled = folder.title.isNotBlank(),
shape = RoundedCornerShape(12.dp),
onClick = { CollectionEditorRepository.saveFolderEdit() },
)
androidx.compose.material3.Button(
onClick = { CollectionEditorRepository.cancelFolderEdit() },
modifier = Modifier.fillMaxWidth().height(52.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Text("Save Folder")
Text(
text = "Cancel",
style = MaterialTheme.typography.titleMedium,
)
}
}
}
@ -727,6 +674,7 @@ private fun CatalogPickerSheet(
text = "Select Catalog Sources",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
TextButton(onClick = onDismiss) {
Text("Done")
@ -737,11 +685,8 @@ private fun CatalogPickerSheet(
val grouped = availableCatalogs.groupBy { it.addonName }
grouped.forEach { (addonName, catalogs) ->
item {
Text(
text = addonName,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
NuvioSectionLabel(
text = addonName.uppercase(),
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
)
}
@ -774,14 +719,15 @@ private fun CatalogPickerSheet(
Column(modifier = Modifier.weight(1f)) {
Text(
text = catalog.catalogName,
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = catalog.type.replaceFirstChar {
if (it.isLowerCase()) it.titlecase() else it.toString()
},
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}

View file

@ -1,22 +1,14 @@
package com.nuvio.app.features.collection
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
@ -24,8 +16,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ArrowDownward
import androidx.compose.material.icons.rounded.ArrowUpward
import androidx.compose.material.icons.rounded.ContentCopy
@ -33,19 +23,14 @@ import androidx.compose.material.icons.rounded.ContentPaste
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Folder
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -61,7 +46,14 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.nuvio.app.core.ui.NuvioPrimaryButton
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.NuvioSectionLabel
import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.core.ui.NuvioSurfaceCard
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CollectionManagementScreen(
onBack: () -> Unit,
@ -74,108 +66,53 @@ fun CollectionManagementScreen(
var importError by remember { mutableStateOf<String?>(null) }
var showDeleteConfirm by remember { mutableStateOf<String?>(null) }
val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentPadding = PaddingValues(
start = 16.dp,
top = 10.dp + statusBarTop,
end = 16.dp,
bottom = 18.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
NuvioScreen {
stickyHeader {
NuvioScreenHeader(
title = "Collections",
onBack = onBack,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onBackground,
)
}
Text(
text = "Collections",
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.onBackground,
IconButton(onClick = {
val json = CollectionRepository.exportToJson()
clipboardManager.setText(AnnotatedString(json))
}) {
Icon(
imageVector = Icons.Rounded.ContentCopy,
contentDescription = "Copy JSON",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = {
val json = CollectionRepository.exportToJson()
clipboardManager.setText(AnnotatedString(json))
}) {
Icon(
imageVector = Icons.Rounded.ContentCopy,
contentDescription = "Copy JSON",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { showImportDialog = true }) {
Icon(
imageVector = Icons.Rounded.ContentPaste,
contentDescription = "Import",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${collections.size} collection${if (collections.size != 1) "s" else ""}, " +
"${collections.sumOf { it.folders.size }} folder${if (collections.sumOf { it.folders.size } != 1) "s" else ""}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
IconButton(onClick = { showImportDialog = true }) {
Icon(
imageVector = Icons.Rounded.ContentPaste,
contentDescription = "Import",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
item {
Button(
onClick = { onNavigateToEditor(null) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
NuvioSurfaceCard {
Text(
text = "${collections.size} collection${if (collections.size != 1) "s" else ""}, " +
"${collections.sumOf { it.folders.size }} folder${if (collections.sumOf { it.folders.size } != 1) "s" else ""}",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(8.dp))
Text("New Collection")
}
}
item {
NuvioPrimaryButton(
text = "New Collection",
onClick = { onNavigateToEditor(null) },
)
}
if (collections.isNotEmpty()) {
item { NuvioSectionLabel(text = "YOUR COLLECTIONS") }
}
itemsIndexed(
items = collections,
key = { _, collection -> collection.id },
@ -193,32 +130,27 @@ fun CollectionManagementScreen(
if (collections.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 48.dp),
contentAlignment = Alignment.Center,
NuvioSurfaceCard(
modifier = Modifier.padding(top = 24.dp),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Rounded.Folder,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "No collections yet",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Create one to organize your catalogs.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
Icon(
imageVector = Icons.Rounded.Folder,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "No collections yet",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Create one to organize your catalogs.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@ -251,35 +183,22 @@ fun CollectionManagementScreen(
)
}
if (showDeleteConfirm != null) {
val collectionId = showDeleteConfirm!!
val collection = collections.find { it.id == collectionId }
AlertDialog(
onDismissRequest = { showDeleteConfirm = null },
title = { Text("Delete Collection") },
text = {
Text("Delete \"${collection?.title ?: ""}\"? This cannot be undone.")
},
confirmButton = {
Button(
onClick = {
CollectionRepository.removeCollection(collectionId)
showDeleteConfirm = null
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = null }) {
Text("Cancel")
}
},
)
}
val deleteId = showDeleteConfirm
val deleteCollection = deleteId?.let { id -> collections.find { it.id == id } }
NuvioStatusModal(
title = "Delete Collection",
message = "Delete \"${deleteCollection?.title ?: ""}\"? This cannot be undone.",
isVisible = deleteId != null,
confirmText = "Delete",
dismissText = "Cancel",
onConfirm = {
if (deleteId != null) {
CollectionRepository.removeCollection(deleteId)
}
showDeleteConfirm = null
},
onDismiss = { showDeleteConfirm = null },
)
}
@Composable
@ -292,91 +211,84 @@ private fun CollectionListItem(
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
),
shape = RoundedCornerShape(12.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = collection.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "${collection.folders.size} folder${if (collection.folders.size != 1) "s" else ""}" +
if (collection.pinToTop) " · Pinned" else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
NuvioSurfaceCard {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = collection.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "${collection.folders.size} folder${if (collection.folders.size != 1) "s" else ""}" +
if (collection.pinToTop) " · Pinned" else "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(10.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
}
Spacer(modifier = Modifier.height(10.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
IconButton(
onClick = onMoveUp,
enabled = index > 0,
modifier = Modifier.size(36.dp),
) {
IconButton(
onClick = onMoveUp,
enabled = index > 0,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Rounded.ArrowUpward,
contentDescription = "Move up",
modifier = Modifier.size(18.dp).alpha(if (index > 0) 1f else 0.3f),
)
}
IconButton(
onClick = onMoveDown,
enabled = index < totalCount - 1,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Rounded.ArrowDownward,
contentDescription = "Move down",
modifier = Modifier.size(18.dp).alpha(if (index < totalCount - 1) 1f else 0.3f),
)
}
Spacer(modifier = Modifier.weight(1f))
IconButton(
onClick = onEdit,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Rounded.Edit,
contentDescription = "Edit",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(
onClick = onDelete,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Rounded.Delete,
contentDescription = "Delete",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error,
)
}
Icon(
imageVector = Icons.Rounded.ArrowUpward,
contentDescription = "Move up",
modifier = Modifier.size(20.dp).alpha(if (index > 0) 1f else 0.3f),
)
}
IconButton(
onClick = onMoveDown,
enabled = index < totalCount - 1,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Rounded.ArrowDownward,
contentDescription = "Move down",
modifier = Modifier.size(20.dp).alpha(if (index < totalCount - 1) 1f else 0.3f),
)
}
Spacer(modifier = Modifier.weight(1f))
IconButton(
onClick = onEdit,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Rounded.Edit,
contentDescription = "Edit",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(
onClick = onDelete,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Rounded.Delete,
contentDescription = "Delete",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ImportDialog(
importText: String,
@ -385,14 +297,24 @@ private fun ImportDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
androidx.compose.material3.BasicAlertDialog(
onDismissRequest = onDismiss,
title = { Text("Import Collections") },
text = {
Column {
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(24.dp),
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = "Import Collections",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Paste your collections JSON below.",
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
@ -402,7 +324,7 @@ private fun ImportDialog(
modifier = Modifier
.fillMaxWidth()
.height(160.dp),
label = { Text("JSON") },
placeholder = { Text("JSON", style = MaterialTheme.typography.bodyLarge) },
isError = importError != null,
supportingText = importError?.let {
{ Text(it, color = MaterialTheme.colorScheme.error) }
@ -410,21 +332,43 @@ private fun ImportDialog(
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { onConfirm() }),
maxLines = 10,
shape = RoundedCornerShape(14.dp),
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface,
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.outline,
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
cursorColor = MaterialTheme.colorScheme.primary,
),
)
Spacer(modifier = Modifier.height(18.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
androidx.compose.material3.Button(
onClick = onDismiss,
shape = RoundedCornerShape(16.dp),
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Text("Cancel")
}
Spacer(modifier = Modifier.width(10.dp))
androidx.compose.material3.Button(
onClick = onConfirm,
enabled = importText.isNotBlank(),
shape = RoundedCornerShape(16.dp),
) {
Text("Import")
}
}
}
},
confirmButton = {
Button(
onClick = onConfirm,
enabled = importText.isNotBlank(),
) {
Text("Import")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}
}
}

View file

@ -12,7 +12,12 @@ enum class FolderViewMode {
companion object {
fun fromString(value: String): FolderViewMode =
entries.firstOrNull { it.name.equals(value, ignoreCase = true) } ?: TABBED_GRID
when {
value.equals(FOLLOW_LAYOUT.name, ignoreCase = true) -> ROWS
value.equals(ROWS.name, ignoreCase = true) -> ROWS
value.equals(TABBED_GRID.name, ignoreCase = true) -> TABBED_GRID
else -> TABBED_GRID
}
}
}

View file

@ -2,9 +2,13 @@ package com.nuvio.app.features.collection
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE
import com.nuvio.app.features.catalog.fetchCatalogPage
import com.nuvio.app.features.catalog.mergeCatalogItems
import com.nuvio.app.features.catalog.supportsPagination
import com.nuvio.app.features.home.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.stableKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -17,11 +21,20 @@ import kotlinx.coroutines.launch
data class FolderTab(
val label: String,
val typeLabel: String = "",
val manifestUrl: String? = null,
val type: String = "",
val catalogId: String = "",
val supportsPagination: Boolean = false,
val items: List<MetaPreview> = emptyList(),
val isLoading: Boolean = true,
val isLoadingMore: Boolean = false,
val nextSkip: Int? = null,
val error: String? = null,
val isAllTab: Boolean = false,
)
) {
val canLoadMore: Boolean
get() = supportsPagination && nextSkip != null
}
data class FolderDetailUiState(
val folder: CollectionFolder? = null,
@ -31,7 +44,30 @@ data class FolderDetailUiState(
val selectedTabIndex: Int = 0,
val isLoading: Boolean = true,
val showAllTab: Boolean = true,
)
) {
val selectedTab: FolderTab?
get() = tabs.getOrNull(selectedTabIndex)
val selectedTabCanLoadMore: Boolean
get() {
val currentTab = selectedTab ?: return false
return if (currentTab.isAllTab) {
tabs.any { !it.isAllTab && it.canLoadMore }
} else {
currentTab.canLoadMore
}
}
val selectedTabIsLoadingMore: Boolean
get() {
val currentTab = selectedTab ?: return false
return if (currentTab.isAllTab) {
tabs.any { !it.isAllTab && it.isLoadingMore }
} else {
currentTab.isLoadingMore
}
}
}
object FolderDetailRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -40,10 +76,24 @@ object FolderDetailRepository {
private val _uiState = MutableStateFlow(FolderDetailUiState())
val uiState: StateFlow<FolderDetailUiState> = _uiState.asStateFlow()
private var loadJobs = mutableListOf<Job>()
private val loadJobs = mutableMapOf<Int, Job>()
private var activeCollectionId: String? = null
private var activeFolderId: String? = null
fun initialize(collectionId: String, folderId: String) {
val current = _uiState.value
if (
activeCollectionId == collectionId &&
activeFolderId == folderId &&
current.folder?.id == folderId &&
current.tabs.isNotEmpty()
) {
return
}
clear()
activeCollectionId = collectionId
activeFolderId = folderId
val collection = CollectionRepository.getCollection(collectionId)
if (collection == null) {
@ -77,6 +127,10 @@ object FolderDetailRepository {
FolderTab(
label = "$label ($typeLabel)",
typeLabel = typeLabel,
manifestUrl = addon?.manifestUrl,
type = source.type,
catalogId = source.catalogId,
supportsPagination = catalog?.supportsPagination() == true,
isLoading = true,
),
)
@ -102,22 +156,7 @@ object FolderDetailRepository {
return@forEachIndexed
}
val job = scope.launch {
runCatching {
val page = fetchCatalogPage(
manifestUrl = addon.manifestUrl,
type = source.type,
catalogId = source.catalogId,
)
updateTab(tabIndex) { it.copy(items = page.items, isLoading = false) }
rebuildAllTab()
}.onFailure { e ->
log.e(e) { "Failed to load catalog ${source.catalogId} from ${source.addonId}" }
updateTab(tabIndex) { it.copy(isLoading = false, error = e.message) }
rebuildAllTab()
}
}
loadJobs.add(job)
loadTabPage(tabIndex, reset = true)
}
// If no sources, mark as done
@ -131,11 +170,30 @@ object FolderDetailRepository {
}
fun clear() {
loadJobs.forEach { it.cancel() }
loadJobs.values.forEach { it.cancel() }
loadJobs.clear()
activeCollectionId = null
activeFolderId = null
_uiState.value = FolderDetailUiState()
}
fun loadMoreSelectedTab() {
val current = _uiState.value
val selectedTab = current.selectedTab ?: return
if (selectedTab.isAllTab) {
current.tabs.forEachIndexed { index, tab ->
if (!tab.isAllTab && tab.canLoadMore && !tab.isLoading && !tab.isLoadingMore) {
loadTabPage(index, reset = false)
}
}
return
}
if (selectedTab.canLoadMore && !selectedTab.isLoading && !selectedTab.isLoadingMore) {
loadTabPage(current.selectedTabIndex, reset = false)
}
}
private fun updateTab(index: Int, transform: (FolderTab) -> FolderTab) {
val current = _uiState.value
val updatedTabs = current.tabs.toMutableList()
@ -149,21 +207,90 @@ object FolderDetailRepository {
)
}
private fun loadTabPage(index: Int, reset: Boolean) {
val currentTab = _uiState.value.tabs.getOrNull(index) ?: return
val manifestUrl = currentTab.manifestUrl ?: return
val requestedSkip = if (reset) 0 else currentTab.nextSkip ?: return
updateTab(index) { tab ->
if (reset) {
tab.copy(
items = emptyList(),
isLoading = true,
isLoadingMore = false,
nextSkip = null,
error = null,
)
} else {
tab.copy(
isLoadingMore = true,
error = null,
)
}
}
loadJobs.remove(index)?.cancel()
val job = scope.launch {
runCatching {
fetchCatalogPage(
manifestUrl = manifestUrl,
type = currentTab.type,
catalogId = currentTab.catalogId,
skip = requestedSkip.takeIf { it > 0 },
)
}.onSuccess { page ->
updateTab(index) { tab ->
val mergedItems = if (reset) {
page.items
} else {
mergeCatalogItems(tab.items, page.items)
}
val supportsPagination = tab.supportsPagination || page.rawItemCount >= CATALOG_PAGE_SIZE
val loadedNewItems = reset || mergedItems.size > tab.items.size
tab.copy(
items = mergedItems,
supportsPagination = supportsPagination,
isLoading = false,
isLoadingMore = false,
nextSkip = if (supportsPagination && loadedNewItems) page.nextSkip else null,
error = null,
)
}
rebuildAllTab()
}.onFailure { error ->
log.e(error) { "Failed to load catalog ${currentTab.catalogId} from $manifestUrl" }
updateTab(index) { tab ->
tab.copy(
isLoading = false,
isLoadingMore = false,
nextSkip = if (reset) null else tab.nextSkip,
error = error.message,
)
}
rebuildAllTab()
}
}
loadJobs[index] = job
}
private fun rebuildAllTab() {
val current = _uiState.value
if (!current.showAllTab) return
val sourceTabs = current.tabs.filter { !it.isAllTab }
if (sourceTabs.any { it.isLoading }) return
// Round-robin merge
val merged = mutableListOf<MetaPreview>()
val seenKeys = mutableSetOf<String>()
val iterators = sourceTabs.map { it.items.iterator() }
var hasMore = true
while (hasMore) {
hasMore = false
for (iterator in iterators) {
if (iterator.hasNext()) {
merged.add(iterator.next())
val item = iterator.next()
if (seenKeys.add(item.stableKey())) {
merged.add(item)
}
hasMore = true
}
}
@ -172,9 +299,14 @@ object FolderDetailRepository {
val updatedTabs = current.tabs.toMutableList()
val allTabIndex = updatedTabs.indexOfFirst { it.isAllTab }
if (allTabIndex >= 0) {
val hasInitialLoads = sourceTabs.any { it.isLoading }
val hasLoadMore = sourceTabs.any { it.isLoadingMore }
val errorMessage = sourceTabs.firstOrNull { it.error != null }?.error
updatedTabs[allTabIndex] = updatedTabs[allTabIndex].copy(
items = merged,
isLoading = false,
isLoading = hasInitialLoads,
isLoadingMore = hasLoadMore,
error = errorMessage.takeIf { merged.isEmpty() },
)
}
_uiState.value = current.copy(tabs = updatedTabs)
@ -190,10 +322,12 @@ object FolderDetailRepository {
title = tab.label,
subtitle = tab.typeLabel,
addonName = "",
type = "",
manifestUrl = "",
catalogId = "",
type = tab.type,
manifestUrl = tab.manifestUrl.orEmpty(),
catalogId = tab.catalogId,
items = tab.items,
availableItemCount = tab.items.size,
supportsPagination = tab.supportsPagination,
)
}
}

View file

@ -5,34 +5,33 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalRippleConfiguration
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@ -42,26 +41,42 @@ import com.nuvio.app.core.ui.NuvioPosterCard
import com.nuvio.app.core.ui.NuvioPosterShape
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding
import com.nuvio.app.core.ui.nuvioPlatformExtraTopPadding
import com.nuvio.app.features.home.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.home.canOpenCatalog
import com.nuvio.app.features.home.stableKey
import com.nuvio.app.features.home.components.HomeCatalogRowSection
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
@Composable
fun FolderDetailScreen(
onBack: () -> Unit,
onCatalogClick: (HomeCatalogSection) -> Unit,
onPosterClick: (MetaPreview) -> Unit,
) {
val uiState by FolderDetailRepository.uiState.collectAsState()
val folder = uiState.folder
val coverImageUrl = folder?.coverImageUrl?.takeIf { it.isNotBlank() }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
if (coverImageUrl != null) {
FolderCoverImage(
imageUrl = coverImageUrl,
title = folder.title,
)
}
NuvioScreenHeader(
title = folder?.title ?: uiState.collectionTitle,
modifier = Modifier.padding(horizontal = 16.dp),
includeStatusBarPadding = coverImageUrl == null,
onBack = onBack,
)
@ -87,49 +102,78 @@ fun FolderDetailScreen(
)
FolderViewMode.ROWS -> RowsContent(
uiState = uiState,
onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick,
)
FolderViewMode.FOLLOW_LAYOUT -> RowsContent(
uiState = uiState,
onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick,
)
}
}
}
@Composable
private fun FolderCoverImage(
imageUrl: String,
title: String,
) {
AsyncImage(
model = imageUrl,
contentDescription = title,
modifier = Modifier
.fillMaxWidth()
.height(176.dp),
contentScale = ContentScale.Crop,
)
}
@Composable
private fun TabbedGridContent(
uiState: FolderDetailUiState,
onTabSelected: (Int) -> Unit,
onPosterClick: (MetaPreview) -> Unit,
) {
val folder = uiState.folder ?: return
val gridState = rememberLazyGridState()
LaunchedEffect(gridState, uiState.selectedTabIndex, uiState.selectedTabCanLoadMore, uiState.selectedTabIsLoadingMore) {
snapshotFlow { gridState.layoutInfo }
.map { layoutInfo ->
val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
lastVisible >= layoutInfo.totalItemsCount - 6
}
.distinctUntilChanged()
.filter { it && uiState.selectedTabCanLoadMore && !uiState.selectedTabIsLoadingMore }
.collect {
FolderDetailRepository.loadMoreSelectedTab()
}
}
Column(modifier = Modifier.fillMaxSize()) {
// Folder header with cover + tabs
FolderHeader(folder = folder)
if (uiState.tabs.size > 1) {
ScrollableTabRow(
selectedTabIndex = uiState.selectedTabIndex,
modifier = Modifier.fillMaxWidth(),
edgePadding = 16.dp,
containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
divider = {},
) {
uiState.tabs.forEachIndexed { index, tab ->
Tab(
selected = index == uiState.selectedTabIndex,
onClick = { onTabSelected(index) },
text = {
Text(
text = tab.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
)
CompositionLocalProvider(LocalRippleConfiguration provides null) {
ScrollableTabRow(
selectedTabIndex = uiState.selectedTabIndex,
modifier = Modifier.fillMaxWidth(),
edgePadding = 16.dp,
containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
divider = {},
) {
uiState.tabs.forEachIndexed { index, tab ->
Tab(
selected = index == uiState.selectedTabIndex,
onClick = { onTabSelected(index) },
text = {
Text(
text = tab.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
)
}
}
}
}
@ -141,20 +185,16 @@ private fun TabbedGridContent(
if (selectedTab == null) return
when {
selectedTab.isLoading -> LoadingIndicator()
selectedTab.error != null -> ErrorMessage(selectedTab.error)
selectedTab.isLoading && selectedTab.items.isEmpty() -> LoadingIndicator()
selectedTab.error != null && selectedTab.items.isEmpty() -> ErrorMessage(selectedTab.error)
selectedTab.items.isEmpty() -> EmptyMessage()
else -> {
val posterShape = folder.posterShape
val nuvioShape = posterShape.toNuvioPosterShape()
val columns = when (nuvioShape) {
NuvioPosterShape.Poster -> 3
NuvioPosterShape.Square -> 3
NuvioPosterShape.Landscape -> 2
}
val nuvioShape = NuvioPosterShape.Poster
val columns = 3
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
state = gridState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp,
@ -166,7 +206,7 @@ private fun TabbedGridContent(
) {
items(
items = selectedTab.items,
key = { it.id },
key = { item -> item.stableKey() },
) { item ->
NuvioPosterCard(
title = item.name,
@ -176,6 +216,12 @@ private fun TabbedGridContent(
onClick = { onPosterClick(item) },
)
}
if (uiState.selectedTabIsLoadingMore) {
item(span = { GridItemSpan(maxLineSpan) }) {
PaginationLoadingFooter()
}
}
}
}
}
@ -185,6 +231,7 @@ private fun TabbedGridContent(
@Composable
private fun RowsContent(
uiState: FolderDetailUiState,
onCatalogClick: (HomeCatalogSection) -> Unit,
onPosterClick: (MetaPreview) -> Unit,
) {
val sections = FolderDetailRepository.getCatalogSectionsForRows()
@ -213,6 +260,11 @@ private fun RowsContent(
HomeCatalogRowSection(
section = section,
entries = section.items.take(18),
onViewAllClick = if (section.canOpenCatalog(18)) {
{ onCatalogClick(section) }
} else {
null
},
onPosterClick = { onPosterClick(it) },
)
}
@ -220,57 +272,18 @@ private fun RowsContent(
}
@Composable
private fun FolderHeader(folder: CollectionFolder) {
Row(
private fun PaginationLoadingFooter() {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
// Cover image or emoji
when {
!folder.coverImageUrl.isNullOrBlank() -> {
AsyncImage(
model = folder.coverImageUrl,
contentDescription = folder.title,
modifier = Modifier
.size(56.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface),
contentScale = ContentScale.Crop,
)
}
!folder.coverEmoji.isNullOrBlank() -> {
Box(
modifier = Modifier
.size(56.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Text(
text = folder.coverEmoji,
style = MaterialTheme.typography.headlineMedium,
)
}
}
}
Column {
Text(
text = folder.title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${folder.catalogSources.size} source${if (folder.catalogSources.size != 1) "s" else ""}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
CircularProgressIndicator(
modifier = Modifier.size(28.dp),
color = MaterialTheme.colorScheme.primary,
strokeWidth = 3.dp,
)
}
}
@ -296,7 +309,7 @@ private fun ErrorMessage(error: String) {
) {
Text(
text = error,
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)