mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-01 09:09:29 +00:00
ref: ui adjustments
This commit is contained in:
parent
44bb940bc0
commit
9de4e30633
8 changed files with 766 additions and 720 deletions
|
|
@ -1155,6 +1155,7 @@ private fun MainAppContent(
|
|||
FolderDetailRepository.clear()
|
||||
navController.popBackStack()
|
||||
},
|
||||
onCatalogClick = onCatalogClick,
|
||||
onPosterClick = { meta ->
|
||||
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue