From 7535d8342e84a534fcba6556f514e26e402af2c6 Mon Sep 17 00:00:00 2001 From: Cilly Leang Date: Sat, 31 Jan 2026 15:31:54 +1100 Subject: [PATCH] feat(ui): full adaptive layout prototype --- .../commonMain/kotlin/moe/lava/neon/ui/App.kt | 42 +++++- .../kotlin/moe/lava/neon/ui/Previews.kt | 78 ++++++++++++ .../moe/lava/neon/ui/screens/chat/Chat.kt | 23 ++++ .../neon/ui/screens/members/MembersList.kt | 108 ++++++++++++++++ .../neon/ui/screens/navigator/Channels.kt | 10 +- .../neon/ui/screens/navigator/GuildButton.kt | 2 +- .../neon/ui/screens/navigator/Navigator.kt | 49 +++++-- .../neon/ui/screens/navigator/Previews.kt | 4 +- .../moe/lava/neon/ui/util/ThreePaneScene.kt | 120 ++++++++++++++++++ 9 files changed, 415 insertions(+), 21 deletions(-) create mode 100644 ui/src/commonMain/kotlin/moe/lava/neon/ui/Previews.kt create mode 100644 ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/chat/Chat.kt create mode 100644 ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/members/MembersList.kt create mode 100644 ui/src/commonMain/kotlin/moe/lava/neon/ui/util/ThreePaneScene.kt 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 2706387..a24fac4 100644 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/App.kt +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/App.kt @@ -3,6 +3,7 @@ package moe.lava.neon.ui import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MotionScheme +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator @@ -21,12 +22,16 @@ 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.chat.Chat +import moe.lava.neon.ui.screens.members.MembersList 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 moe.lava.neon.ui.util.ThreePaneSceneStrategy +import moe.lava.neon.ui.util.rememberThreePaneSceneStrategy import kotlin.system.exitProcess -private object Route { +object Route { @Serializable data object Login : NavKey @@ -35,6 +40,12 @@ private object Route { @Serializable data class Navigator(val left: Boolean) : NavKey + + @Serializable + data object Chat : NavKey + + @Serializable + data object MembersList : NavKey } private val config = SavedStateConfiguration { @@ -43,11 +54,13 @@ private val config = SavedStateConfiguration { subclass(Route.Login::class, Route.Login.serializer()) subclass(Route.Sample::class, Route.Sample.serializer()) subclass(Route.Navigator::class, Route.Navigator.serializer()) + subclass(Route.Chat::class, Route.Chat.serializer()) + subclass(Route.MembersList::class, Route.MembersList.serializer()) } } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3AdaptiveApi::class) @Composable fun App() { Thread.setDefaultUncaughtExceptionHandler { t: Thread, e: Throwable -> @@ -64,7 +77,8 @@ fun App() { motionScheme = MotionScheme.expressive(), ) { val init = if (graph.auth.token != null) Route.Sample else Route.Login - val backStack = rememberNavBackStack(config, init) + val backStack = rememberNavBackStack(config, Route.Sample) + val threePaneStrategy = rememberThreePaneSceneStrategy() NavDisplay( backStack = backStack, entryDecorators = listOf( @@ -72,6 +86,7 @@ fun App() { rememberViewModelStoreNavEntryDecorator(), ), onBack = { backStack.removeLastOrNull() }, + sceneStrategy = threePaneStrategy, entryProvider = entryProvider { entry { Login( @@ -85,6 +100,8 @@ fun App() { Sample( navTest = { backStack.add(Route.Navigator(it)) + backStack.add(Route.Chat) + backStack.add(Route.MembersList) }, onRequestLogout = { backStack.clear() @@ -92,7 +109,10 @@ fun App() { } ) } - entry { key -> + + entry( + metadata = ThreePaneSceneStrategy.listPane() + ) { key -> if (key.left) { Navigator( NavigatorPreviewProvider.base2.copy( @@ -107,6 +127,20 @@ fun App() { ) } } + + entry( + metadata = ThreePaneSceneStrategy.detailPane() + ) { + Chat( + onOpenMembers = { backStack.add(Route.MembersList) } + ) + } + + entry( + metadata = ThreePaneSceneStrategy.extraPane() + ) { + MembersList() + } } ) } diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/Previews.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/Previews.kt new file mode 100644 index 0000000..123957d --- /dev/null +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/Previews.kt @@ -0,0 +1,78 @@ +package moe.lava.neon.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewScreenSizes +import androidx.compose.ui.unit.dp +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.scene.SceneStrategyScope +import moe.lava.neon.ui.screens.chat.Chat +import moe.lava.neon.ui.screens.members.MembersList +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 moe.lava.neon.ui.util.ThemedPreview +import moe.lava.neon.ui.util.ThreePaneSceneStrategy +import moe.lava.neon.ui.util.rememberThreePaneSceneStrategy + +@PreviewScreenSizes +@Preview( + name = "Phone - Navigator", + device = "spec:width=411dp,height=892dp,orientation=portrait,dpi=420", + showSystemUi = true, +) +@Composable +fun AppPrewiew() { + val chat: NavEntry = NavEntry( + Route.Chat, + metadata = ThreePaneSceneStrategy.detailPane(), + ) { + Chat(onOpenMembers = {}) + } + val nav: NavEntry = NavEntry( + Route.Navigator(true), + metadata = ThreePaneSceneStrategy.listPane(), + ) { + Navigator( + NavigatorPreviewProvider.base2.copy( + guildNavPosition = NavigatorModel.GuildNavPosition.LeftSidebar + ) + ) + } + val nav2: NavEntry = NavEntry( + Route.Navigator(true), + metadata = ThreePaneSceneStrategy.listPane(), + ) { + Navigator( + NavigatorPreviewProvider.base2.copy( + guildNavPosition = NavigatorModel.GuildNavPosition.BottomSheet + ) + ) + } + val mem: NavEntry = NavEntry( + Route.MembersList, + metadata = ThreePaneSceneStrategy.extraPane(), + ) { + MembersList() + } + val strat = rememberThreePaneSceneStrategy() + ThemedPreview { + Box(Modifier.fillMaxSize()) { + SceneStrategyScope().run { + strat.run { + calculateScene(listOf(chat, nav, mem))?.content() + ?: if (LocalWindowInfo.current.containerDpSize.height == 892.dp) { + nav2.Content() + } else { + chat.Content() + } + } + } + } + } +} diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/chat/Chat.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/chat/Chat.kt new file mode 100644 index 0000000..589caf4 --- /dev/null +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/chat/Chat.kt @@ -0,0 +1,23 @@ +package moe.lava.neon.ui.screens.chat + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun Chat( + onOpenMembers: () -> Unit, +) { + Box(Modifier.fillMaxSize()) { + Button( + modifier = Modifier.align(Alignment.Center), + onClick = { onOpenMembers() } + ) { + Text("Hi! Click to open members") + } + } +} diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/members/MembersList.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/members/MembersList.kt new file mode 100644 index 0000000..3f0703f --- /dev/null +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/members/MembersList.kt @@ -0,0 +1,108 @@ +package moe.lava.neon.ui.screens.members + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import moe.lava.neon.ui.util.ThemedPreview + +data class MembersListModel( + val members: List, +) { + data class Member( + val avatar: String, + val name: String, + val status: String?, + ) +} + +val sampleModel = MembersListModel( + listOf( + MembersListModel.Member("", "Mem1", "Something?"), + MembersListModel.Member("", "Mem2", null), + MembersListModel.Member("", "Mem4", "Bleh"), + MembersListModel.Member("", "Mem8", null), + MembersListModel.Member("", "Mem7", null), + ) +) + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun MembersList( + model: MembersListModel = sampleModel, +) { + Scaffold( + + ) { contentPadding -> + Box( + Modifier.fillMaxSize() + .padding(contentPadding.plus(PaddingValues(16.dp))) + ) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap) + ) { + model.members.forEachIndexed { idx, member -> + MemberEntry(member, idx, model.members.size) + } + Spacer(Modifier.height(10.dp)) + model.members.forEachIndexed { idx, member -> + MemberEntry(member, idx, model.members.size) + } + Spacer(Modifier.height(10.dp)) + model.members.forEachIndexed { idx, member -> + MemberEntry(member, idx, model.members.size) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun MemberEntry( + model: MembersListModel.Member, + index: Int, + size: Int, +) { + val colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainer) + SegmentedListItem( + onClick = {}, + colors = colors, + leadingContent = { }, + supportingContent = { + model.status?.let { Text(it) } + }, + shapes = ListItemDefaults.segmentedShapes(index, size) + ) { + Text( + text = model.name, + style = MaterialTheme.typography.bodyLarge, + ) + } +} + +@Preview +@Composable +fun MembersListPreview() { + ThemedPreview { + MembersList() + } +} diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/navigator/Channels.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/navigator/Channels.kt index 3e20a73..89c5133 100644 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/navigator/Channels.kt +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/navigator/Channels.kt @@ -42,8 +42,13 @@ fun Channels( model: NavigatorModel, bottomSpace: Dp, padding: PaddingValues, + onNavigate: (NavigatorModel.Channel) -> Unit, ) { - val colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainer) + val colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + selectedContainerColor = MaterialTheme.colorScheme.primary, + selectedContentColor = MaterialTheme.colorScheme.onPrimary, + ) Column( modifier = Modifier @@ -87,7 +92,8 @@ fun Channels( Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { cat.channels.forEachIndexed { idx, ch -> SegmentedListItem( - onClick = {}, + selected = model.selected.second == ch, + onClick = { onNavigate(ch) }, colors = colors, leadingContent = { val res = when (ch.type) { diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/navigator/GuildButton.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/navigator/GuildButton.kt index cc46d72..e68104a 100644 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/navigator/GuildButton.kt +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/navigator/GuildButton.kt @@ -43,9 +43,9 @@ fun GuildButton( val isHovered by interactionSource.collectIsHoveredAsState() val isPressed by interactionSource.collectIsPressedAsState() val target = when { - isHovered -> 0.8f isPressed -> 0.85f selected -> 1f + isHovered -> 0.8f else -> 0f } val fadingIn = isPressed && !selected || !isPressed && selected diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/navigator/Navigator.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/navigator/Navigator.kt index b7d8657..4d9fb63 100644 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/navigator/Navigator.kt +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/navigator/Navigator.kt @@ -30,7 +30,7 @@ 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.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -47,6 +47,7 @@ data class NavigatorModel( val guildName: String, val channels: List, val guilds: List, + val selected: Pair, ) { data class Category( val name: String, @@ -70,21 +71,38 @@ data class NavigatorModel( } } +// TODO: Move channel's bottomSpace and padding into a Modifier, so this can be greatly simplified +// by sharing the Channels and GuildButton implementations in both layouts @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun Navigator( model: NavigatorModel ) { + // TODO: this obviously should be done properly + var selected by remember { mutableStateOf(model.selected) } + val onNavigateChannel: (NavigatorModel.Channel) -> Unit = { ch -> selected = selected.copy(second = ch) } + val onNavigateGuild: (NavigatorModel.Guild) -> Unit = { guild -> selected = selected.copy(first = guild) } + when (model.guildNavPosition) { - NavigatorModel.GuildNavPosition.BottomSheet -> BottomSheetNavigator(model) - NavigatorModel.GuildNavPosition.LeftSidebar -> SidebarNavigator(model) + NavigatorModel.GuildNavPosition.BottomSheet -> BottomSheetNavigator( + model.copy(selected = selected), + onNavigateGuild = onNavigateGuild, + onNavigateChannel = onNavigateChannel, + ) + NavigatorModel.GuildNavPosition.LeftSidebar -> SidebarNavigator( + model.copy(selected = selected), + onNavigateGuild = onNavigateGuild, + onNavigateChannel = onNavigateChannel, + ) } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable private fun BottomSheetNavigator( - model: NavigatorModel + model: NavigatorModel, + onNavigateGuild: (NavigatorModel.Guild) -> Unit, + onNavigateChannel: (NavigatorModel.Channel) -> Unit, ) { val morph = remember { Morph(MaterialShapes.Diamond, MaterialShapes.Cookie9Sided) @@ -97,7 +115,6 @@ private fun BottomSheetNavigator( val offsetDp = with (LocalDensity.current) { offset.toDp() } - TopAppBarDefaults.TopAppBarExpandedHeight BottomSheetScaffold( sheetContent = { - var selected by remember { mutableIntStateOf(-1) } LazyRow( Modifier .fillMaxWidth() @@ -110,8 +127,8 @@ private fun BottomSheetNavigator( GuildButton( model = guild, morph = morph, - selected = selected == idx, - onSelected = { selected = idx } + selected = model.selected.first == guild, + onSelected = { onNavigateGuild(guild) } ) } } @@ -125,20 +142,26 @@ private fun BottomSheetNavigator( ) }, ) { innerPadding -> - Channels(model, offsetDp, innerPadding) + Channels( + model = model, + bottomSpace = offsetDp, + padding = innerPadding, + onNavigate = { onNavigateChannel(it) } + ) } } @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable private fun SidebarNavigator( - model: NavigatorModel + model: NavigatorModel, + onNavigateGuild: (NavigatorModel.Guild) -> Unit, + onNavigateChannel: (NavigatorModel.Channel) -> Unit, ) { val morph = remember { Morph(MaterialShapes.Diamond, MaterialShapes.Cookie9Sided) } Row(Modifier.fillMaxSize()) { - var selected by remember { mutableIntStateOf(-1) } LazyColumn( Modifier .background(MaterialTheme.colorScheme.surfaceContainerLow) @@ -153,8 +176,8 @@ private fun SidebarNavigator( GuildButton( model = guild, morph = morph, - selected = selected == idx, - onSelected = { selected = idx } + selected = model.selected.first == guild, + onSelected = { onNavigateGuild(guild) } ) } } @@ -165,7 +188,7 @@ private fun SidebarNavigator( ) }, ) { innerPadding -> - Channels(model, 0.dp, innerPadding) + Channels(model, 0.dp, innerPadding, onNavigate = { onNavigateChannel(it) }) } } } diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/navigator/Previews.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/navigator/Previews.kt index 5b88f4d..d6b85cc 100644 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/navigator/Previews.kt +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/navigator/Previews.kt @@ -47,6 +47,7 @@ internal class NavigatorPreviewProvider : PreviewParameterProvider( + override val key: Any, + override val previousEntries: List>, + val listEntry: NavEntry?, + val detailEntry: NavEntry, + val extraEntry: NavEntry?, +) : Scene { + override val entries: List> = listOfNotNull(listEntry, detailEntry, extraEntry) + override val content: @Composable (() -> Unit) = { + Row(modifier = Modifier.fillMaxSize()) { + if (listEntry != null) { + Column(modifier = Modifier.width(listWidth)) { + listEntry.Content() + } + } + Column(modifier = Modifier.weight(1f)) { + AnimatedContent( + targetState = detailEntry, + contentKey = { entry -> entry.contentKey }, + transitionSpec = { + slideInHorizontally( + initialOffsetX = { it } + ) togetherWith + slideOutHorizontally(targetOffsetX = { -it }) + } + ){ entry -> + entry.Content() + } + } + if (extraEntry != null) { + Column(modifier = Modifier.width(extraWidth)) { + AnimatedContent( + targetState = extraEntry, + contentKey = { entry -> entry.contentKey }, + transitionSpec = { + slideInHorizontally( + initialOffsetX = { it } + ) togetherWith + slideOutHorizontally(targetOffsetX = { -it }) + } + ){ entry -> + entry.Content() + } + } + } + } + } +} + +private const val LIST_KEY = "ThreePaneScene-List" +private const val DETAIL_KEY = "ThreePaneScene-Detail" +private const val EXTRA_KEY = "ThreePaneScene-Extra" + +class ThreePaneSceneStrategy(val windowSizeClass: WindowSizeClass) : SceneStrategy { + override fun SceneStrategyScope.calculateScene(entries: List>): Scene? { + val useTwoPanes = when { + windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_LARGE_LOWER_BOUND) + -> false + windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) + -> true + else -> return null + } + + val listEntry = entries.findLast { it.metadata.containsKey(LIST_KEY) } ?: return null + val detailEntry = entries.findLast { it.metadata.containsKey(DETAIL_KEY) } ?: return null + val extraEntry = entries.findLast { it.metadata.containsKey(EXTRA_KEY) } + + val sceneKey = listEntry.contentKey + + return ThreePaneScene( + key = sceneKey, + previousEntries = entries.dropLast(1), + listEntry = listEntry.takeIf { !(useTwoPanes && extraEntry != null) }, +// listEntry = listEntry, + detailEntry = detailEntry, + extraEntry = extraEntry, +// extraEntry = extraEntry.takeIf { !useTwoPanes }, + ) + } + + companion object { + fun listPane() = mapOf(LIST_KEY to true) + fun detailPane() = mapOf(DETAIL_KEY to true) + fun extraPane() = mapOf(EXTRA_KEY to true) + } +} + +@Composable +fun rememberThreePaneSceneStrategy(): ThreePaneSceneStrategy { + val windowSizeClass = currentWindowAdaptiveInfo(true).windowSizeClass + + return remember(windowSizeClass) { + ThreePaneSceneStrategy(windowSizeClass) + } +}