From c5cf0b5aa762cc5b115b6dd568f806b13b255847 Mon Sep 17 00:00:00 2001 From: Cilly Leang Date: Thu, 29 Jan 2026 00:51:59 +1100 Subject: [PATCH] feat(ui): initial channel browser and some ui groundwork --- .../moe/lava/neon/ui/ColorScheme.android.kt | 25 ++ .../drawable/arrow_drop_down.xml | 9 + .../drawable/arrow_drop_up.xml | 9 + .../composeResources/drawable/tag.xml | 9 + .../composeResources/drawable/volume_up.xml | 9 + .../commonMain/kotlin/moe/lava/neon/ui/App.kt | 20 +- .../kotlin/moe/lava/neon/ui/ColorScheme.kt | 7 + .../moe/lava/neon/ui/layout/ChannelBrowser.kt | 247 ++++++++++++++++++ .../kotlin/moe/lava/neon/ui/screens/Login.kt | 1 + .../kotlin/moe/lava/neon/ui/screens/Sample.kt | 8 +- .../moe/lava/neon/ui/ColorScheme.jvm.kt | 17 ++ 11 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 ui/src/androidMain/kotlin/moe/lava/neon/ui/ColorScheme.android.kt create mode 100644 ui/src/commonMain/composeResources/drawable/arrow_drop_down.xml create mode 100644 ui/src/commonMain/composeResources/drawable/arrow_drop_up.xml create mode 100644 ui/src/commonMain/composeResources/drawable/tag.xml create mode 100644 ui/src/commonMain/composeResources/drawable/volume_up.xml create mode 100644 ui/src/commonMain/kotlin/moe/lava/neon/ui/ColorScheme.kt create mode 100644 ui/src/commonMain/kotlin/moe/lava/neon/ui/layout/ChannelBrowser.kt create mode 100644 ui/src/jvmMain/kotlin/moe/lava/neon/ui/ColorScheme.jvm.kt diff --git a/ui/src/androidMain/kotlin/moe/lava/neon/ui/ColorScheme.android.kt b/ui/src/androidMain/kotlin/moe/lava/neon/ui/ColorScheme.android.kt new file mode 100644 index 0000000..6ca40eb --- /dev/null +++ b/ui/src/androidMain/kotlin/moe/lava/neon/ui/ColorScheme.android.kt @@ -0,0 +1,25 @@ +package moe.lava.neon.ui + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.expressiveLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +actual fun getColorScheme(): ColorScheme { + val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val darkTheme = isSystemInDarkTheme() + return when { + dynamicColor && darkTheme -> dynamicDarkColorScheme(LocalContext.current) + dynamicColor && !darkTheme -> dynamicLightColorScheme(LocalContext.current) + darkTheme -> darkColorScheme() + else -> expressiveLightColorScheme() + } +} diff --git a/ui/src/commonMain/composeResources/drawable/arrow_drop_down.xml b/ui/src/commonMain/composeResources/drawable/arrow_drop_down.xml new file mode 100644 index 0000000..ac49572 --- /dev/null +++ b/ui/src/commonMain/composeResources/drawable/arrow_drop_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/src/commonMain/composeResources/drawable/arrow_drop_up.xml b/ui/src/commonMain/composeResources/drawable/arrow_drop_up.xml new file mode 100644 index 0000000..322fa56 --- /dev/null +++ b/ui/src/commonMain/composeResources/drawable/arrow_drop_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/src/commonMain/composeResources/drawable/tag.xml b/ui/src/commonMain/composeResources/drawable/tag.xml new file mode 100644 index 0000000..5cac8ef --- /dev/null +++ b/ui/src/commonMain/composeResources/drawable/tag.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/src/commonMain/composeResources/drawable/volume_up.xml b/ui/src/commonMain/composeResources/drawable/volume_up.xml new file mode 100644 index 0000000..bce53f6 --- /dev/null +++ b/ui/src/commonMain/composeResources/drawable/volume_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/App.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/App.kt index 96552ef..ccb93e6 100644 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/App.kt +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/App.kt @@ -1,6 +1,8 @@ package moe.lava.neon.ui -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.MotionScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator @@ -27,6 +29,9 @@ private object Route { @Serializable data object Sample : NavKey + + @Serializable + data object ChannelBrowser : NavKey } private val config = SavedStateConfiguration { @@ -34,10 +39,12 @@ private val config = SavedStateConfiguration { polymorphic(NavKey::class) { subclass(Route.Login::class, Route.Login.serializer()) subclass(Route.Sample::class, Route.Sample.serializer()) + subclass(Route.ChannelBrowser::class, Route.ChannelBrowser.serializer()) } } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun App() { Thread.setDefaultUncaughtExceptionHandler { t: Thread, e: Throwable -> @@ -49,7 +56,10 @@ fun App() { val graph = uiGraph.core CaptchaBinder(graph.api) CompositionLocalProvider(LocalMetroViewModelFactory provides uiGraph.metroViewModelFactory) { - MaterialTheme { + MaterialExpressiveTheme( + colorScheme = getColorScheme(), + motionScheme = MotionScheme.expressive(), + ) { val init = if (graph.auth.token != null) Route.Sample else Route.Login val backStack = rememberNavBackStack(config, init) NavDisplay( @@ -70,12 +80,18 @@ fun App() { } entry { key -> Sample( + navTest = { + backStack.add(Route.ChannelBrowser) + }, onRequestLogout = { backStack.clear() backStack.add(Route.Login) } ) } +// entry { +// ChannelBrowser() +// } } ) } diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/ColorScheme.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/ColorScheme.kt new file mode 100644 index 0000000..f03b646 --- /dev/null +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/ColorScheme.kt @@ -0,0 +1,7 @@ +package moe.lava.neon.ui + +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable + +@Composable +expect fun getColorScheme(): ColorScheme diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/layout/ChannelBrowser.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/layout/ChannelBrowser.kt new file mode 100644 index 0000000..e3776b1 --- /dev/null +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/layout/ChannelBrowser.kt @@ -0,0 +1,247 @@ +package moe.lava.neon.ui.layout + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewScreenSizes +import androidx.compose.ui.unit.dp +import moe.lava.neon.resources.Res +import moe.lava.neon.resources.arrow_drop_down +import moe.lava.neon.resources.arrow_drop_up +import moe.lava.neon.resources.tag +import moe.lava.neon.resources.volume_up +import org.jetbrains.compose.resources.painterResource + +data class ChannelBrowserModel( + val guildName: String, + val channels: List, +) { + data class Category( + val name: String, + val channels: List, + ) + data class Channel( + val name: String, + val type: ChannelType, + ) + enum class ChannelType { + Text, + Voice + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ChannelBrowser( + model: ChannelBrowserModel +) { + val peekHeight = 128.dp + val colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainer) + BottomSheetScaffold( + sheetContent = {}, + scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState(SheetValue.Hidden, skipHiddenState = false) + ), + sheetPeekHeight = peekHeight, + topBar = { + TopAppBar( + title = { Text(model.guildName) } + ) + }, + ) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + for (cat in model.channels) { + var expanded by rememberSaveable { mutableStateOf(true) } + val base = ListItemDefaults.segmentedShapes(0, 2) + val large = MaterialTheme.shapes.large + + SegmentedListItem( + onClick = { expanded = !expanded }, + colors = colors, + shapes = if (expanded) base else base.copy(shape = large), + trailingContent = { + Icon( + painterResource(if (expanded) Res.drawable.arrow_drop_up else Res.drawable.arrow_drop_down), + contentDescription = null, + modifier = Modifier + .background( + if (expanded) MaterialTheme.colorScheme.surface else Color.Transparent, + shape = RoundedCornerShape(100) + ) + .padding(6.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + }, + ) { + Text(text = cat.name) + } + AnimatedVisibility( + visible = expanded, + enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()), + exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()), + ) { + Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { + cat.channels.forEachIndexed { idx, ch -> + SegmentedListItem( + onClick = {}, + colors = colors, + leadingContent = { + val res = when (ch.type) { + ChannelBrowserModel.ChannelType.Text -> Res.drawable.tag + ChannelBrowserModel.ChannelType.Voice -> Res.drawable.volume_up + } + Icon( + painterResource(res), + contentDescription = null, + modifier = Modifier + .background( + MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(100) + ) + .padding(6.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + }, + shapes = ListItemDefaults.segmentedShapes( + idx + 1, + cat.channels.size + 1 + ) + ) { + Text(text = ch.name) + } + } + } + } + Spacer(Modifier.height(10.dp)) + } + Spacer(Modifier.height(peekHeight + 4.dp)) + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@PreviewScreenSizes +@Preview +@Composable +internal fun ChannelBrowserPreview() { + MaterialExpressiveTheme { + ChannelBrowser( + ChannelBrowserModel( + guildName = "My Awesome Guild", + channels = listOf( + ChannelBrowserModel.Category( + name = "Text Channels", + channels = listOf( + ChannelBrowserModel.Channel( + "general", + ChannelBrowserModel.ChannelType.Text + ), + ChannelBrowserModel.Channel( + "random", + ChannelBrowserModel.ChannelType.Text + ), + ChannelBrowserModel.Channel( + "music-discussion", + ChannelBrowserModel.ChannelType.Text + ) + ) + ), + ChannelBrowserModel.Category( + name = "Voice Channels", + channels = listOf( + ChannelBrowserModel.Channel( + "General", + ChannelBrowserModel.ChannelType.Voice + ), + ChannelBrowserModel.Channel( + "Gaming", + ChannelBrowserModel.ChannelType.Voice + ), + ChannelBrowserModel.Channel( + "dev-voice", + ChannelBrowserModel.ChannelType.Voice + ), + ChannelBrowserModel.Channel( + "chill", + ChannelBrowserModel.ChannelType.Voice + ) + ) + ), + ChannelBrowserModel.Category( + name = "Development", + channels = listOf( + ChannelBrowserModel.Channel( + "neon", + ChannelBrowserModel.ChannelType.Text + ), + ChannelBrowserModel.Channel( + "compose", + ChannelBrowserModel.ChannelType.Text + ), + ChannelBrowserModel.Channel( + "dev-voice-chat", + ChannelBrowserModel.ChannelType.Voice + ) + ) + ), + ChannelBrowserModel.Category( + name = "Off-topic", + channels = listOf( + ChannelBrowserModel.Channel( + "memes", + ChannelBrowserModel.ChannelType.Text + ), + ChannelBrowserModel.Channel( + "music", + ChannelBrowserModel.ChannelType.Voice + ), + ChannelBrowserModel.Channel( + "anime", + ChannelBrowserModel.ChannelType.Text + ), + ChannelBrowserModel.Channel( + "gaming-chat", + ChannelBrowserModel.ChannelType.Text + ) + ) + ) + ) + ) + ) + } +} diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Login.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Login.kt index 3f246d1..8ef0335 100644 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Login.kt +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Login.kt @@ -63,6 +63,7 @@ fun Login( onValueChange = { email = it }, label = { Text("Enter email") }, ) + // TODO: Switch to SecureTextField OutlinedTextField( value = password, onValueChange = { password = it }, diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt index ba030f1..b303839 100644 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt @@ -34,7 +34,10 @@ import moe.lava.neon.ui.Greeting import org.jetbrains.compose.resources.painterResource @Composable -fun Sample(onRequestLogout: () -> Unit) { +fun Sample( + navTest: () -> Unit, + onRequestLogout: () -> Unit, +) { val viewModel: SampleViewModel = metroViewModel() var showContent by remember { mutableStateOf(false) } Column( @@ -47,6 +50,9 @@ fun Sample(onRequestLogout: () -> Unit) { Button(onClick = { showContent = !showContent }) { Text("Click me!") } + Button(onClick = { navTest() }) { + Text("Click me too!") + } AnimatedVisibility(showContent) { val greeting = remember { Greeting().greet() } Column( diff --git a/ui/src/jvmMain/kotlin/moe/lava/neon/ui/ColorScheme.jvm.kt b/ui/src/jvmMain/kotlin/moe/lava/neon/ui/ColorScheme.jvm.kt new file mode 100644 index 0000000..23ce362 --- /dev/null +++ b/ui/src/jvmMain/kotlin/moe/lava/neon/ui/ColorScheme.jvm.kt @@ -0,0 +1,17 @@ +package moe.lava.neon.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.expressiveLightColorScheme +import androidx.compose.runtime.Composable + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +actual fun getColorScheme(): ColorScheme { + return when (isSystemInDarkTheme()) { + true -> darkColorScheme() + false -> expressiveLightColorScheme() + } +}