refactor: switch from metro to koin

Honestly metro looks too overcomplicated and I still don't know how to
use it properly. Switching to koin for now as I'm more comfortable with
it.
This commit is contained in:
Cilly Leang 2026-02-01 00:50:57 +11:00
parent 53abaccd21
commit 2725342c3f
Signed by: cilly
GPG key ID: 6500251E087653C9
23 changed files with 165 additions and 199 deletions

View file

@ -6,8 +6,8 @@ plugins {
alias(libs.plugins.composeHotReload) apply false
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.koinCompiler) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.kotlinSerialization) apply false
alias(libs.plugins.metro) apply false
alias(libs.plugins.sqldelight) apply false
}

View file

@ -2,9 +2,9 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidLibrary)
alias(libs.plugins.koinCompiler)
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.metro)
alias(libs.plugins.sqldelight)
}
@ -23,6 +23,9 @@ kotlin {
implementation(libs.ktor.client.websockets)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
implementation(libs.kermit)
implementation(libs.settings)
}

View file

@ -2,12 +2,7 @@ package moe.lava.neon.core
import com.russhwolf.settings.Settings
import com.russhwolf.settings.nullableString
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
@SingleIn(AppScope::class)
@Inject
class AppSettings {
private val settings = Settings()

View file

@ -1,9 +1,6 @@
package moe.lava.neon.core.api
import co.touchlab.kermit.Logger
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.HttpSend
@ -23,8 +20,6 @@ import kotlinx.serialization.json.JsonNamingStrategy
import moe.lava.neon.core.api.captcha.CaptchaRequest
import moe.lava.neon.core.api.captcha.CaptchaResponse
@SingleIn(AppScope::class)
@Inject
class ApiClient {
private val logger = Logger.withTag("neon.core.api/client")

View file

@ -1,21 +1,19 @@
package moe.lava.neon.core.api.gateway
import co.touchlab.kermit.Logger
import dev.zacsweers.metro.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import moe.lava.neon.core.di.EventHandlerGraph
import moe.lava.neon.core.api.gateway.handlers.EventHandlers
import moe.lava.neon.core.repository.AuthRepository
import kotlin.math.pow
import kotlin.time.Duration.Companion.seconds
@Inject
class GatewayHandler(
private val auth: AuthRepository,
private val handlers: EventHandlerGraph,
private val eventHandlers: EventHandlers,
) {
private val logger = Logger.withTag("neon.core.api.gateway/handler")
private val scope = CoroutineScope(Dispatchers.IO)
@ -35,7 +33,7 @@ class GatewayHandler(
session = GatewaySession.start(
token = token,
eventHandlers = handlers,
eventHandlers = eventHandlers,
resumeProps = resumeProps,
onSuccess = {
logger.d { "Successful session start" }

View file

@ -24,7 +24,7 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import moe.lava.neon.core.api.ApiConstants
import moe.lava.neon.core.api.ApiConstants.json
import moe.lava.neon.core.di.EventHandlerGraph
import moe.lava.neon.core.api.gateway.handlers.EventHandlers
import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds
@ -33,7 +33,7 @@ private val logger = Logger.withTag("neon.core.api.gateway/session")
class GatewaySession private constructor(
private var ws: DefaultClientWebSocketSession,
private val token: String,
private val handlers: EventHandlerGraph,
private val handlers: EventHandlers,
private val scope: CoroutineScope,
private var resumeProps: ResumeProperties?,
private val onDestroy: (GatewayCloseReason, ResumeProperties?) -> Unit,
@ -46,7 +46,7 @@ class GatewaySession private constructor(
companion object {
suspend fun start(
token: String,
eventHandlers: EventHandlerGraph,
eventHandlers: EventHandlers,
client: HttpClient = HttpClient {
install(HttpCookies)
install(WebSockets)

View file

@ -3,3 +3,7 @@ package moe.lava.neon.core.api.gateway.handlers
import moe.lava.neon.core.api.gateway.Event
sealed interface Handler<T: Event.Incoming>
class EventHandlers(
val ready: ReadyHandler
)

View file

@ -1,13 +1,11 @@
package moe.lava.neon.core.api.gateway.handlers
import co.touchlab.kermit.Logger
import dev.zacsweers.metro.Inject
import moe.lava.neon.core.api.gateway.Event
import moe.lava.neon.core.api.gateway.ResumeProperties
private val logger = Logger.withTag("neon.core.api.events/ready")
@Inject
class ReadyHandler : Handler<Event.Ready> {
fun handle(event: Event.Ready, updateResumeProps: (ResumeProperties) -> Unit) {
logger.i { "Received payload $event" }

View file

@ -1,18 +0,0 @@
package moe.lava.neon.core.di
import dev.zacsweers.metro.GraphExtension
import moe.lava.neon.core.AppSettings
import moe.lava.neon.core.api.ApiClient
import moe.lava.neon.core.repository.AuthRepository
import moe.lava.neon.core.repository.UserRepository
@GraphExtension
interface AppGraph {
val api: ApiClient
val settings: AppSettings
val auth: AuthRepository
val users: UserRepository
val gatewayHandlers: EventHandlerGraph
}

View file

@ -0,0 +1,24 @@
package moe.lava.neon.core.di
import moe.lava.neon.core.AppSettings
import moe.lava.neon.core.api.ApiClient
import moe.lava.neon.core.api.gateway.GatewayHandler
import moe.lava.neon.core.api.gateway.handlers.EventHandlers
import moe.lava.neon.core.api.gateway.handlers.ReadyHandler
import moe.lava.neon.core.repository.AuthRepository
import moe.lava.neon.core.repository.UserRepository
import org.koin.dsl.module
import org.koin.plugin.module.dsl.single
val coreModule = module {
single<ApiClient>()
single<AppSettings>()
single<AuthRepository>()
single<UserRepository>()
single<GatewayHandler>()
single<ReadyHandler>()
single<EventHandlers>()
}

View file

@ -1,12 +0,0 @@
package moe.lava.neon.core.di
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.GraphExtension
import moe.lava.neon.core.api.gateway.handlers.ReadyHandler
@GraphExtension
@ContributesTo(AppScope::class)
interface EventHandlerGraph {
val ready: ReadyHandler
}

View file

@ -1,9 +1,6 @@
package moe.lava.neon.core.repository
import co.touchlab.kermit.Logger
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.header
@ -46,8 +43,6 @@ sealed class AuthResponse {
// data class MFARequested() : AuthResponse()
}
@Inject
@SingleIn(AppScope::class)
class AuthRepository(
private val settings: AppSettings,
private val api: ApiClient,

View file

@ -1,10 +1,4 @@
package moe.lava.neon.core.repository
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
@Inject
@SingleIn(AppScope::class)
class UserRepository {
}

View file

@ -19,12 +19,13 @@ desugar = "2.1.5"
hcaptcha = "4.4.0"
junit = "4.13.2"
kermit = "2.0.8"
koin-bom = "4.2.0-RC1"
koin-plugin = "0.3.0"
kotlin = "2.3.0"
kotlinx-coroutines = "1.10.2"
ktor = "3.4.0"
material3 = "1.11.0-alpha02"
material3-adaptive = "1.3.0-alpha04"
metro = "0.10.2"
settings = "1.3.0"
sqldelight = "2.2.1"
@ -54,6 +55,12 @@ desugar = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desuga
hcaptcha-compose = { module = "com.github.hCaptcha.hcaptcha-android-sdk:compose-sdk", version.ref = "hcaptcha" }
junit = { module = "junit:junit", version.ref = "junit" }
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" }
koin-compose = { module = "io.insert-koin:koin-compose" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" }
koin-compose-navigation3 = { module = "io.insert-koin:koin-compose-navigation3" }
koin-core = { module = "io.insert-koin:koin-core" }
koin-test = { module = "io.insert-koin:koin-test" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
@ -62,7 +69,6 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
metrox-viewmodel-compose = { module = "dev.zacsweers.metro:metrox-viewmodel-compose", version.ref = "metro" }
settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "settings" }
[plugins]
@ -71,7 +77,7 @@ androidLibrary = { id = "com.android.library", version.ref = "agp" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "compose-hot-reload" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
koinCompiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
metro = { id = "dev.zacsweers.metro", version.ref = "metro" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }

View file

@ -8,7 +8,7 @@ plugins {
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeHotReload)
alias(libs.plugins.metro)
alias(libs.plugins.koinCompiler)
}
kotlin {
@ -54,8 +54,10 @@ kotlin {
implementation(libs.kermit)
implementation(libs.metrox.viewmodel.compose)
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.koin.compose.navigation3)
}
commonTest.dependencies {
implementation(libs.kotlin.test)

View file

@ -4,23 +4,23 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import moe.lava.neon.ui.App
import moe.lava.neon.ui.di.initKoin
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
initKoin {
androidContext(this@MainActivity)
androidLogger()
}
setContent {
App()
}
}
}
@Preview
@Composable
fun AppAndroidPreview() {
App()
}

View file

@ -5,7 +5,6 @@ import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
@ -14,12 +13,10 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import androidx.savedstate.serialization.SavedStateConfiguration
import co.touchlab.kermit.Logger
import dev.zacsweers.metro.createGraph
import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import moe.lava.neon.ui.di.AppUiGraph
import moe.lava.neon.core.repository.AuthRepository
import moe.lava.neon.ui.screens.Login
import moe.lava.neon.ui.screens.Sample
import moe.lava.neon.ui.screens.chat.Chat
@ -29,6 +26,7 @@ import moe.lava.neon.ui.screens.navigator.NavigatorModel
import moe.lava.neon.ui.screens.navigator.NavigatorPreviewProvider
import moe.lava.neon.ui.util.ThreePaneSceneStrategy
import moe.lava.neon.ui.util.rememberThreePaneSceneStrategy
import org.koin.compose.koinInject
import kotlin.system.exitProcess
object Route {
@ -68,15 +66,13 @@ fun App() {
exitProcess(1)
}
val uiGraph = createGraph<AppUiGraph>()
val graph = uiGraph.core
CaptchaBinder(graph.api)
CompositionLocalProvider(LocalMetroViewModelFactory provides uiGraph.metroViewModelFactory) {
val auth: AuthRepository = koinInject()
CaptchaBinder(koinInject())
MaterialExpressiveTheme(
colorScheme = getColorScheme(),
motionScheme = MotionScheme.expressive(),
) {
val init = if (graph.auth.token != null) Route.Sample else Route.Login
val init = if (auth.token != null) Route.Sample else Route.Login
val backStack = rememberNavBackStack(config, Route.Sample)
val threePaneStrategy = rememberThreePaneSceneStrategy<NavKey>()
NavDisplay(
@ -145,4 +141,3 @@ fun App() {
)
}
}
}

View file

@ -1,29 +0,0 @@
package moe.lava.neon.ui.di
import androidx.lifecycle.ViewModel
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.DependencyGraph
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.Provider
import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory
import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory
import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory
import dev.zacsweers.metrox.viewmodel.ViewModelGraph
import moe.lava.neon.core.di.AppGraph
import kotlin.reflect.KClass
@DependencyGraph(AppScope::class)
interface AppUiGraph : ViewModelGraph {
val core: AppGraph
}
@Inject
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class AppViewModelFactory(
override val viewModelProviders: Map<KClass<out ViewModel>, Provider<ViewModel>>,
override val assistedFactoryProviders: Map<KClass<out ViewModel>, Provider<ViewModelAssistedFactory>>,
override val manualAssistedFactoryProviders: Map<KClass<out ManualViewModelAssistedFactory>, Provider<ManualViewModelAssistedFactory>>,
) : MetroViewModelFactory()

View file

@ -0,0 +1,13 @@
package moe.lava.neon.ui.di
import org.koin.core.KoinApplication
import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration
import org.koin.dsl.includes
fun initKoin(config: KoinAppDeclaration? = null): KoinApplication {
return startKoin {
includes(config)
modules(uiModule)
}
}

View file

@ -0,0 +1,13 @@
package moe.lava.neon.ui.di
import moe.lava.neon.core.di.coreModule
import moe.lava.neon.ui.screens.LoginViewModel
import moe.lava.neon.ui.screens.SampleViewModel
import org.koin.dsl.module
import org.koin.plugin.module.dsl.viewModel
val uiModule = module {
includes(coreModule)
viewModel<LoginViewModel>()
viewModel<SampleViewModel>()
}

View file

@ -23,11 +23,6 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import co.touchlab.kermit.Logger
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import dev.zacsweers.metrox.viewmodel.ViewModelKey
import dev.zacsweers.metrox.viewmodel.metroViewModel
import kotlinx.coroutines.launch
import moe.lava.neon.core.repository.AuthRepository
import moe.lava.neon.core.repository.AuthResponse
@ -35,12 +30,13 @@ import moe.lava.neon.resources.Res
import moe.lava.neon.resources.visibility
import moe.lava.neon.resources.visibility_off
import org.jetbrains.compose.resources.painterResource
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun Login(
onSuccess: () -> Unit,
) {
val viewModel: LoginViewModel = metroViewModel()
val viewModel: LoginViewModel = koinViewModel()
val scope = rememberCoroutineScope()
Column(
@ -113,9 +109,6 @@ fun Login(
}
}
@Inject
@ViewModelKey(LoginViewModel::class)
@ContributesIntoMap(AppScope::class)
class LoginViewModel(
private val auth: AuthRepository
) : ViewModel() {

View file

@ -20,11 +20,6 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import dev.zacsweers.metrox.viewmodel.ViewModelKey
import dev.zacsweers.metrox.viewmodel.metroViewModel
import kotlinx.coroutines.launch
import moe.lava.neon.core.api.gateway.GatewayHandler
import moe.lava.neon.core.repository.AuthRepository
@ -32,13 +27,14 @@ import moe.lava.neon.resources.Res
import moe.lava.neon.resources.compose_multiplatform
import moe.lava.neon.ui.Greeting
import org.jetbrains.compose.resources.painterResource
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun Sample(
navTest: (Boolean) -> Unit,
onRequestLogout: () -> Unit,
) {
val viewModel: SampleViewModel = metroViewModel()
val viewModel: SampleViewModel = koinViewModel()
var showContent by remember { mutableStateOf(false) }
Column(
modifier = Modifier
@ -86,9 +82,6 @@ fun Sample(
}
}
@Inject
@ViewModelKey(SampleViewModel::class)
@ContributesIntoMap(AppScope::class)
class SampleViewModel(
private val auth: AuthRepository,
private val gateway: GatewayHandler,

View file

@ -6,12 +6,16 @@ import androidx.compose.ui.unit.Density
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import moe.lava.neon.ui.App
import moe.lava.neon.ui.di.initKoin
// The UI is designed with touchscreens in mind; on desktop elements may look gigantic
// So scale them down a bit
const val scaleFactor = 0.75f
fun main() = application {
initKoin {
printLogger()
}
Window(
onCloseRequest = ::exitApplication,
title = "Neon",