guilds, lifecycle gateway disconnector, base channels
This commit is contained in:
parent
fcdd237809
commit
0781606a00
14 changed files with 292 additions and 6 deletions
|
|
@ -20,7 +20,7 @@ kotlin {
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(project(":api:shared"))
|
api(project(":api:shared"))
|
||||||
|
|
||||||
implementation(libs.kermit)
|
implementation(libs.kermit)
|
||||||
implementation(libs.ktor.client.core)
|
implementation(libs.ktor.client.core)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package moe.lava.neon.api.gateway
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import moe.lava.neon.api.ApiConstants
|
import moe.lava.neon.api.ApiConstants
|
||||||
|
import moe.lava.neon.api.gateway.content.GuildSerializer
|
||||||
|
import moe.lava.neon.api.objects.Guild
|
||||||
import moe.lava.neon.api.objects.User
|
import moe.lava.neon.api.objects.User
|
||||||
|
|
||||||
sealed interface Payload {
|
sealed interface Payload {
|
||||||
|
|
@ -58,7 +60,8 @@ sealed interface Event {
|
||||||
NO_AFFINE_USER_IDS,
|
NO_AFFINE_USER_IDS,
|
||||||
DEDUPE_USER_OBJECTS,
|
DEDUPE_USER_OBJECTS,
|
||||||
USER_SETTINGS_PROTO,
|
USER_SETTINGS_PROTO,
|
||||||
DEBOUNCE_MESSAGE_REACTIONS
|
DEBOUNCE_MESSAGE_REACTIONS,
|
||||||
|
CLIENT_STATE_V2,
|
||||||
) },
|
) },
|
||||||
// TODO: Client state v2
|
// TODO: Client state v2
|
||||||
// val clientState: ClientState,
|
// val clientState: ClientState,
|
||||||
|
|
@ -107,7 +110,7 @@ sealed interface Event {
|
||||||
data class Ready(
|
data class Ready(
|
||||||
val v: Int,
|
val v: Int,
|
||||||
val user: User,
|
val user: User,
|
||||||
// val guilds: List<UnavailableGuild>,
|
val guilds: List<@Serializable(GuildSerializer::class) Guild>,
|
||||||
val sessionId: String,
|
val sessionId: String,
|
||||||
val resumeGatewayUrl: String,
|
val resumeGatewayUrl: String,
|
||||||
// val application: Application,
|
// val application: Application,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package moe.lava.neon.api.gateway.content
|
||||||
|
|
||||||
|
import kotlinx.serialization.DeserializationStrategy
|
||||||
|
import kotlinx.serialization.json.JsonContentPolymorphicSerializer
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import moe.lava.neon.api.objects.Guild
|
||||||
|
|
||||||
|
object GuildSerializer : JsonContentPolymorphicSerializer<Guild>(Guild::class) {
|
||||||
|
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<Guild> {
|
||||||
|
return if (element.jsonObject["unavailable"]?.jsonPrimitive?.booleanOrNull == true) {
|
||||||
|
Guild.Unavailable.serializer()
|
||||||
|
} else {
|
||||||
|
Guild.Gateway.serializer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,7 @@ kotlin {
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(project(":api:shared"))
|
api(project(":api:shared"))
|
||||||
implementation(project(":common"))
|
implementation(project(":common"))
|
||||||
|
|
||||||
implementation(libs.kermit)
|
implementation(libs.kermit)
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ object ApiConstants {
|
||||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
encodeDefaults = true
|
encodeDefaults = true
|
||||||
|
// TODO: Distinguish missing vs null fields
|
||||||
|
explicitNulls = false
|
||||||
}
|
}
|
||||||
|
|
||||||
val superProps = Base64.encode(json.encodeToString(SuperProperties()).encodeToByteArray())
|
val superProps = Base64.encode(json.encodeToString(SuperProperties()).encodeToByteArray())
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package moe.lava.neon.api.objects
|
||||||
|
|
||||||
|
data class Channel(
|
||||||
|
val id: Snowflake,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
package moe.lava.neon.api.objects
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class Guild {
|
||||||
|
abstract val id: Snowflake
|
||||||
|
abstract val unavailable: Boolean?
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Unavailable(
|
||||||
|
override val id: Snowflake,
|
||||||
|
override val unavailable: Boolean?,
|
||||||
|
|
||||||
|
val geoRestricted: Boolean?,
|
||||||
|
val name: String?,
|
||||||
|
val icon: String?,
|
||||||
|
) : Guild()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Available(
|
||||||
|
override val id: Snowflake,
|
||||||
|
override val unavailable: Boolean?,
|
||||||
|
|
||||||
|
val name: String,
|
||||||
|
val icon: String?,
|
||||||
|
val banner: String?,
|
||||||
|
val homeHeader: String?,
|
||||||
|
val splash: String?,
|
||||||
|
val discoverySplash: String?,
|
||||||
|
val ownerId: Snowflake,
|
||||||
|
val applicationId: Snowflake?,
|
||||||
|
val description: String?,
|
||||||
|
val region: String?,
|
||||||
|
val afkChannelId: Snowflake?,
|
||||||
|
val afkTimeout: Int,
|
||||||
|
val widgetEnabled: Boolean?,
|
||||||
|
val widgetChannelId: Snowflake?,
|
||||||
|
val verificationLevel: Int,
|
||||||
|
val defaultMessageNotifications: Int,
|
||||||
|
val explicitContentFilter: Int,
|
||||||
|
val features: List<String>,
|
||||||
|
// val stickers: List<Sticker> = listOf(),
|
||||||
|
// val roles: List<Role> = listOf(),
|
||||||
|
// val emojis: List<Emoji> = listOf(),
|
||||||
|
|
||||||
|
val mfaLevel: Int,
|
||||||
|
val systemChannelId: Snowflake?,
|
||||||
|
val systemChannelFlags: Int,
|
||||||
|
val rulesChannelId: Snowflake?,
|
||||||
|
val publicUpdatesChannelId: Snowflake?,
|
||||||
|
val safetyAlertsChannelId: Snowflake?,
|
||||||
|
val maxPresences: Int?,
|
||||||
|
val maxMembers: Int?,
|
||||||
|
val vanityUrlCode: String?,
|
||||||
|
val premiumTier: Int,
|
||||||
|
val premiumSubscriptionCount: Int?,
|
||||||
|
val preferredLocale: String,
|
||||||
|
val maxVideoChannelUsers: Int?,
|
||||||
|
val maxStageVideoChannelUsers: Int?,
|
||||||
|
val nsfwLevel: Int,
|
||||||
|
val ownerConfiguredContentLevel: Int?,
|
||||||
|
val hubType: Int?,
|
||||||
|
val premiumProgressBarEnabled: Boolean,
|
||||||
|
val latestOnboardingQuestionId: Snowflake?,
|
||||||
|
// val incidents_data: AutomodIncidentsData?,
|
||||||
|
// val premium_features: GuildPremiumFeatures?,
|
||||||
|
// val profile: GuildIdentity?,
|
||||||
|
val approximateMemberCount: Int?,
|
||||||
|
val approximatePresenceCount: Int?,
|
||||||
|
): Guild()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Gateway(
|
||||||
|
override val id: Snowflake,
|
||||||
|
override val unavailable: Boolean?,
|
||||||
|
|
||||||
|
val joinedAt: Instant,
|
||||||
|
val large: Boolean,
|
||||||
|
val geoRestricted: Boolean?,
|
||||||
|
val memberCount: Int,
|
||||||
|
// val members: List<GuildMember>,
|
||||||
|
// val channels: List<Channel>,
|
||||||
|
// val threads: List<Channel>,
|
||||||
|
// val presences: List<Presence>,
|
||||||
|
// val voiceStates: List<VoiceState>,
|
||||||
|
// val activityInstances: List<EmbeddedActivityInstance>,
|
||||||
|
// val stageInstances: List<StageInstance>,
|
||||||
|
// val guildScheduledEvents: List<GuildScheduledEvent>,
|
||||||
|
val dataMode: String,
|
||||||
|
val properties: Available, // Client state v2, this is the below fields
|
||||||
|
// val stickers: List<Sticker>,
|
||||||
|
// val roles: List<Role>,
|
||||||
|
// val emojis: List<Emoji>,
|
||||||
|
// val soundboardSounds: List<SoundboardSound>,
|
||||||
|
// override val premiumSubscriptionCount: Int,
|
||||||
|
//
|
||||||
|
// override val id: Snowflake,
|
||||||
|
// override val unavailable: Boolean?,
|
||||||
|
//
|
||||||
|
// override val name: String,
|
||||||
|
// override val icon: String?,
|
||||||
|
// override val banner: String?,
|
||||||
|
// override val homeHeader: String?,
|
||||||
|
// override val splash: String?,
|
||||||
|
// override val discoverySplash: String?,
|
||||||
|
// override val ownerId: Snowflake,
|
||||||
|
// override val applicationId: Snowflake?,
|
||||||
|
// override val description: String?,
|
||||||
|
// override val region: String?,
|
||||||
|
// override val afkChannelId: Snowflake?,
|
||||||
|
// override val afkTimeout: Int,
|
||||||
|
// override val widgetEnabled: Boolean?,
|
||||||
|
// override val widgetChannelId: Snowflake?,
|
||||||
|
// override val verificationLevel: Int,
|
||||||
|
// override val defaultMessageNotifications: Int,
|
||||||
|
// override val explicitContentFilter: Int,
|
||||||
|
// override val features: List<String>,
|
||||||
|
//
|
||||||
|
// override val mfaLevel: Int,
|
||||||
|
// override val systemChannelId: Snowflake?,
|
||||||
|
// override val systemChannelFlags: Int,
|
||||||
|
// override val rulesChannelId: Snowflake?,
|
||||||
|
// override val publicUpdatesChannelId: Snowflake?,
|
||||||
|
// override val safetyAlertsChannelId: Snowflake?,
|
||||||
|
// override val maxPresences: Int?,
|
||||||
|
// override val maxMembers: Int?,
|
||||||
|
// override val vanityUrlCode: String?,
|
||||||
|
// override val premiumTier: Int,
|
||||||
|
// override val preferredLocale: String,
|
||||||
|
// override val maxVideoChannelUsers: Int?,
|
||||||
|
// override val maxStageVideoChannelUsers: Int?,
|
||||||
|
// override val nsfwLevel: Int,
|
||||||
|
// override val ownerConfiguredContentLevel: Int?,
|
||||||
|
// override val hubType: Int?,
|
||||||
|
// override val premiumProgressBarEnabled: Boolean,
|
||||||
|
// override val latestOnboardingQuestionId: Snowflake?,
|
||||||
|
// // override val incidents_data: AutomodIncidentsData?
|
||||||
|
// // override val premium_features: GuildPremiumFeatures?
|
||||||
|
// // override val profile: GuildIdentity?
|
||||||
|
// override val approximateMemberCount: Int?,
|
||||||
|
// override val approximatePresenceCount: Int?,
|
||||||
|
) : Guild()
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,9 @@ kotlin {
|
||||||
implementation(project(":api:rest"))
|
implementation(project(":api:rest"))
|
||||||
implementation(project(":common"))
|
implementation(project(":common"))
|
||||||
|
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.serialization.core)
|
||||||
|
|
||||||
implementation(project.dependencies.platform(libs.koin.bom))
|
implementation(project.dependencies.platform(libs.koin.bom))
|
||||||
implementation(libs.koin.core)
|
implementation(libs.koin.core)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package moe.lava.neon.core.data.guild
|
||||||
|
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.asFlow
|
||||||
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
|
import kotlinx.coroutines.flow.flatMapConcat
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import moe.lava.neon.api.gateway.Event
|
||||||
|
import moe.lava.neon.api.gateway.GatewayHandler
|
||||||
|
import moe.lava.neon.core.model.Guild
|
||||||
|
import moe.lava.neon.api.objects.Guild as ApiGuild
|
||||||
|
|
||||||
|
class GuildGatewayDataSource(
|
||||||
|
gateway: GatewayHandler,
|
||||||
|
) {
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
val flow = gateway.events
|
||||||
|
.filterIsInstance<Event.Ready>()
|
||||||
|
.flatMapConcat { it.guilds.asFlow() }
|
||||||
|
.filterIsInstance<ApiGuild.Gateway>()
|
||||||
|
.map { apiGuild ->
|
||||||
|
Guild(
|
||||||
|
id = apiGuild.id,
|
||||||
|
name = apiGuild.properties.name,
|
||||||
|
iconHash = apiGuild.properties.icon,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,21 +3,27 @@ package moe.lava.neon.core.di
|
||||||
import moe.lava.neon.api.ApiClient
|
import moe.lava.neon.api.ApiClient
|
||||||
import moe.lava.neon.api.gateway.GatewayHandler
|
import moe.lava.neon.api.gateway.GatewayHandler
|
||||||
import moe.lava.neon.core.AppSettings
|
import moe.lava.neon.core.AppSettings
|
||||||
|
import moe.lava.neon.core.data.guild.GuildGatewayDataSource
|
||||||
import moe.lava.neon.core.repository.AuthRepository
|
import moe.lava.neon.core.repository.AuthRepository
|
||||||
import moe.lava.neon.core.repository.CaptchaRepository
|
import moe.lava.neon.core.repository.CaptchaRepository
|
||||||
import moe.lava.neon.core.repository.GatewayRepository
|
import moe.lava.neon.core.repository.GatewayRepository
|
||||||
|
import moe.lava.neon.core.repository.GuildRepository
|
||||||
import moe.lava.neon.core.repository.UserRepository
|
import moe.lava.neon.core.repository.UserRepository
|
||||||
|
import org.koin.core.module.dsl.createdAtStart
|
||||||
|
import org.koin.core.module.dsl.withOptions
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.koin.plugin.module.dsl.single
|
import org.koin.plugin.module.dsl.single
|
||||||
|
|
||||||
val coreModule = module {
|
val coreModule = module {
|
||||||
factory { ApiClient() }
|
factory { ApiClient() }
|
||||||
single<AppSettings>()
|
single<AppSettings>()
|
||||||
|
single<GatewayHandler>()
|
||||||
|
|
||||||
|
single<GuildGatewayDataSource>()
|
||||||
|
|
||||||
single<AuthRepository>()
|
single<AuthRepository>()
|
||||||
single<CaptchaRepository>()
|
single<CaptchaRepository>()
|
||||||
single<GatewayRepository>()
|
single<GatewayRepository>()
|
||||||
|
single<GuildRepository>() withOptions { createdAtStart() }
|
||||||
single<UserRepository>()
|
single<UserRepository>()
|
||||||
|
|
||||||
single<GatewayHandler>()
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
core/src/commonMain/kotlin/moe/lava/neon/core/model/Guild.kt
Normal file
16
core/src/commonMain/kotlin/moe/lava/neon/core/model/Guild.kt
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package moe.lava.neon.core.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import moe.lava.neon.api.objects.Snowflake
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Guild(
|
||||||
|
val id: Snowflake,
|
||||||
|
val name: String,
|
||||||
|
val iconHash: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UnavailableGuild(val e: String)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package moe.lava.neon.core.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import moe.lava.neon.api.objects.Snowflake
|
||||||
|
import moe.lava.neon.core.data.guild.GuildGatewayDataSource
|
||||||
|
import moe.lava.neon.core.model.Guild
|
||||||
|
|
||||||
|
class GuildRepository(
|
||||||
|
gatewaySource: GuildGatewayDataSource,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
gatewaySource.flow
|
||||||
|
.onEach { guild ->
|
||||||
|
cache[guild.id] = guild
|
||||||
|
joined[guild.id] = guild
|
||||||
|
}
|
||||||
|
.launchIn(CoroutineScope(Dispatchers.IO))
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cache = mutableMapOf<Snowflake, Guild>()
|
||||||
|
private val joined = mutableMapOf<Snowflake, Guild>()
|
||||||
|
|
||||||
|
fun get(id: Snowflake) = cache[id]
|
||||||
|
fun getJoined() = joined.toMap()
|
||||||
|
}
|
||||||
|
|
@ -70,6 +70,7 @@ kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest"
|
||||||
kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" }
|
kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" }
|
||||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||||
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
|
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
|
||||||
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
||||||
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||||
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,17 @@ import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -34,6 +38,32 @@ fun Sample(
|
||||||
onRequestLogout: () -> Unit,
|
onRequestLogout: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val viewModel: SampleViewModel = koinViewModel()
|
val viewModel: SampleViewModel = koinViewModel()
|
||||||
|
|
||||||
|
val li = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(li) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
when (event) {
|
||||||
|
Lifecycle.Event.ON_PAUSE -> {
|
||||||
|
viewModel.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
Lifecycle.Event.ON_RESUME -> {
|
||||||
|
viewModel.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
Lifecycle.Event.ON_DESTROY -> {
|
||||||
|
viewModel.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
li.lifecycle.addObserver(observer)
|
||||||
|
onDispose {
|
||||||
|
li.lifecycle.removeObserver(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var showContent by remember { mutableStateOf(false) }
|
var showContent by remember { mutableStateOf(false) }
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue