feat: add SettingsScreen and integrate into navigation; update NuvioScreenHeader for back navigation

This commit is contained in:
tapframe 2026-03-11 22:30:12 +05:30
parent 9366f09292
commit 3c4cc6b59f
4 changed files with 476 additions and 18 deletions

View file

@ -6,9 +6,9 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -40,6 +40,7 @@ import com.nuvio.app.features.home.HomeCatalogSection
import com.nuvio.app.features.home.HomeScreen
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.search.SearchScreen
import com.nuvio.app.features.settings.SettingsScreen
import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.streams.StreamsScreen
import kotlinx.serialization.Serializable
@ -75,10 +76,13 @@ data class CatalogRoute(
val genre: String? = null,
)
@Serializable
object AddonsRoute
enum class AppScreenTab {
Home,
Search,
Addons,
Settings,
}
@Composable
@ -87,6 +91,7 @@ fun AppScreen(
modifier: Modifier = Modifier,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onAddonsClick: (() -> Unit)? = null,
) {
when (tab) {
AppScreenTab.Home -> HomeScreen(
@ -98,7 +103,10 @@ fun AppScreen(
modifier = modifier,
onPosterClick = onPosterClick,
)
AppScreenTab.Addons -> AddonsScreen(modifier = modifier)
AppScreenTab.Settings -> SettingsScreen(
modifier = modifier,
onAddonsClick = { onAddonsClick?.invoke() },
)
}
}
@ -173,10 +181,10 @@ fun App() {
label = { Text("Search") },
)
NavigationBarItem(
selected = selectedTab == AppScreenTab.Addons,
onClick = { selectedTab = AppScreenTab.Addons },
icon = { Icon(Icons.Rounded.Extension, contentDescription = null) },
label = { Text("Addons") },
selected = selectedTab == AppScreenTab.Settings,
onClick = { selectedTab = AppScreenTab.Settings },
icon = { Icon(Icons.Rounded.Settings, contentDescription = null) },
label = { Text("Settings") },
)
}
},
@ -188,6 +196,9 @@ fun App() {
onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
},
onAddonsClick = {
navController.navigate(AddonsRoute)
},
)
}
@ -252,6 +263,13 @@ fun App() {
modifier = Modifier.fillMaxSize(),
)
}
composable<AddonsRoute> {
AddonsScreen(
title = "Addons",
onBack = { navController.popBackStack() },
modifier = Modifier.fillMaxSize(),
)
}
}
}
}

View file

@ -47,6 +47,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
@Composable
fun NuvioScreen(
@ -94,6 +96,7 @@ fun NuvioSurfaceCard(
fun NuvioScreenHeader(
title: String,
modifier: Modifier = Modifier,
onBack: (() -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {},
) {
val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
@ -105,16 +108,30 @@ fun NuvioScreenHeader(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
) {
AnimatedContent(
targetState = title,
transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "screen_header_title",
) { currentTitle ->
Text(
text = currentTitle,
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (onBack != null) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onBackground,
)
}
}
AnimatedContent(
targetState = title,
transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "screen_header_title",
) { currentTitle ->
Text(
text = currentTitle,
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.onBackground,
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),

View file

@ -52,6 +52,8 @@ import kotlinx.coroutines.launch
@Composable
fun AddonsScreen(
modifier: Modifier = Modifier,
title: String = "Content & Discovery",
onBack: (() -> Unit)? = null,
) {
LaunchedEffect(Unit) {
AddonRepository.initialize()
@ -76,7 +78,8 @@ fun AddonsScreen(
NuvioScreen(modifier = modifier) {
stickyHeader {
NuvioScreenHeader(
title = "Addons",
title = title,
onBack = onBack,
) {
NuvioIconActionButton(
icon = Icons.Rounded.SwapVert,

View file

@ -0,0 +1,420 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
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.automirrored.rounded.ArrowForward
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.NuvioSectionLabel
private enum class SettingsCategory(
val label: String,
val icon: ImageVector,
) {
General("General", Icons.Rounded.Settings),
}
private enum class SettingsPage(
val title: String,
) {
Root("Settings"),
ContentDiscovery("Content & Discovery"),
}
@Composable
fun SettingsScreen(
modifier: Modifier = Modifier,
onAddonsClick: () -> Unit = {},
) {
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) }
val page = remember(currentPage) { SettingsPage.valueOf(currentPage) }
if (maxWidth >= 768.dp) {
TabletSettingsScreen(
page = page,
onPageChange = { currentPage = it.name },
onAddonsClick = onAddonsClick,
)
} else {
MobileSettingsScreen(
page = page,
onPageChange = { currentPage = it.name },
onAddonsClick = onAddonsClick,
)
}
}
}
@Composable
private fun MobileSettingsScreen(
page: SettingsPage,
onPageChange: (SettingsPage) -> Unit,
onAddonsClick: () -> Unit,
) {
NuvioScreen {
stickyHeader {
NuvioScreenHeader(
title = page.title,
onBack = if (page != SettingsPage.Root) {
{ onPageChange(SettingsPage.Root) }
} else {
null
},
)
}
when (page) {
SettingsPage.Root -> settingsRootContent(
isTablet = false,
onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) },
)
SettingsPage.ContentDiscovery -> contentDiscoveryContent(
isTablet = false,
onAddonsClick = onAddonsClick,
)
}
}
}
@Composable
private fun TabletSettingsScreen(
page: SettingsPage,
onPageChange: (SettingsPage) -> Unit,
onAddonsClick: () -> Unit,
) {
var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) }
val activeCategory = SettingsCategory.valueOf(selectedCategory)
val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val topOffset = max(statusBarPadding + 24.dp, 48.dp) + 64.dp
Row(modifier = Modifier.fillMaxSize()) {
Surface(
modifier = Modifier
.width(280.dp)
.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = topOffset),
) {
Text(
text = "Settings",
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 20.dp),
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(10.dp))
SettingsSidebarItem(
label = activeCategory.label,
icon = activeCategory.icon,
selected = true,
onClick = { selectedCategory = activeCategory.name },
)
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 40.dp,
top = topOffset,
end = 40.dp,
bottom = 40.dp,
),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
item {
TabletPageHeader(
title = if (page == SettingsPage.Root) activeCategory.label else page.title,
showBack = page != SettingsPage.Root,
onBack = { onPageChange(SettingsPage.Root) },
)
}
when (page) {
SettingsPage.Root -> settingsRootContent(
isTablet = true,
onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) },
)
SettingsPage.ContentDiscovery -> contentDiscoveryContent(
isTablet = true,
onAddonsClick = onAddonsClick,
)
}
}
}
}
private fun androidx.compose.foundation.lazy.LazyListScope.settingsRootContent(
isTablet: Boolean,
onContentDiscoveryClick: () -> Unit,
) {
item {
SettingsSection(
title = "GENERAL",
isTablet = isTablet,
) {
SettingsNavigationRow(
title = "Content & Discovery",
description = "Manage addons and discovery sources.",
icon = Icons.Rounded.Extension,
isTablet = isTablet,
onClick = onContentDiscoveryClick,
)
}
}
}
private fun androidx.compose.foundation.lazy.LazyListScope.contentDiscoveryContent(
isTablet: Boolean,
onAddonsClick: () -> Unit,
) {
item {
SettingsSection(
title = "GENERAL",
isTablet = isTablet,
) {
SettingsNavigationRow(
title = "Addons",
description = "Install, remove, refresh, and sort your content sources.",
icon = Icons.Rounded.Extension,
isTablet = isTablet,
onClick = onAddonsClick,
)
}
}
}
@Composable
private fun TabletPageHeader(
title: String,
showBack: Boolean,
onBack: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (showBack) {
Surface(
modifier = Modifier
.size(36.dp)
.clickable(onClick = onBack),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
Text(
text = title,
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onBackground,
fontWeight = FontWeight.SemiBold,
)
}
}
@Composable
private fun SettingsSidebarItem(
label: String,
icon: ImageVector,
selected: Boolean,
onClick: () -> Unit,
) {
val primary = MaterialTheme.colorScheme.primary
val background = if (selected) primary.copy(alpha = 0.10f) else Color.Transparent
val iconChip = if (selected) primary.copy(alpha = 0.15f) else Color.Transparent
val contentColor = if (selected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp)
.background(background, RoundedCornerShape(10.dp))
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
modifier = Modifier.size(32.dp),
color = iconChip,
shape = RoundedCornerShape(8.dp),
) {
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (selected) primary else contentColor,
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = contentColor,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
)
}
}
@Composable
private fun SettingsSection(
title: String,
isTablet: Boolean,
content: @Composable ColumnScope.() -> Unit,
) {
Column {
NuvioSectionLabel(text = title)
Spacer(modifier = Modifier.height(if (isTablet) 12.dp else 10.dp))
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(if (isTablet) 20.dp else 16.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Column(content = content)
}
}
}
@Composable
private fun SettingsNavigationRow(
title: String,
description: String,
icon: ImageVector,
isTablet: Boolean,
onClick: () -> Unit,
) {
val iconSize = if (isTablet) 42.dp else 36.dp
val iconRadius = if (isTablet) 12.dp else 10.dp
val verticalPadding = if (isTablet) 16.dp else 14.dp
val horizontalPadding = if (isTablet) 20.dp else 16.dp
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.padding(end = 12.dp)
.widthIn(max = if (isTablet) 560.dp else 320.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
modifier = Modifier.size(iconSize),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
shape = RoundedCornerShape(iconRadius),
) {
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
Spacer(modifier = Modifier.width(if (isTablet) 16.dp else 14.dp))
Column {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.alpha(0.92f),
)
}
}
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowForward,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}