feat(ui): initial navigator prototype

Will be handling guild/channel browsing

- Bumped CMP material 3, so we don't have to use jetpack anymore
- Added coil for images later on
- Added a bajillion placeholder images
This commit is contained in:
Cilly Leang 2026-01-30 01:16:32 +11:00
parent 83f13d4b5c
commit 8e02b98c51
Signed by: cilly
GPG key ID: 6500251E087653C9
22 changed files with 721 additions and 284 deletions

View file

@ -11,6 +11,7 @@ androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0-alpha07"
androidx-nav3 = "1.0.0-alpha06"
androidx-testExt = "1.3.0"
coil = "3.3.0"
composeHotReload = "1.0.0"
composeMultiplatform = "1.11.0-alpha02"
desugar = "2.1.5"
@ -19,7 +20,7 @@ junit = "4.13.2"
kermit = "2.0.8"
kotlin = "2.3.0"
kotlinx-coroutines = "1.10.2"
material3 = "1.10.0-alpha05"
material3 = "1.11.0-alpha02"
material3-google = "1.5.0-alpha12"
material3-adaptive = "1.3.0-alpha03"
metro = "0.10.0"
@ -28,6 +29,8 @@ sqldelight = "2.2.1"
ktor = "3.4.0"
[libraries]
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
desugar = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar" }
hcaptcha-compose = { module = "com.github.hCaptcha.hcaptcha-android-sdk:compose-sdk", version.ref = "hcaptcha" }
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }

View file

@ -27,6 +27,8 @@ kotlin {
implementation(libs.androidx.appcompat)
implementation(libs.hcaptcha.compose)
implementation(libs.ktor.client.okhttp)
}
commonMain.dependencies {
implementation(project(":core"))
@ -36,8 +38,8 @@ kotlin {
// TODO: Desktop will not build
// Using upstream jetpack material3 for expressive list items in 1.5.0-alpha11
// At time of writing, cmp material3 is still on 1.5.0-alpha10
implementation(libs.compose.material3.google)
// implementation(libs.compose.material3)
implementation(libs.compose.material3)
// implementation(libs.compose.material3.google)
implementation(libs.compose.ui)
implementation(libs.compose.components.resources)
@ -53,6 +55,9 @@ kotlin {
implementation(libs.metrox.viewmodel.compose)
implementation(libs.kermit)
implementation(libs.coil.compose)
implementation(libs.coil.network.ktor3)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
@ -60,6 +65,8 @@ kotlin {
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)
implementation(libs.ktor.client.okhttp)
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View file

@ -21,6 +21,9 @@ import kotlinx.serialization.modules.polymorphic
import moe.lava.neon.ui.di.AppUiGraph
import moe.lava.neon.ui.screens.Login
import moe.lava.neon.ui.screens.Sample
import moe.lava.neon.ui.screens.navigator.Navigator
import moe.lava.neon.ui.screens.navigator.NavigatorModel
import moe.lava.neon.ui.screens.navigator.NavigatorPreviewProvider
import kotlin.system.exitProcess
private object Route {
@ -31,7 +34,7 @@ private object Route {
data object Sample : NavKey
@Serializable
data object ChannelBrowser : NavKey
data class Navigator(val left: Boolean) : NavKey
}
private val config = SavedStateConfiguration {
@ -39,7 +42,7 @@ 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())
subclass(Route.Navigator::class, Route.Navigator.serializer())
}
}
}
@ -78,10 +81,10 @@ fun App() {
}
)
}
entry<Route.Sample> { key ->
entry<Route.Sample> {
Sample(
navTest = {
backStack.add(Route.ChannelBrowser)
backStack.add(Route.Navigator(it))
},
onRequestLogout = {
backStack.clear()
@ -89,9 +92,21 @@ fun App() {
}
)
}
// entry<Route.ChannelBrowser> {
// ChannelBrowser()
// }
entry<Route.Navigator> { key ->
if (key.left) {
Navigator(
NavigatorPreviewProvider.base2.copy(
guildNavPosition = NavigatorModel.GuildNavPosition.LeftSidebar
)
)
} else {
Navigator(
NavigatorPreviewProvider.base2.copy(
guildNavPosition = NavigatorModel.GuildNavPosition.BottomSheet
)
)
}
}
}
)
}

View file

@ -1,271 +0,0 @@
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.MotionScheme
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.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
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 guildPosition: GuildPosition,
val guildName: String,
val channels: List<Category>,
) {
data class Category(
val name: String,
val channels: List<Channel>,
)
data class Channel(
val name: String,
val type: ChannelType,
)
enum class ChannelType {
Text,
Voice
}
enum class GuildPosition {
LeftSidebar,
BottomSheet
}
}
@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)
@Preview
@Composable
internal fun ChannelBrowserPreview(
@PreviewParameter(ChannelBrowserPreviewProvider::class) model: ChannelBrowserModel
) {
MaterialExpressiveTheme(
// This will break live previews
// colorScheme = getColorScheme(),
motionScheme = MotionScheme.expressive(),
) {
ChannelBrowser(model)
}
}
internal class ChannelBrowserPreviewProvider : PreviewParameterProvider<ChannelBrowserModel> {
private val base = ChannelBrowserModel(
guildPosition = ChannelBrowserModel.GuildPosition.BottomSheet,
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
)
)
)
)
)
private val models = listOf(
base.copy(guildPosition = ChannelBrowserModel.GuildPosition.BottomSheet),
base.copy(guildPosition = ChannelBrowserModel.GuildPosition.LeftSidebar),
)
override val values: Sequence<ChannelBrowserModel> = models.asSequence()
override fun getDisplayName(index: Int): String =
models[index].guildPosition.toString()
}

View file

@ -35,7 +35,7 @@ import org.jetbrains.compose.resources.painterResource
@Composable
fun Sample(
navTest: () -> Unit,
navTest: (Boolean) -> Unit,
onRequestLogout: () -> Unit,
) {
val viewModel: SampleViewModel = metroViewModel()
@ -50,8 +50,11 @@ fun Sample(
Button(onClick = { showContent = !showContent }) {
Text("Click me!")
}
Button(onClick = { navTest() }) {
Text("Click me too!")
Button(onClick = { navTest(true) }) {
Text("Click me (left)!")
}
Button(onClick = { navTest(false) }) {
Text("Click me (bottom!")
}
AnimatedVisibility(showContent) {
val greeting = remember { Greeting().greet() }

View file

@ -0,0 +1,126 @@
package moe.lava.neon.ui.screens.navigator
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.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.Text
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.unit.Dp
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
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun Channels(
model: NavigatorModel,
bottomSpace: Dp,
padding: PaddingValues,
) {
val colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
Column(
modifier = Modifier
.padding(padding.plus(PaddingValues(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.Companion.Transparent,
shape = RoundedCornerShape(100)
)
.padding(6.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
},
) {
Text(
text = cat.name,
style = MaterialTheme.typography.labelLarge,
)
}
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) {
NavigatorModel.ChannelType.Text -> Res.drawable.tag
NavigatorModel.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,
style = MaterialTheme.typography.labelLargeEmphasized,
)
}
}
}
}
Spacer(Modifier.height(10.dp))
}
Spacer(Modifier.height(bottomSpace))
}
}

View file

@ -0,0 +1,103 @@
package moe.lava.neon.ui.screens.navigator
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.graphics.shapes.Morph
import coil3.compose.AsyncImage
import moe.lava.neon.ui.util.MorphPolygonShape
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun GuildButton(
model: NavigatorModel.Guild,
morph: Morph = Morph(MaterialShapes.Companion.Diamond, MaterialShapes.Companion.Cookie9Sided),
selected: Boolean,
onSelected: (NavigatorModel.Guild) -> Unit,
) {
val interactionSource = remember {
MutableInteractionSource()
}
val isHovered by interactionSource.collectIsHoveredAsState()
val isPressed by interactionSource.collectIsPressedAsState()
val target = when {
isHovered -> 0.8f
isPressed -> 0.85f
selected -> 1f
else -> 0f
}
val fadingIn = isPressed && !selected || !isPressed && selected
val fastProgress by animateFloatAsState(
targetValue = target,
label = "progress",
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec()
)
val defaultProgress by animateFloatAsState(
targetValue = target,
label = "progress",
animationSpec = MaterialTheme.motionScheme.defaultSpatialSpec()
)
val slowProgress by animateFloatAsState(
targetValue = target,
label = "progress",
animationSpec = MaterialTheme.motionScheme.slowSpatialSpec()
)
val progress = if (fadingIn) fastProgress else slowProgress
val rotation = if (fadingIn) defaultProgress * 360f else (1 - defaultProgress) * 360f
Box(
Modifier
.size(guildIconSize + 24.dp)
.clickable(
interactionSource = interactionSource,
indication = null,
) { onSelected(model) }
) {
Box(
Modifier
.fillMaxSize()
.scale(progress)
.rotate(rotation)
.alpha(progress)
) {
Box(
Modifier
.fillMaxSize()
.clip(MorphPolygonShape(morph, progress))
.background(MaterialTheme.colorScheme.primary)
.fillMaxSize()
.align(Alignment.Companion.Center)
)
}
Box(Modifier.fillMaxSize().padding(12.dp)) {
AsyncImage(
model = model.url,
contentDescription = model.name,
contentScale = ContentScale.Companion.Crop,
modifier = Modifier.clip(CircleShape),
)
}
}
}

View file

@ -0,0 +1,171 @@
package moe.lava.neon.ui.screens.navigator
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeGestures
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.dp
import androidx.graphics.shapes.Morph
import kotlin.math.roundToInt
val guildIconSize = 56.dp
data class NavigatorModel(
val guildNavPosition: GuildNavPosition,
val guildName: String,
val channels: List<Category>,
val guilds: List<Guild>,
) {
data class Category(
val name: String,
val channels: List<Channel>,
)
data class Guild(
val name: String,
val url: String,
)
data class Channel(
val name: String,
val type: ChannelType,
)
enum class ChannelType {
Text,
Voice
}
enum class GuildNavPosition {
LeftSidebar,
BottomSheet
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun Navigator(
model: NavigatorModel
) {
when (model.guildNavPosition) {
NavigatorModel.GuildNavPosition.BottomSheet -> BottomSheetNavigator(model)
NavigatorModel.GuildNavPosition.LeftSidebar -> SidebarNavigator(model)
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun BottomSheetNavigator(
model: NavigatorModel
) {
val morph = remember {
Morph(MaterialShapes.Diamond, MaterialShapes.Cookie9Sided)
}
val sheetState = rememberStandardBottomSheetState(SheetValue.Expanded)
val screenHeight = LocalWindowInfo.current.containerSize.height
val currentSheetHeight = runCatching { screenHeight - sheetState.requireOffset() }
val offset = currentSheetHeight.getOrDefault(0.0f).roundToInt()
val offsetDp = with (LocalDensity.current) { offset.toDp() } - TopAppBarDefaults.TopAppBarExpandedHeight
BottomSheetScaffold(
sheetContent = {
var selected by remember { mutableIntStateOf(-1) }
LazyRow(
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeGestures.only(WindowInsetsSides.Bottom))
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy((-4).dp)
) {
itemsIndexed(model.guilds) { idx, guild ->
if (idx == 0) return@itemsIndexed
GuildButton(
model = guild,
morph = morph,
selected = selected == idx,
onSelected = { selected = idx }
)
}
}
},
scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = sheetState
),
topBar = {
TopAppBar(
title = { Text(model.guildName) }
)
},
) { innerPadding ->
Channels(model, offsetDp, innerPadding)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
private fun SidebarNavigator(
model: NavigatorModel
) {
val morph = remember {
Morph(MaterialShapes.Diamond, MaterialShapes.Cookie9Sided)
}
Row(Modifier.fillMaxSize()) {
var selected by remember { mutableIntStateOf(-1) }
LazyColumn(
Modifier
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.padding(horizontal = 8.dp)
.fillMaxHeight(),
contentPadding = WindowInsets.safeGestures.only(
WindowInsetsSides.Top + WindowInsetsSides.Bottom
).asPaddingValues(),
verticalArrangement = Arrangement.spacedBy((-4).dp)
) {
itemsIndexed(model.guilds) { idx, guild ->
GuildButton(
model = guild,
morph = morph,
selected = selected == idx,
onSelected = { selected = idx }
)
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(model.guildName) }
)
},
) { innerPadding ->
Channels(model, 0.dp, innerPadding)
}
}
}

View file

@ -0,0 +1,176 @@
package moe.lava.neon.ui.screens.navigator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
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.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import coil3.annotation.ExperimentalCoilApi
import moe.lava.neon.resources.Res
import moe.lava.neon.ui.util.ThemedPreview
@Preview
@Composable
internal fun NavigatorPreview(
@PreviewParameter(NavigatorPreviewProvider::class) model: NavigatorModel
) {
ThemedPreview {
Navigator(model)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalCoilApi::class)
@Preview
@Composable
internal fun NavigatorGuildPreview() {
ThemedPreview {
var selected by remember { mutableStateOf(false) }
GuildButton(NavigatorPreviewProvider.base.guilds[1], selected = selected) {
selected = !selected
}
}
}
internal class NavigatorPreviewProvider : PreviewParameterProvider<NavigatorModel> {
companion object {
val base = NavigatorModel(
guildNavPosition = NavigatorModel.GuildNavPosition.BottomSheet,
guildName = "My Awesome Guild",
guilds = listOf(
NavigatorModel.Guild(name = "Hu Tao", url = ""),
NavigatorModel.Guild(name = "Who?", url = ""),
NavigatorModel.Guild(name = "Tao", url = ""),
NavigatorModel.Guild(name = "Hutao", url = ""),
NavigatorModel.Guild(name = "huuutao", url = ""),
),
channels = listOf(
NavigatorModel.Category(
name = "Text Channels",
channels = listOf(
NavigatorModel.Channel(
"general",
NavigatorModel.ChannelType.Text
),
NavigatorModel.Channel(
"random",
NavigatorModel.ChannelType.Text
),
NavigatorModel.Channel(
"music-discussion",
NavigatorModel.ChannelType.Text
)
)
),
NavigatorModel.Category(
name = "Voice Channels",
channels = listOf(
NavigatorModel.Channel(
"General",
NavigatorModel.ChannelType.Voice
),
NavigatorModel.Channel(
"Gaming",
NavigatorModel.ChannelType.Voice
),
NavigatorModel.Channel(
"dev-voice",
NavigatorModel.ChannelType.Voice
),
NavigatorModel.Channel(
"chill",
NavigatorModel.ChannelType.Voice
)
)
),
NavigatorModel.Category(
name = "Development",
channels = listOf(
NavigatorModel.Channel(
"neon",
NavigatorModel.ChannelType.Text
),
NavigatorModel.Channel(
"compose",
NavigatorModel.ChannelType.Text
),
NavigatorModel.Channel(
"dev-voice-chat",
NavigatorModel.ChannelType.Voice
)
)
),
NavigatorModel.Category(
name = "Off-topic",
channels = listOf(
NavigatorModel.Channel(
"memes",
NavigatorModel.ChannelType.Text
),
NavigatorModel.Channel(
"music",
NavigatorModel.ChannelType.Voice
),
NavigatorModel.Channel(
"anime",
NavigatorModel.ChannelType.Text
),
NavigatorModel.Channel(
"gaming-chat",
NavigatorModel.ChannelType.Text
)
)
)
)
)
val base2 get() = base.copy(
guilds = listOf(
NavigatorModel.Guild(name = "Hu Tao", url = Res.getUri("drawable/placeholder_1.webp")),
NavigatorModel.Guild(name = "Who?", url = Res.getUri("drawable/placeholder_2.jpg")),
NavigatorModel.Guild(name = "Tao", url = Res.getUri("drawable/placeholder_3.jpg")),
NavigatorModel.Guild(name = "Hutao", url = Res.getUri("drawable/placeholder_4.webp")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_5.png")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_6.jpg")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_7.jpg")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_8.jpg")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_9.jpg")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_10.jpg")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_11.jpg")),
NavigatorModel.Guild(name = "Hu Tao", url = Res.getUri("drawable/placeholder_1.webp")),
NavigatorModel.Guild(name = "Who?", url = Res.getUri("drawable/placeholder_2.jpg")),
NavigatorModel.Guild(name = "Tao", url = Res.getUri("drawable/placeholder_3.jpg")),
NavigatorModel.Guild(name = "Hutao", url = Res.getUri("drawable/placeholder_4.webp")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_5.png")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_6.jpg")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_7.jpg")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_8.jpg")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_9.jpg")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_10.jpg")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_11.jpg")),
NavigatorModel.Guild(name = "Hu Tao", url = Res.getUri("drawable/placeholder_1.webp")),
NavigatorModel.Guild(name = "Who?", url = Res.getUri("drawable/placeholder_2.jpg")),
NavigatorModel.Guild(name = "Tao", url = Res.getUri("drawable/placeholder_3.jpg")),
NavigatorModel.Guild(name = "Hutao", url = Res.getUri("drawable/placeholder_4.webp")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_5.png")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_6.jpg")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_7.jpg")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_8.jpg")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_9.jpg")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_10.jpg")),
NavigatorModel.Guild(name = "huuutao", url = Res.getUri("drawable/placeholder_11.jpg")),
)
)
}
private val models = listOf(
base.copy(guildNavPosition = NavigatorModel.GuildNavPosition.BottomSheet),
base.copy(guildNavPosition = NavigatorModel.GuildNavPosition.LeftSidebar),
)
override val values: Sequence<NavigatorModel> = models.asSequence()
override fun getDisplayName(index: Int): String =
models[index].guildNavPosition.toString()
}

View file

@ -0,0 +1,30 @@
package moe.lava.neon.ui.util
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.toPath
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Matrix
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.graphics.shapes.Morph
class MorphPolygonShape(
private val morph: Morph,
private val percentage: Float,
) : Shape {
private val matrix = Matrix()
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
matrix.scale(size.width, size.height)
val path = morph.toPath(progress = percentage)
path.transform(matrix)
return Outline.Generic(path)
}
}

View file

@ -0,0 +1,74 @@
package moe.lava.neon.ui.util
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.compose.ui.graphics.painter.Painter
import coil3.ColorImage
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImagePainter
import coil3.compose.AsyncImagePreviewHandler
import coil3.compose.LocalAsyncImagePreviewHandler
import coil3.request.SuccessResult
import moe.lava.neon.resources.Res
import moe.lava.neon.resources.placeholder_1
import moe.lava.neon.resources.placeholder_10
import moe.lava.neon.resources.placeholder_11
import moe.lava.neon.resources.placeholder_2
import moe.lava.neon.resources.placeholder_3
import moe.lava.neon.resources.placeholder_4
import moe.lava.neon.resources.placeholder_5
import moe.lava.neon.resources.placeholder_6
import moe.lava.neon.resources.placeholder_7
import moe.lava.neon.resources.placeholder_8
import moe.lava.neon.resources.placeholder_9
import org.jetbrains.compose.resources.painterResource
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalCoilApi::class)
@Composable
fun ThemedPreview(content: @Composable () -> Unit) {
val placeholders = placeholders()
val previewHandler = AsyncImagePreviewHandler { _, request ->
val img = ColorImage(0)
AsyncImagePainter.State.Success(placeholders.next(), SuccessResult(img, request))
}
CompositionLocalProvider(LocalAsyncImagePreviewHandler provides previewHandler) {
MaterialExpressiveTheme(
// This will break live previews
// colorScheme = getColorScheme(),
motionScheme = MotionScheme.Companion.expressive(),
) {
content()
}
}
}
@Composable
private fun placeholders(): Iterator<Painter> {
val list = listOf(
Res.drawable.placeholder_1,
Res.drawable.placeholder_2,
Res.drawable.placeholder_3,
Res.drawable.placeholder_4,
Res.drawable.placeholder_5,
Res.drawable.placeholder_6,
Res.drawable.placeholder_7,
Res.drawable.placeholder_8,
Res.drawable.placeholder_9,
Res.drawable.placeholder_10,
Res.drawable.placeholder_11,
).map { painterResource(it) }
return object : Iterator<Painter> {
override fun hasNext() = true
private var idx = 0
private val maxIdx = list.size
override fun next(): Painter {
if (idx >= maxIdx) idx = 0
return list[idx++]
}
}
}