diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index b0a1ef44..8e45aaad 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1155,6 +1155,7 @@ private fun MainAppContent( FolderDetailRepository.clear() navController.popBackStack() }, + onCatalogClick = onCatalogClick, onPosterClick = { meta -> navController.navigate(DetailRoute(type = meta.type, id = meta.id)) }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt index f4afb525..cb35865b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt @@ -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, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt index a1e17b92..400cffd5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt @@ -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, ) }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt index 847bc638..71448945 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt @@ -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, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt index fe362e34..a612c92b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt @@ -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(null) } var showDeleteConfirm by remember { mutableStateOf(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") - } - }, - ) + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt index 5a02e7bb..96df5e78 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt @@ -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 + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt index 706a323e..68c9b844 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt @@ -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 = 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 = _uiState.asStateFlow() - private var loadJobs = mutableListOf() + private val loadJobs = mutableMapOf() + 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() + val seenKeys = mutableSetOf() 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, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt index 5270e0e9..bb0cfc47 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt @@ -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, )