Skip to content

Commit 24eeaf7

Browse files
committed
Refactor: Replace kotlin-inject with Metro for dependency injection
Metro is a Kotlin compiler plugin, so it generates .class files directly during IR transformation rather than producing source files like KSP. This removes the need for the KSP plugin and seems to also eliminate per iOS target wiring in the build script, this is nice. A downside however also still exists: `@Inject` on top-level functions works at compile time, but the generated types are not resolved by the IDE ("Unresolved reference" errors). Wrapper classes with `@Inject` on the constructor and a `@Composable operator fun invoke()` are used to keep IDE support intact, this is not ideal as it adds boilerplate (See: https://zacsweers.github.io/metro/latest/installation/#ide-support).
1 parent 4baccdf commit 24eeaf7

28 files changed

Lines changed: 190 additions & 282 deletions

File tree

androidApp/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ val abysnerBuildNumber: String by project.properties
1818
plugins {
1919
alias(libs.plugins.androidApplication)
2020
alias(libs.plugins.compose.compiler)
21+
alias(libs.plugins.metro)
2122
alias(libs.plugins.screenshot)
2223
alias(libs.plugins.kover)
2324
id("screenshot-reference-cleanup")
@@ -116,6 +117,7 @@ screenshotTests {
116117

117118
dependencies {
118119
implementation(project(":composeApp"))
120+
implementation(project(":data"))
119121
implementation(libs.androidx.activity.compose)
120122

121123
screenshotTestImplementation(libs.screenshot.validation.api)

androidApp/src/main/kotlin/org/neotech/app/abysner/AbysnerApplication.kt

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/*
22
* Abysner - Dive planner
3-
* Copyright (C) 2024 Neotech
3+
* Copyright (C) 2024-2026 Neotech
44
*
55
* Abysner is free software: you can redistribute it and/or modify
66
* it under the terms of the GNU Affero General Public License version 3,
@@ -13,20 +13,23 @@
1313
package org.neotech.app.abysner
1414

1515
import android.app.Application
16+
import dev.zacsweers.metro.createGraphFactory
17+
import org.neotech.app.abysner.data.PlatformFileDataSourceImpl
1618
import org.neotech.app.abysner.di.AppComponent
17-
import org.neotech.app.abysner.di.PlatformComponentImpl
18-
import org.neotech.app.abysner.di.create
1919

2020
class AbysnerApplication: Application() {
2121

2222
private lateinit var appComponent: AppComponent
2323

2424
override fun onCreate() {
2525
super.onCreate()
26-
val platformComponent = PlatformComponentImpl::class.create(this.applicationContext)
27-
appComponent = AppComponent::class.create(platformComponent)
26+
// Metro graphs cannot have constructor parameters, so platform dependencies must be
27+
// constructed manually and passed through a @DependencyGraph.Factory. This doesn't scale
28+
// well: every new platform dependency must be added to the factory, to a @Provides function
29+
// in AppComponent, and to each call site (Android, iOS, JVM). With kotlin-inject these were
30+
// @Inject-annotated and automatically discovered by the graph.
31+
appComponent = createGraphFactory<AppComponent.Factory>().create(PlatformFileDataSourceImpl(this.applicationContext))
2832
}
2933

3034
fun appComponent(): AppComponent = appComponent
3135
}
32-

composeApp/build.gradle.kts

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,8 @@
1010
* along with this program. If not, see https://www.gnu.org/licenses/.
1111
*/
1212

13-
import com.google.devtools.ksp.gradle.KspAATask
1413
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
1514
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
16-
import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
17-
import org.jetbrains.kotlin.gradle.plugin.KotlinTarget
18-
import org.neotech.gradle.capitalizeFirstCharacter
1915
import java.io.ByteArrayOutputStream
2016

2117
// DMG distribution does not support "-beta", MSI requires at least MAJOR.MINOR.BUILD
@@ -29,7 +25,7 @@ plugins {
2925
alias(libs.plugins.androidKmpLibrary)
3026
alias(libs.plugins.jetbrainsCompose)
3127
alias(libs.plugins.compose.compiler)
32-
alias(libs.plugins.ksp)
28+
alias(libs.plugins.metro)
3329
alias(libs.plugins.kover)
3430
}
3531

@@ -112,7 +108,6 @@ kotlin {
112108
commonMain.dependencies {
113109
implementation(project(":domain"))
114110
implementation(project(":data"))
115-
implementation(libs.kotlinInject.runtimeKmp)
116111
implementation(libs.navigation.compose)
117112
implementation(libs.jetbrains.lifecycle.viewmodel)
118113
implementation(libs.jetbrains.lifecycle.runtime)
@@ -160,17 +155,6 @@ compose.desktop {
160155

161156
dependencies {
162157

163-
// This is the same as repeating:
164-
// add(target, libs.kotlinInject.compilerKsp)
165-
// where `target` is "kspDesktop", "kspAndroid", "kspIosX64" "kspIosArm64" or "kspIosSimulatorArm64"
166-
val kotlinTargets: Sequence<KotlinTarget> = kotlin.targets.asSequence()
167-
kotlinTargets.filter {
168-
// Don't add KSP for common target, only final platforms
169-
it.platformType != KotlinPlatformType.common
170-
}.forEach {
171-
add("ksp${it.targetName.capitalizeFirstCharacter()}", libs.kotlinInject.compilerKsp)
172-
}
173-
174158
androidRuntimeClasspath(libs.jetbrains.compose.ui.tooling)
175159
}
176160

@@ -244,6 +228,6 @@ rootProject.file("iosApp/Configuration/Version.xcconfig").writeText(
244228
""".trimIndent() + "\n"
245229
)
246230

247-
tasks.withType(KspAATask::class.java).configureEach {
231+
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
248232
dependsOn(versionInfoProvider)
249233
}

composeApp/src/androidMain/kotlin/org/neotech/app/abysner/di/PlatformComponent.android.kt

Lines changed: 0 additions & 31 deletions
This file was deleted.
Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/*
22
* Abysner - Dive planner
3-
* Copyright (C) 2024 Neotech
3+
* Copyright (C) 2024-2026 Neotech
44
*
55
* Abysner is free software: you can redistribute it and/or modify
66
* it under the terms of the GNU Affero General Public License version 3,
@@ -12,9 +12,9 @@
1212

1313
package org.neotech.app.abysner.di
1414

15-
import me.tatarka.inject.annotations.Component
16-
import me.tatarka.inject.annotations.Provides
17-
import me.tatarka.inject.annotations.Scope
15+
import dev.zacsweers.metro.DependencyGraph
16+
import dev.zacsweers.metro.Provides
17+
import dev.zacsweers.metro.SingleIn
1818
import org.neotech.app.abysner.data.PersistenceRepositoryImpl
1919
import org.neotech.app.abysner.data.diveplanning.PlanningRepositoryImpl
2020
import org.neotech.app.abysner.data.PlatformFileDataSource
@@ -24,30 +24,28 @@ import org.neotech.app.abysner.domain.persistence.PersistenceRepository
2424
import org.neotech.app.abysner.domain.settings.SettingsRepository
2525
import org.neotech.app.abysner.presentation.MainNavController
2626

27-
@Scope
28-
annotation class AppScope
27+
abstract class AppScope
2928

30-
@AppScope
31-
@Component
32-
abstract class AppComponent(@Component val platformComponent: PlatformComponent) {
29+
@SingleIn(AppScope::class)
30+
@DependencyGraph
31+
abstract class AppComponent {
3332

3433
abstract val mainNavController: MainNavController
3534

36-
@AppScope
35+
@SingleIn(AppScope::class)
3736
@Provides
3837
fun providesPlanningRepository(planningRepository: PlanningRepositoryImpl): PlanningRepository = planningRepository
3938

40-
@AppScope
39+
@SingleIn(AppScope::class)
4140
@Provides
4241
fun providesSettingsRepository(settingsRepository: SettingsRepositoryImpl): SettingsRepository = settingsRepository
4342

44-
@AppScope
43+
@SingleIn(AppScope::class)
4544
@Provides
4645
fun providesPersistenceRepository(persistenceRepository: PersistenceRepositoryImpl): PersistenceRepository = persistenceRepository
4746

48-
}
49-
50-
abstract class PlatformComponent {
51-
52-
abstract val providesPlatformFileDataSource: PlatformFileDataSource
47+
@DependencyGraph.Factory
48+
fun interface Factory {
49+
fun create(@Provides platformFileDataSource: PlatformFileDataSource): AppComponent
50+
}
5351
}

composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/MainNavController.kt

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,28 +12,26 @@
1212

1313
package org.neotech.app.abysner.presentation
1414

15+
import androidx.compose.foundation.layout.Box
1516
import androidx.compose.runtime.Composable
1617
import androidx.compose.runtime.CompositionLocalProvider
1718
import androidx.compose.runtime.collectAsState
1819
import androidx.compose.runtime.getValue
1920
import androidx.lifecycle.viewmodel.compose.viewModel
2021
import androidx.navigation.compose.rememberNavController
21-
import me.tatarka.inject.annotations.Inject
22-
import androidx.compose.ui.tooling.preview.Preview
23-
import org.neotech.app.abysner.di.AppScope
24-
import org.neotech.app.abysner.presentation.screens.about.AboutScreen
25-
import org.neotech.app.abysner.presentation.screens.planner.PlannerScreen
22+
import dev.zacsweers.metro.Inject
23+
import org.neotech.app.abysner.presentation.component.BitmapRenderRoot
2624
import org.neotech.app.abysner.presentation.screens.DiveConfigurationScreen
2725
import org.neotech.app.abysner.presentation.screens.SettingsScreen
26+
import org.neotech.app.abysner.presentation.screens.about.AboutScreen
27+
import org.neotech.app.abysner.presentation.screens.planner.PlannerScreen
2828
import org.neotech.app.abysner.presentation.screens.terms_and_conditions.TermsAndConditionsScreen
2929
import org.neotech.app.abysner.presentation.theme.LocalThemeMode
3030
import org.neotech.app.abysner.presentation.utilities.DestinationDefinition
3131
import org.neotech.app.abysner.presentation.utilities.NavHost
3232
import org.neotech.app.abysner.presentation.utilities.fadeComposable
3333
import org.neotech.app.abysner.presentation.utilities.rootComposable
3434
import org.neotech.app.abysner.presentation.utilities.slideComposable
35-
import androidx.compose.foundation.layout.Box
36-
import org.neotech.app.abysner.presentation.component.BitmapRenderRoot
3735

3836
enum class Destinations(override val destinationName: String) : DestinationDefinition {
3937
PLANNER("planner"),
@@ -44,25 +42,41 @@ enum class Destinations(override val destinationName: String) : DestinationDefin
4442
TERMS_AND_CONDITIONS_INITIAL("terms-and-conditions-initial")
4543
}
4644

47-
typealias MainNavController = @Composable () -> Unit
45+
// Metro supports @Inject on top-level functions, but the generated types are not resolved by the
46+
// IDE, causing "Unresolved reference" errors. This wrapper class avoids those IDE errors.
47+
// See: https://zacsweers.github.io/metro/latest/installation/#ide-support
48+
@Inject
49+
class MainNavController(
50+
private val viewModelCreator: () -> MainNavControllerViewModel,
51+
private val plannerScreen: PlannerScreen,
52+
private val diveConfigurationScreen: DiveConfigurationScreen,
53+
private val settingsScreen: SettingsScreen,
54+
private val termsAndConditionsScreen: TermsAndConditionsScreen,
55+
private val aboutScreen: AboutScreen,
56+
) {
57+
@Composable
58+
operator fun invoke() {
59+
MainNavController(
60+
viewModel = viewModel { viewModelCreator() },
61+
plannerScreen = plannerScreen,
62+
diveConfigurationScreen = diveConfigurationScreen,
63+
settingsScreen = settingsScreen,
64+
termsAndConditionsScreen = termsAndConditionsScreen,
65+
aboutScreen = aboutScreen,
66+
)
67+
}
68+
}
4869

4970
@Composable
50-
@Preview
51-
@AppScope
52-
@Inject
5371
fun MainNavController(
54-
viewModelCreator: () -> MainNavControllerViewModel,
72+
viewModel: MainNavControllerViewModel,
5573
plannerScreen: PlannerScreen,
5674
diveConfigurationScreen: DiveConfigurationScreen,
5775
settingsScreen: SettingsScreen,
5876
termsAndConditionsScreen: TermsAndConditionsScreen,
59-
aboutScreen: AboutScreen
77+
aboutScreen: AboutScreen,
6078
) {
6179

62-
val viewModel = viewModel {
63-
viewModelCreator()
64-
}
65-
6680
val startDestination = when (viewModel.settings.value.termsAndConditionsAccepted) {
6781
false -> Destinations.TERMS_AND_CONDITIONS_INITIAL
6882
true -> Destinations.PLANNER

composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/MainNavControllerViewModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import kotlinx.coroutines.flow.SharingStarted
1818
import kotlinx.coroutines.flow.StateFlow
1919
import kotlinx.coroutines.flow.stateIn
2020
import kotlinx.coroutines.runBlocking
21-
import me.tatarka.inject.annotations.Inject
21+
import dev.zacsweers.metro.Inject
2222
import org.neotech.app.abysner.domain.settings.SettingsRepository
2323
import org.neotech.app.abysner.domain.settings.model.SettingsModel
2424

composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/DiveConfigurationScreen.kt

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212

1313
package org.neotech.app.abysner.presentation.screens
1414

15-
import androidx.compose.foundation.clickable
1615
import androidx.compose.foundation.layout.Box
1716
import androidx.compose.foundation.layout.Column
1817
import androidx.compose.foundation.layout.padding
@@ -31,21 +30,17 @@ import androidx.compose.material3.TopAppBar
3130
import androidx.compose.runtime.Composable
3231
import androidx.compose.runtime.collectAsState
3332
import androidx.compose.runtime.getValue
34-
import androidx.compose.runtime.mutableStateOf
35-
import androidx.compose.runtime.remember
36-
import androidx.compose.runtime.setValue
3733
import androidx.compose.ui.Modifier
3834
import androidx.compose.ui.platform.LocalInspectionMode
3935
import androidx.compose.ui.text.buildAnnotatedString
36+
import androidx.compose.ui.tooling.preview.Preview
4037
import androidx.compose.ui.unit.dp
4138
import androidx.navigation.NavHostController
4239
import androidx.navigation.compose.currentBackStackEntryAsState
4340
import androidx.navigation.compose.rememberNavController
41+
import dev.zacsweers.metro.Inject
4442
import kotlinx.collections.immutable.persistentListOf
4543
import kotlinx.collections.immutable.toImmutableList
46-
import me.tatarka.inject.annotations.Assisted
47-
import me.tatarka.inject.annotations.Inject
48-
import androidx.compose.ui.tooling.preview.Preview
4944
import org.neotech.app.abysner.domain.core.model.Configuration
5045
import org.neotech.app.abysner.domain.core.model.Salinity
5146
import org.neotech.app.abysner.domain.diveplanning.PlanningRepository
@@ -62,16 +57,28 @@ import org.neotech.app.abysner.presentation.component.textfield.SuffixVisualTran
6257
import org.neotech.app.abysner.presentation.theme.AbysnerTheme
6358
import kotlin.math.abs
6459

65-
66-
typealias DiveConfigurationScreen = @Composable (navController: NavHostController) -> Unit
67-
68-
@OptIn(ExperimentalMaterial3Api::class)
60+
// Metro supports @Inject on top-level functions, but the generated types are not resolved by the
61+
// IDE, causing "Unresolved reference" errors. This wrapper class avoids those IDE errors.
62+
// See: https://zacsweers.github.io/metro/latest/installation/#ide-support
6963
@Inject
64+
class DiveConfigurationScreen(
65+
private val planningRepository: PlanningRepository,
66+
) {
67+
@Composable
68+
operator fun invoke(navController: NavHostController) {
69+
DiveConfigurationScreen(
70+
navController = navController,
71+
planningRepository = planningRepository,
72+
)
73+
}
74+
}
75+
7076
@Composable
7177
fun DiveConfigurationScreen(
78+
navController: NavHostController,
7279
planningRepository: PlanningRepository,
73-
@Assisted navController: NavHostController = rememberNavController()
7480
) {
81+
// TODO should be adding a ViewModel to this screen
7582
val configuration by planningRepository.configuration.collectAsState()
7683
DiveConfigurationScreen(
7784
navController = navController,

0 commit comments

Comments
 (0)