diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 98d3b5ff..85def228 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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 { + AddonsScreen( + title = "Addons", + onBack = { navController.popBackStack() }, + modifier = Modifier.fillMaxSize(), + ) + } } } } 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 3bac4a83..134c3b80 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 @@ -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), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt index ccb49ba0..5f224228 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt new file mode 100644 index 00000000..80c8d4ca --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -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, + ) + } +}