diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index af9baa2..918bb3b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 3a911a8..040d6af 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -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) } } } diff --git a/ui/src/commonMain/composeResources/drawable/placeholder_1.webp b/ui/src/commonMain/composeResources/drawable/placeholder_1.webp new file mode 100644 index 0000000..1e43db5 Binary files /dev/null and b/ui/src/commonMain/composeResources/drawable/placeholder_1.webp differ diff --git a/ui/src/commonMain/composeResources/drawable/placeholder_10.jpg b/ui/src/commonMain/composeResources/drawable/placeholder_10.jpg new file mode 100644 index 0000000..8408fb3 Binary files /dev/null and b/ui/src/commonMain/composeResources/drawable/placeholder_10.jpg differ diff --git a/ui/src/commonMain/composeResources/drawable/placeholder_11.jpg b/ui/src/commonMain/composeResources/drawable/placeholder_11.jpg new file mode 100644 index 0000000..08b7782 Binary files /dev/null and b/ui/src/commonMain/composeResources/drawable/placeholder_11.jpg differ diff --git a/ui/src/commonMain/composeResources/drawable/placeholder_2.jpg b/ui/src/commonMain/composeResources/drawable/placeholder_2.jpg new file mode 100644 index 0000000..7552f40 Binary files /dev/null and b/ui/src/commonMain/composeResources/drawable/placeholder_2.jpg differ diff --git a/ui/src/commonMain/composeResources/drawable/placeholder_3.jpg b/ui/src/commonMain/composeResources/drawable/placeholder_3.jpg new file mode 100644 index 0000000..b03cc4c Binary files /dev/null and b/ui/src/commonMain/composeResources/drawable/placeholder_3.jpg differ diff --git a/ui/src/commonMain/composeResources/drawable/placeholder_4.webp b/ui/src/commonMain/composeResources/drawable/placeholder_4.webp new file mode 100644 index 0000000..de5ea6d Binary files /dev/null and b/ui/src/commonMain/composeResources/drawable/placeholder_4.webp differ diff --git a/ui/src/commonMain/composeResources/drawable/placeholder_5.png b/ui/src/commonMain/composeResources/drawable/placeholder_5.png new file mode 100644 index 0000000..933c65b Binary files /dev/null and b/ui/src/commonMain/composeResources/drawable/placeholder_5.png differ diff --git a/ui/src/commonMain/composeResources/drawable/placeholder_6.jpg b/ui/src/commonMain/composeResources/drawable/placeholder_6.jpg new file mode 100644 index 0000000..2a39b88 Binary files /dev/null and b/ui/src/commonMain/composeResources/drawable/placeholder_6.jpg differ diff --git a/ui/src/commonMain/composeResources/drawable/placeholder_7.jpg b/ui/src/commonMain/composeResources/drawable/placeholder_7.jpg new file mode 100644 index 0000000..950a59b Binary files /dev/null and b/ui/src/commonMain/composeResources/drawable/placeholder_7.jpg differ diff --git a/ui/src/commonMain/composeResources/drawable/placeholder_8.jpg b/ui/src/commonMain/composeResources/drawable/placeholder_8.jpg new file mode 100644 index 0000000..19f95d3 Binary files /dev/null and b/ui/src/commonMain/composeResources/drawable/placeholder_8.jpg differ diff --git a/ui/src/commonMain/composeResources/drawable/placeholder_9.jpg b/ui/src/commonMain/composeResources/drawable/placeholder_9.jpg new file mode 100644 index 0000000..f367937 Binary files /dev/null and b/ui/src/commonMain/composeResources/drawable/placeholder_9.jpg differ 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 ccb93e6..2706387 100644 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/App.kt +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/App.kt @@ -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 { key -> + entry { Sample( navTest = { - backStack.add(Route.ChannelBrowser) + backStack.add(Route.Navigator(it)) }, onRequestLogout = { backStack.clear() @@ -89,9 +92,21 @@ fun App() { } ) } -// entry { -// ChannelBrowser() -// } + entry { key -> + if (key.left) { + Navigator( + NavigatorPreviewProvider.base2.copy( + guildNavPosition = NavigatorModel.GuildNavPosition.LeftSidebar + ) + ) + } else { + Navigator( + NavigatorPreviewProvider.base2.copy( + guildNavPosition = NavigatorModel.GuildNavPosition.BottomSheet + ) + ) + } + } } ) } 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 deleted file mode 100644 index b91d6a9..0000000 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/layout/ChannelBrowser.kt +++ /dev/null @@ -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, -) { - data class Category( - val name: String, - val channels: List, - ) - 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 { - 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 = models.asSequence() - - override fun getDisplayName(index: Int): String = - models[index].guildPosition.toString() -} 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 b303839..2a5531f 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 @@ -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() } 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 new file mode 100644 index 0000000..3e20a73 --- /dev/null +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/navigator/Channels.kt @@ -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)) + } +} 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 new file mode 100644 index 0000000..cc46d72 --- /dev/null +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/navigator/GuildButton.kt @@ -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), + ) + } + } +} 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 new file mode 100644 index 0000000..b7d8657 --- /dev/null +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/navigator/Navigator.kt @@ -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, + val guilds: List, +) { + data class Category( + val name: String, + val channels: List, + ) + 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) + } + } +} 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 new file mode 100644 index 0000000..5b88f4d --- /dev/null +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/navigator/Previews.kt @@ -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 { + 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 = models.asSequence() + + override fun getDisplayName(index: Int): String = + models[index].guildNavPosition.toString() +} diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/util/MorphPolygonShape.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/util/MorphPolygonShape.kt new file mode 100644 index 0000000..c3d6e92 --- /dev/null +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/util/MorphPolygonShape.kt @@ -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) + } +} diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/util/ThemedPreview.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/util/ThemedPreview.kt new file mode 100644 index 0000000..9172ff1 --- /dev/null +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/util/ThemedPreview.kt @@ -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 { + 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 { + 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++] + } + } +} +