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()
+ }
+}