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
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
ui/src/commonMain/composeResources/drawable/placeholder_1.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
ui/src/commonMain/composeResources/drawable/placeholder_10.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
ui/src/commonMain/composeResources/drawable/placeholder_11.jpg
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
ui/src/commonMain/composeResources/drawable/placeholder_2.jpg
Normal file
|
After Width: | Height: | Size: 358 KiB |
BIN
ui/src/commonMain/composeResources/drawable/placeholder_3.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
ui/src/commonMain/composeResources/drawable/placeholder_4.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
ui/src/commonMain/composeResources/drawable/placeholder_5.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
ui/src/commonMain/composeResources/drawable/placeholder_6.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
ui/src/commonMain/composeResources/drawable/placeholder_7.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
ui/src/commonMain/composeResources/drawable/placeholder_8.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
ui/src/commonMain/composeResources/drawable/placeholder_9.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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++]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||