diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index b1da0c4..e63ebbe 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -18,6 +18,7 @@ val abysnerBuildNumber: String by project.properties plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.compose.compiler) + alias(libs.plugins.metro) alias(libs.plugins.screenshot) alias(libs.plugins.kover) id("screenshot-reference-cleanup") @@ -116,6 +117,7 @@ screenshotTests { dependencies { implementation(project(":composeApp")) + implementation(project(":data")) implementation(libs.androidx.activity.compose) screenshotTestImplementation(libs.screenshot.validation.api) diff --git a/androidApp/src/main/kotlin/org/neotech/app/abysner/AbysnerApplication.kt b/androidApp/src/main/kotlin/org/neotech/app/abysner/AbysnerApplication.kt index f1afe03..d7f5e04 100644 --- a/androidApp/src/main/kotlin/org/neotech/app/abysner/AbysnerApplication.kt +++ b/androidApp/src/main/kotlin/org/neotech/app/abysner/AbysnerApplication.kt @@ -1,6 +1,6 @@ /* * Abysner - Dive planner - * Copyright (C) 2024 Neotech + * Copyright (C) 2024-2026 Neotech * * Abysner is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License version 3, @@ -13,9 +13,9 @@ package org.neotech.app.abysner import android.app.Application +import dev.zacsweers.metro.createGraphFactory +import org.neotech.app.abysner.data.PlatformFileDataSourceImpl import org.neotech.app.abysner.di.AppComponent -import org.neotech.app.abysner.di.PlatformComponentImpl -import org.neotech.app.abysner.di.create class AbysnerApplication: Application() { @@ -23,10 +23,16 @@ class AbysnerApplication: Application() { override fun onCreate() { super.onCreate() - val platformComponent = PlatformComponentImpl::class.create(this.applicationContext) - appComponent = AppComponent::class.create(platformComponent) + // Metro graphs cannot have constructor parameters, and platform source sets cannot extend + // the shared graph with additional bindings (only the reverse direction is supported via + // @GraphExtension it seems). So platform dependencies must be constructed manually and + // passed through a @DependencyGraph.Factory. Currently, there is only one + // (PlatformFileDataSource), but if more platform-specific bindings are added this might get + // messy? Each one needs a factory parameter, a @Provides function in AppComponent, and + // manual construction at every call site (Android, iOS, JVM). + // kotlin-inject avoided this through component inheritance. + appComponent = createGraphFactory().create(PlatformFileDataSourceImpl(this.applicationContext)) } fun appComponent(): AppComponent = appComponent } - diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 24dffeb..d323880 100755 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -10,12 +10,8 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import com.google.devtools.ksp.gradle.KspAATask import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType -import org.jetbrains.kotlin.gradle.plugin.KotlinTarget -import org.neotech.gradle.capitalizeFirstCharacter import java.io.ByteArrayOutputStream // DMG distribution does not support "-beta", MSI requires at least MAJOR.MINOR.BUILD @@ -29,7 +25,7 @@ plugins { alias(libs.plugins.androidKmpLibrary) alias(libs.plugins.jetbrainsCompose) alias(libs.plugins.compose.compiler) - alias(libs.plugins.ksp) + alias(libs.plugins.metro) alias(libs.plugins.kover) } @@ -114,7 +110,6 @@ kotlin { commonMain.dependencies { implementation(project(":domain")) implementation(project(":data")) - implementation(libs.kotlinInject.runtimeKmp) implementation(libs.navigation.compose) implementation(libs.jetbrains.lifecycle.viewmodel) implementation(libs.jetbrains.lifecycle.runtime) @@ -162,17 +157,6 @@ compose.desktop { dependencies { - // This is the same as repeating: - // add(target, libs.kotlinInject.compilerKsp) - // where `target` is "kspDesktop", "kspAndroid", "kspIosX64" "kspIosArm64" or "kspIosSimulatorArm64" - val kotlinTargets: Sequence = kotlin.targets.asSequence() - kotlinTargets.filter { - // Don't add KSP for common target, only final platforms - it.platformType != KotlinPlatformType.common - }.forEach { - add("ksp${it.targetName.capitalizeFirstCharacter()}", libs.kotlinInject.compilerKsp) - } - androidRuntimeClasspath(libs.jetbrains.compose.ui.tooling) } @@ -246,6 +230,6 @@ rootProject.file("iosApp/Configuration/Version.xcconfig").writeText( """.trimIndent() + "\n" ) -tasks.withType(KspAATask::class.java).configureEach { +tasks.withType>().configureEach { dependsOn(versionInfoProvider) } diff --git a/composeApp/src/androidMain/kotlin/org/neotech/app/abysner/di/PlatformComponent.android.kt b/composeApp/src/androidMain/kotlin/org/neotech/app/abysner/di/PlatformComponent.android.kt deleted file mode 100644 index c1691f7..0000000 --- a/composeApp/src/androidMain/kotlin/org/neotech/app/abysner/di/PlatformComponent.android.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Abysner - Dive planner - * Copyright (C) 2024 Neotech - * - * Abysner is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License version 3, - * as published by the Free Software Foundation. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see https://www.gnu.org/licenses/. - */ - -package org.neotech.app.abysner.di - -import android.content.Context -import me.tatarka.inject.annotations.Component -import me.tatarka.inject.annotations.Provides -import org.neotech.app.abysner.data.PlatformFileDataSource -import org.neotech.app.abysner.data.PlatformFileDataSourceImpl - -@AppScope -@Component -abstract class PlatformComponentImpl(private val applicationContext: Context): PlatformComponent() { - - val providesApplicationContext: Context - @AppScope @Provides get() = applicationContext - - @AppScope - @Provides - fun providesPlatformFileDataSource(applicationContext: Context): PlatformFileDataSource = PlatformFileDataSourceImpl(applicationContext) -} diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/di/AppComponent.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/di/AppComponent.kt index f7ffc6d..9c18da7 100644 --- a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/di/AppComponent.kt +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/di/AppComponent.kt @@ -1,6 +1,6 @@ /* * Abysner - Dive planner - * Copyright (C) 2024 Neotech + * Copyright (C) 2024-2026 Neotech * * Abysner is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License version 3, @@ -12,9 +12,9 @@ package org.neotech.app.abysner.di -import me.tatarka.inject.annotations.Component -import me.tatarka.inject.annotations.Provides -import me.tatarka.inject.annotations.Scope +import dev.zacsweers.metro.DependencyGraph +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn import org.neotech.app.abysner.data.PersistenceRepositoryImpl import org.neotech.app.abysner.data.diveplanning.PlanningRepositoryImpl import org.neotech.app.abysner.data.PlatformFileDataSource @@ -24,30 +24,28 @@ import org.neotech.app.abysner.domain.persistence.PersistenceRepository import org.neotech.app.abysner.domain.settings.SettingsRepository import org.neotech.app.abysner.presentation.MainNavController -@Scope -annotation class AppScope +abstract class AppScope -@AppScope -@Component -abstract class AppComponent(@Component val platformComponent: PlatformComponent) { +@SingleIn(AppScope::class) +@DependencyGraph +abstract class AppComponent { abstract val mainNavController: MainNavController - @AppScope + @SingleIn(AppScope::class) @Provides fun providesPlanningRepository(planningRepository: PlanningRepositoryImpl): PlanningRepository = planningRepository - @AppScope + @SingleIn(AppScope::class) @Provides fun providesSettingsRepository(settingsRepository: SettingsRepositoryImpl): SettingsRepository = settingsRepository - @AppScope + @SingleIn(AppScope::class) @Provides fun providesPersistenceRepository(persistenceRepository: PersistenceRepositoryImpl): PersistenceRepository = persistenceRepository -} - -abstract class PlatformComponent { - - abstract val providesPlatformFileDataSource: PlatformFileDataSource + @DependencyGraph.Factory + fun interface Factory { + fun create(@Provides platformFileDataSource: PlatformFileDataSource): AppComponent + } } diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/MainNavController.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/MainNavController.kt index a3d77b9..b0f92c1 100644 --- a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/MainNavController.kt +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/MainNavController.kt @@ -12,19 +12,19 @@ package org.neotech.app.abysner.presentation +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.rememberNavController -import me.tatarka.inject.annotations.Inject -import androidx.compose.ui.tooling.preview.Preview -import org.neotech.app.abysner.di.AppScope -import org.neotech.app.abysner.presentation.screens.about.AboutScreen -import org.neotech.app.abysner.presentation.screens.planner.PlannerScreen +import dev.zacsweers.metro.Inject +import org.neotech.app.abysner.presentation.component.BitmapRenderRoot import org.neotech.app.abysner.presentation.screens.DiveConfigurationScreen import org.neotech.app.abysner.presentation.screens.SettingsScreen +import org.neotech.app.abysner.presentation.screens.about.AboutScreen +import org.neotech.app.abysner.presentation.screens.planner.PlannerScreen import org.neotech.app.abysner.presentation.screens.terms_and_conditions.TermsAndConditionsScreen import org.neotech.app.abysner.presentation.theme.LocalThemeMode import org.neotech.app.abysner.presentation.utilities.DestinationDefinition @@ -32,8 +32,6 @@ import org.neotech.app.abysner.presentation.utilities.NavHost import org.neotech.app.abysner.presentation.utilities.fadeComposable import org.neotech.app.abysner.presentation.utilities.rootComposable import org.neotech.app.abysner.presentation.utilities.slideComposable -import androidx.compose.foundation.layout.Box -import org.neotech.app.abysner.presentation.component.BitmapRenderRoot enum class Destinations(override val destinationName: String) : DestinationDefinition { PLANNER("planner"), @@ -44,25 +42,41 @@ enum class Destinations(override val destinationName: String) : DestinationDefin TERMS_AND_CONDITIONS_INITIAL("terms-and-conditions-initial") } -typealias MainNavController = @Composable () -> Unit +// Metro supports @Inject on top-level functions, but the generated types are not resolved by the +// IDE, causing "Unresolved reference" errors. This wrapper class avoids those IDE errors. +// See: https://zacsweers.github.io/metro/latest/installation/#ide-support +@Inject +class MainNavController( + private val viewModelCreator: () -> MainNavControllerViewModel, + private val plannerScreen: PlannerScreen, + private val diveConfigurationScreen: DiveConfigurationScreen, + private val settingsScreen: SettingsScreen, + private val termsAndConditionsScreen: TermsAndConditionsScreen, + private val aboutScreen: AboutScreen, +) { + @Composable + operator fun invoke() { + MainNavController( + viewModel = viewModel { viewModelCreator() }, + plannerScreen = plannerScreen, + diveConfigurationScreen = diveConfigurationScreen, + settingsScreen = settingsScreen, + termsAndConditionsScreen = termsAndConditionsScreen, + aboutScreen = aboutScreen, + ) + } +} @Composable -@Preview -@AppScope -@Inject fun MainNavController( - viewModelCreator: () -> MainNavControllerViewModel, + viewModel: MainNavControllerViewModel, plannerScreen: PlannerScreen, diveConfigurationScreen: DiveConfigurationScreen, settingsScreen: SettingsScreen, termsAndConditionsScreen: TermsAndConditionsScreen, - aboutScreen: AboutScreen + aboutScreen: AboutScreen, ) { - val viewModel = viewModel { - viewModelCreator() - } - val startDestination = when (viewModel.settings.value.termsAndConditionsAccepted) { false -> Destinations.TERMS_AND_CONDITIONS_INITIAL true -> Destinations.PLANNER diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/MainNavControllerViewModel.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/MainNavControllerViewModel.kt index 0f55504..227ed68 100644 --- a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/MainNavControllerViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/MainNavControllerViewModel.kt @@ -18,7 +18,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.runBlocking -import me.tatarka.inject.annotations.Inject +import dev.zacsweers.metro.Inject import org.neotech.app.abysner.domain.settings.SettingsRepository import org.neotech.app.abysner.domain.settings.model.SettingsModel diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/DiveConfigurationScreen.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/DiveConfigurationScreen.kt index 737bfcf..f0dd112 100644 --- a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/DiveConfigurationScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/DiveConfigurationScreen.kt @@ -33,15 +33,14 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import dev.zacsweers.metro.Inject import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import me.tatarka.inject.annotations.Assisted -import me.tatarka.inject.annotations.Inject -import androidx.compose.ui.tooling.preview.Preview import org.neotech.app.abysner.domain.core.model.Configuration import org.neotech.app.abysner.domain.core.model.Salinity import org.neotech.app.abysner.domain.core.model.UnitSystem @@ -67,16 +66,32 @@ import org.neotech.app.abysner.presentation.utilities.volumePerMinuteUnitLabel import kotlin.math.abs import kotlin.math.roundToInt -typealias DiveConfigurationScreen = @Composable (navController: NavHostController) -> Unit -@OptIn(ExperimentalMaterial3Api::class) +// Metro supports @Inject on top-level functions, but the generated types are not resolved by the +// IDE, causing "Unresolved reference" errors. This wrapper class avoids those IDE errors. +// See: https://zacsweers.github.io/metro/latest/installation/#ide-support @Inject +class DiveConfigurationScreen( + private val planningRepository: PlanningRepository, + private val settingsRepository: SettingsRepository, +) { + @Composable + operator fun invoke(navController: NavHostController) { + DiveConfigurationScreen( + navController = navController, + planningRepository = planningRepository, + settingsRepository = settingsRepository, + ) + } +} + @Composable fun DiveConfigurationScreen( + navController: NavHostController, planningRepository: PlanningRepository, settingsRepository: SettingsRepository, - @Assisted navController: NavHostController = rememberNavController() ) { + // TODO should be adding a ViewModel to this screen val configuration by planningRepository.configuration.collectAsState() val settings by settingsRepository.settings.collectAsState() DiveConfigurationScreen( diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/SettingsScreen.kt index 4a14613..5e96f44 100644 --- a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/SettingsScreen.kt @@ -38,9 +38,8 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import kotlinx.collections.immutable.toImmutableList -import me.tatarka.inject.annotations.Assisted -import me.tatarka.inject.annotations.Inject import org.neotech.app.abysner.domain.core.model.UnitSystem +import dev.zacsweers.metro.Inject import org.neotech.app.abysner.domain.settings.SettingsRepository import org.neotech.app.abysner.domain.settings.model.SettingsModel import org.neotech.app.abysner.domain.settings.model.ThemeMode @@ -49,15 +48,28 @@ import org.neotech.app.abysner.presentation.component.preferences.SingleChoicePr import org.neotech.app.abysner.presentation.component.preferences.SwitchPreference import org.neotech.app.abysner.presentation.theme.AbysnerTheme -typealias SettingsScreen = @Composable (navController: NavHostController) -> Unit - -@OptIn(ExperimentalMaterial3Api::class) +// Metro supports @Inject on top-level functions, but the generated types are not resolved by the +// IDE, causing "Unresolved reference" errors. This wrapper class avoids those IDE errors. +// See: https://zacsweers.github.io/metro/latest/installation/#ide-support @Inject +class SettingsScreen( + private val settingsRepository: SettingsRepository, +) { + @Composable + operator fun invoke(navController: NavHostController) { + SettingsScreen( + navController = navController, + settingsRepository = settingsRepository + ) + } +} + @Composable -fun SettingsScreen( +private fun SettingsScreen( + navController: NavHostController, settingsRepository: SettingsRepository, - @Assisted navController: NavHostController = rememberNavController() ) { + // TODO should be adding a ViewModel to this screen val settings by settingsRepository.settings.collectAsState() SettingsScreen( navController = navController, diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/about/AboutScreen.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/about/AboutScreen.kt index 6d22cff..6024aeb 100644 --- a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/about/AboutScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/about/AboutScreen.kt @@ -60,22 +60,27 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import me.tatarka.inject.annotations.Assisted -import me.tatarka.inject.annotations.Inject +import dev.zacsweers.metro.Inject import org.jetbrains.compose.resources.painterResource import org.neotech.app.abysner.presentation.Destinations import org.neotech.app.abysner.presentation.theme.AbysnerTheme import org.neotech.app.abysner.version.VersionInfo +// Metro supports @Inject on top-level functions, but the generated types are not resolved by the +// IDE, causing "Unresolved reference" errors. This wrapper class avoids those IDE errors. +// See: https://zacsweers.github.io/metro/latest/installation/#ide-support +@Inject +class AboutScreen { -typealias AboutScreen = @Composable (navController: NavHostController) -> Unit + @Composable + operator fun invoke(navController: NavHostController) { + AboutScreen(navController = navController) + } +} @OptIn(ExperimentalMaterial3Api::class) -@Inject @Composable -fun AboutScreen( - @Assisted navController: NavHostController = rememberNavController() -) { +fun AboutScreen(navController: NavHostController = rememberNavController()) { val uriHandler = LocalUriHandler.current AbysnerTheme { @@ -297,5 +302,5 @@ private fun VersionText() { @Preview @Composable fun AboutScreenPreview() { - AboutScreen() + AboutScreen(navController = rememberNavController()) } diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/PlanScreen.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/PlanScreen.kt index fae6030..e2e6c13 100644 --- a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/PlanScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/PlanScreen.kt @@ -51,8 +51,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import kotlinx.collections.immutable.toImmutableList -import me.tatarka.inject.annotations.Assisted -import me.tatarka.inject.annotations.Inject +import dev.zacsweers.metro.Inject import org.neotech.app.abysner.domain.core.model.Cylinder import org.neotech.app.abysner.domain.core.model.DiveMode import org.neotech.app.abysner.domain.core.model.UnitSystem @@ -77,15 +76,27 @@ import org.neotech.app.abysner.presentation.preview.DEVICE_PHONE_MAX_HEIGHT import org.neotech.app.abysner.presentation.preview.PreviewData import kotlin.time.Duration -typealias PlannerScreen = @Composable (navController: NavHostController) -> Unit - +// Metro supports @Inject on top-level functions, but the generated types are not resolved by the +// IDE, causing "Unresolved reference" errors. This wrapper class avoids those IDE errors. +// See: https://zacsweers.github.io/metro/latest/installation/#ide-support @Inject +class PlannerScreen( + private val viewModelCreator: () -> PlanScreenViewModel, +) { + @Composable + operator fun invoke(navController: NavHostController) { + PlannerScreen( + viewModel = viewModel { viewModelCreator() }, + navController = navController, + ) + } +} + @Composable -fun PlannerScreen( - viewModelCreator: () -> PlanScreenViewModel, - @Assisted navController: NavHostController = rememberNavController() +private fun PlannerScreen( + viewModel: PlanScreenViewModel, + navController: NavHostController ) { - val viewModel = viewModel { viewModelCreator() } val uiState: PlanScreenViewModel.UiState by viewModel.uiState.collectAsState() PlannerScreen( @@ -98,7 +109,13 @@ fun PlannerScreen( onAddSegment = { viewModel.addSegment(it) }, onUpdateSegment = { index, segment -> viewModel.updateSegment(index, segment) }, onRemoveSegment = { viewModel.removeSegment(it) }, - onContingencyInputChanged = { deeper, longer, bailout -> viewModel.setContingency(deeper, longer, bailout) }, + onContingencyInputChanged = { deeper, longer, bailout -> + viewModel.setContingency( + deeper, + longer, + bailout + ) + }, onSelectDive = { viewModel.selectDive(it) }, onAddDive = { viewModel.addDive(it) }, onRemoveDive = { viewModel.removeDive(it) }, diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/PlanScreenViewModel.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/PlanScreenViewModel.kt index 32a1a5d..5e3d74c 100644 --- a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/PlanScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/PlanScreenViewModel.kt @@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import me.tatarka.inject.annotations.Inject +import dev.zacsweers.metro.Inject import org.neotech.app.abysner.domain.core.model.Configuration import org.neotech.app.abysner.domain.core.model.Cylinder import org.neotech.app.abysner.domain.core.model.DiveMode diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/terms_and_conditions/TermsAndConditionsScreen.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/terms_and_conditions/TermsAndConditionsScreen.kt index 1cfa317..48fb6f5 100644 --- a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/terms_and_conditions/TermsAndConditionsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/terms_and_conditions/TermsAndConditionsScreen.kt @@ -36,11 +36,11 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController @@ -49,11 +49,9 @@ import com.mikepenz.markdown.compose.Markdown import com.mikepenz.markdown.m3.markdownColor import com.mikepenz.markdown.m3.markdownTypography import com.mikepenz.markdown.model.markdownPadding +import dev.zacsweers.metro.Inject import kotlinx.coroutines.runBlocking -import me.tatarka.inject.annotations.Assisted -import me.tatarka.inject.annotations.Inject import org.jetbrains.compose.resources.ExperimentalResourceApi -import androidx.compose.ui.tooling.preview.Preview import org.neotech.app.abysner.presentation.Destinations import org.neotech.app.abysner.presentation.component.core.ifTrue import org.neotech.app.abysner.presentation.component.core.onlyBottom @@ -61,21 +59,32 @@ import org.neotech.app.abysner.presentation.component.core.onlyHorizontal import org.neotech.app.abysner.presentation.component.core.onlyTop import org.neotech.app.abysner.presentation.screens.terms_and_conditions.TermsAndConditionsViewModel.UiState import org.neotech.app.abysner.presentation.theme.AbysnerTheme -import org.neotech.app.abysner.presentation.utilities.EventEffect import org.neotech.app.abysner.presentation.utilities.ConfigurePreviewContext +import org.neotech.app.abysner.presentation.utilities.EventEffect import org.neotech.app.abysner.presentation.utilities.closeApp import org.neotech.app.abysner.presentation.utilities.consumed -typealias TermsAndConditionsScreen = @Composable (navController: NavHostController) -> Unit - -@OptIn(ExperimentalMaterial3Api::class) +// Metro supports @Inject on top-level functions, but the generated types are not resolved by the +// IDE, causing "Unresolved reference" errors. This wrapper class avoids those IDE errors. +// See: https://zacsweers.github.io/metro/latest/installation/#ide-support @Inject +class TermsAndConditionsScreen( + private val viewModelCreator: () -> TermsAndConditionsViewModel, +) { + @Composable + operator fun invoke(navController: NavHostController = rememberNavController()) { + TermsAndConditionsScreen( + navController = navController, + viewModel = viewModel { viewModelCreator() } + ) + } +} + @Composable -fun TermsAndConditionsScreen( - @Assisted navController: NavHostController = rememberNavController(), - viewModelCreator: () -> TermsAndConditionsViewModel, +private fun TermsAndConditionsScreen( + navController: NavHostController, + viewModel: TermsAndConditionsViewModel ) { - val viewModel = viewModel { viewModelCreator() } val uiState: UiState = viewModel.uiState.collectAsState().value TermsAndConditionsScreen( navController = navController, @@ -86,7 +95,6 @@ fun TermsAndConditionsScreen( } @OptIn(ExperimentalMaterial3Api::class) -@Inject @Composable fun TermsAndConditionsScreen( navController: NavHostController = rememberNavController(), diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/terms_and_conditions/TermsAndConditionsViewModel.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/terms_and_conditions/TermsAndConditionsViewModel.kt index 8cf23ef..8abc1a9 100644 --- a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/terms_and_conditions/TermsAndConditionsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/terms_and_conditions/TermsAndConditionsViewModel.kt @@ -18,7 +18,7 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import me.tatarka.inject.annotations.Inject +import dev.zacsweers.metro.Inject import org.jetbrains.compose.resources.ExperimentalResourceApi import org.neotech.app.abysner.domain.settings.SettingsRepository import org.neotech.app.abysner.presentation.utilities.StateEvent diff --git a/composeApp/src/iosArm64Main/kotlin/MainViewController.iosArm64.kt b/composeApp/src/iosArm64Main/kotlin/MainViewController.iosArm64.kt deleted file mode 100644 index 2364a78..0000000 --- a/composeApp/src/iosArm64Main/kotlin/MainViewController.iosArm64.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Abysner - Dive planner - * Copyright (C) 2024 Neotech - * - * Abysner is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License version 3, - * as published by the Free Software Foundation. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see https://www.gnu.org/licenses/. - */ - -import org.neotech.app.abysner.di.AppComponent -import org.neotech.app.abysner.di.PlatformComponentImpl -import org.neotech.app.abysner.di.create - -actual fun createAppComponent(): AppComponent = AppComponent::class.create(PlatformComponentImpl::class.create()) diff --git a/composeApp/src/iosMain/kotlin/MainViewController.kt b/composeApp/src/iosMain/kotlin/MainViewController.kt index 9db0e80..b1907a5 100755 --- a/composeApp/src/iosMain/kotlin/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/MainViewController.kt @@ -1,6 +1,6 @@ /* * Abysner - Dive planner - * Copyright (C) 2024 Neotech + * Copyright (C) 2024-2026 Neotech * * Abysner is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License version 3, @@ -12,28 +12,19 @@ import androidx.compose.ui.uikit.OnFocusBehavior import androidx.compose.ui.window.ComposeUIViewController +import dev.zacsweers.metro.createGraphFactory import org.neotech.app.abysner.App +import org.neotech.app.abysner.data.PlatformFileDataSourceImpl import org.neotech.app.abysner.di.AppComponent -// Unfortunately it seems non-trivial (impossible) to use KSP on the iosMain sourceSet, since -// iosMain is not a target but rather common code (the actual targets are X64, Arm64 and SimulatorArm64. -// See: https://github.com/google/ksp/issues/567. -// -// Therefore create() functions are not generated within this common sourceSet, but rather in the -// target source sets, so these create functions are not accessible here (undefined). -// -// A workaround would be to create an expected function here and implement them in the -// targets, to makes this less cumbersome using @KmpComponentCreate would seem like a good -// solution as it automatically generates the actual implementations. However that also does -// not seem to work (error: '@KmpComponentCreate() actual fun foo(): Bar' has no corresponding -// expected declaration) because the generated actual functions do not seem to correspond to -// a expect function? -// -// TLDR: We have to manually implement a actual and expected method in each iOS target to provide -// us the create methods. -expect fun createAppComponent(): AppComponent - -private val appComponent = createAppComponent() +// Metro graphs cannot have constructor parameters, and platform source sets cannot extend the +// shared graph with additional bindings (only the reverse direction is supported via +// @GraphExtension it seems). So platform dependencies must be constructed manually and passed +// through a @DependencyGraph.Factory. Currently, there is only one (PlatformFileDataSource), but +// if more platform-specific bindings are added this might get messy? Each one needs a factory +// parameter, a @Provides function in AppComponent, and manual construction at every call site +// (Android, iOS, JVM). kotlin-inject avoided this through component inheritance. +private val appComponent = createGraphFactory().create(PlatformFileDataSourceImpl()) @Suppress("unused", "FunctionName") fun MainViewController() = ComposeUIViewController( diff --git a/composeApp/src/iosMain/kotlin/org/neotech/app/abysner/data/PlatformFileDataSource.ios.kt b/composeApp/src/iosMain/kotlin/org/neotech/app/abysner/data/PlatformFileDataSource.ios.kt index eba3b5b..fc57542 100644 --- a/composeApp/src/iosMain/kotlin/org/neotech/app/abysner/data/PlatformFileDataSource.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/neotech/app/abysner/data/PlatformFileDataSource.ios.kt @@ -13,17 +13,13 @@ package org.neotech.app.abysner.data import kotlinx.cinterop.ExperimentalForeignApi -import me.tatarka.inject.annotations.Inject import okio.Path import okio.Path.Companion.toPath -import org.neotech.app.abysner.di.AppScope import platform.Foundation.NSDocumentDirectory import platform.Foundation.NSFileManager import platform.Foundation.NSURL import platform.Foundation.NSUserDomainMask -@AppScope -@Inject class PlatformFileDataSourceImpl: PlatformFileDataSource { @OptIn(ExperimentalForeignApi::class) diff --git a/composeApp/src/iosMain/kotlin/org/neotech/app/abysner/di/PlatformComponent.ios.kt b/composeApp/src/iosMain/kotlin/org/neotech/app/abysner/di/PlatformComponent.ios.kt deleted file mode 100644 index 4aba296..0000000 --- a/composeApp/src/iosMain/kotlin/org/neotech/app/abysner/di/PlatformComponent.ios.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Abysner - Dive planner - * Copyright (C) 2024 Neotech - * - * Abysner is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License version 3, - * as published by the Free Software Foundation. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see https://www.gnu.org/licenses/. - */ - -package org.neotech.app.abysner.di - -import me.tatarka.inject.annotations.Component -import org.neotech.app.abysner.data.PlatformFileDataSourceImpl - -@AppScope -@Component -abstract class PlatformComponentImpl: PlatformComponent() { - abstract override val providesPlatformFileDataSource: PlatformFileDataSourceImpl -} diff --git a/composeApp/src/iosSimulatorArm64Main/kotlin/MainViewController.iosSimulatorArm64.kt b/composeApp/src/iosSimulatorArm64Main/kotlin/MainViewController.iosSimulatorArm64.kt deleted file mode 100644 index 2364a78..0000000 --- a/composeApp/src/iosSimulatorArm64Main/kotlin/MainViewController.iosSimulatorArm64.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Abysner - Dive planner - * Copyright (C) 2024 Neotech - * - * Abysner is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License version 3, - * as published by the Free Software Foundation. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see https://www.gnu.org/licenses/. - */ - -import org.neotech.app.abysner.di.AppComponent -import org.neotech.app.abysner.di.PlatformComponentImpl -import org.neotech.app.abysner.di.create - -actual fun createAppComponent(): AppComponent = AppComponent::class.create(PlatformComponentImpl::class.create()) diff --git a/composeApp/src/iosX64Main/kotlin/MainViewController.iosX64.kt b/composeApp/src/iosX64Main/kotlin/MainViewController.iosX64.kt deleted file mode 100644 index 2364a78..0000000 --- a/composeApp/src/iosX64Main/kotlin/MainViewController.iosX64.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Abysner - Dive planner - * Copyright (C) 2024 Neotech - * - * Abysner is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License version 3, - * as published by the Free Software Foundation. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see https://www.gnu.org/licenses/. - */ - -import org.neotech.app.abysner.di.AppComponent -import org.neotech.app.abysner.di.PlatformComponentImpl -import org.neotech.app.abysner.di.create - -actual fun createAppComponent(): AppComponent = AppComponent::class.create(PlatformComponentImpl::class.create()) diff --git a/composeApp/src/jvmMain/kotlin/main.kt b/composeApp/src/jvmMain/kotlin/main.kt index 9158956..dc63a4a 100644 --- a/composeApp/src/jvmMain/kotlin/main.kt +++ b/composeApp/src/jvmMain/kotlin/main.kt @@ -1,6 +1,6 @@ /* * Abysner - Dive planner - * Copyright (C) 2024 Neotech + * Copyright (C) 2024-2026 Neotech * * Abysner is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License version 3, @@ -12,12 +12,19 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.application +import dev.zacsweers.metro.createGraphFactory import org.neotech.app.abysner.App +import org.neotech.app.abysner.data.PlatformFileDataSourceImpl import org.neotech.app.abysner.di.AppComponent -import org.neotech.app.abysner.di.PlatformComponentImpl -import org.neotech.app.abysner.di.create -private val appComponent = AppComponent::class.create(PlatformComponentImpl::class.create()) +// Metro graphs cannot have constructor parameters, and platform source sets cannot extend the +// shared graph with additional bindings (only the reverse direction is supported via +// @GraphExtension it seems). So platform dependencies must be constructed manually and passed +// through a @DependencyGraph.Factory. Currently, there is only one (PlatformFileDataSource), but +// if more platform-specific bindings are added this might get messy? Each one needs a factory +// parameter, a @Provides function in AppComponent, and manual construction at every call site +// (Android, iOS, JVM). kotlin-inject avoided this through component inheritance. +private val appComponent = createGraphFactory().create(PlatformFileDataSourceImpl()) fun main() = application { Window( diff --git a/composeApp/src/jvmMain/kotlin/org/neotech/app/abysner/data/PlatformFileDataSource.jvm.kt b/composeApp/src/jvmMain/kotlin/org/neotech/app/abysner/data/PlatformFileDataSource.jvm.kt index 08f01ab..bee84b5 100644 --- a/composeApp/src/jvmMain/kotlin/org/neotech/app/abysner/data/PlatformFileDataSource.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/neotech/app/abysner/data/PlatformFileDataSource.jvm.kt @@ -13,13 +13,9 @@ package org.neotech.app.abysner.data import ca.gosyer.appdirs.AppDirs -import me.tatarka.inject.annotations.Inject import okio.Path import okio.Path.Companion.toPath -import org.neotech.app.abysner.di.AppScope -@AppScope -@Inject class PlatformFileDataSourceImpl: PlatformFileDataSource { private val appDirs = AppDirs { diff --git a/composeApp/src/jvmMain/kotlin/org/neotech/app/abysner/di/PlatformComponent.jvm.kt b/composeApp/src/jvmMain/kotlin/org/neotech/app/abysner/di/PlatformComponent.jvm.kt deleted file mode 100644 index 4aba296..0000000 --- a/composeApp/src/jvmMain/kotlin/org/neotech/app/abysner/di/PlatformComponent.jvm.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Abysner - Dive planner - * Copyright (C) 2024 Neotech - * - * Abysner is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License version 3, - * as published by the Free Software Foundation. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see https://www.gnu.org/licenses/. - */ - -package org.neotech.app.abysner.di - -import me.tatarka.inject.annotations.Component -import org.neotech.app.abysner.data.PlatformFileDataSourceImpl - -@AppScope -@Component -abstract class PlatformComponentImpl: PlatformComponent() { - abstract override val providesPlatformFileDataSource: PlatformFileDataSourceImpl -} diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 366b7f3..d712282 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -15,7 +15,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidKmpLibrary) - alias(libs.plugins.ksp) + alias(libs.plugins.metro) alias(libs.plugins.kotlinx.serialization) alias(libs.plugins.kover) } @@ -71,7 +71,6 @@ kotlin { commonMain.dependencies { implementation(project(":domain")) - implementation(libs.kotlinInject.runtimeKmp) implementation(libs.kotlinx.coroutines.core) implementation(libs.androidx.datastore.preferences) implementation(libs.kotlinx.serialization) diff --git a/data/src/commonMain/kotlin/org/neotech/app/abysner/data/PersistenceRepositoryImpl.kt b/data/src/commonMain/kotlin/org/neotech/app/abysner/data/PersistenceRepositoryImpl.kt index ada1c69..b329a30 100644 --- a/data/src/commonMain/kotlin/org/neotech/app/abysner/data/PersistenceRepositoryImpl.kt +++ b/data/src/commonMain/kotlin/org/neotech/app/abysner/data/PersistenceRepositoryImpl.kt @@ -20,7 +20,7 @@ import androidx.datastore.preferences.core.edit import kotlinx.coroutines.flow.Flow import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json -import me.tatarka.inject.annotations.Inject +import dev.zacsweers.metro.Inject import okio.Path import org.neotech.app.abysner.domain.persistence.PersistenceRepository diff --git a/data/src/commonMain/kotlin/org/neotech/app/abysner/data/diveplanning/PlanningRepositoryImpl.kt b/data/src/commonMain/kotlin/org/neotech/app/abysner/data/diveplanning/PlanningRepositoryImpl.kt index ee9b7b5..276f927 100644 --- a/data/src/commonMain/kotlin/org/neotech/app/abysner/data/diveplanning/PlanningRepositoryImpl.kt +++ b/data/src/commonMain/kotlin/org/neotech/app/abysner/data/diveplanning/PlanningRepositoryImpl.kt @@ -25,7 +25,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import me.tatarka.inject.annotations.Inject +import dev.zacsweers.metro.Inject import org.neotech.app.abysner.data.diveplanning.resources.ConfigurationResourceV1 import org.neotech.app.abysner.data.diveplanning.resources.DivePlanInputResourceV1 import org.neotech.app.abysner.data.diveplanning.resources.MultiDivePlanInputResourceV1 diff --git a/data/src/commonMain/kotlin/org/neotech/app/abysner/data/settings/SettingsRepositoryImpl.kt b/data/src/commonMain/kotlin/org/neotech/app/abysner/data/settings/SettingsRepositoryImpl.kt index 481c59f..28849a3 100644 --- a/data/src/commonMain/kotlin/org/neotech/app/abysner/data/settings/SettingsRepositoryImpl.kt +++ b/data/src/commonMain/kotlin/org/neotech/app/abysner/data/settings/SettingsRepositoryImpl.kt @@ -23,7 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import me.tatarka.inject.annotations.Inject +import dev.zacsweers.metro.Inject import org.neotech.app.abysner.data.PREFERENCE_DEPRECATION_WARNING import org.neotech.app.abysner.data.detectSystemUnitSystem import org.neotech.app.abysner.data.getJson diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1c2b8c6..c6343b9 100755 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,12 +13,10 @@ androidx-activityCompose = "1.13.0" # Compose & Kotlin compose-plugin = "1.10.3" kotlin = "2.3.20" -# KSP version should follow Kotlin version -ksp = "2.3.6" kover = "0.9.8" # Dependency Injection -kotlinInject = "0.9.0" +metro = "1.0.0" # Local storage datastore = "1.2.1" @@ -73,10 +71,6 @@ compose-preview-renderer = { group = "com.android.tools.compose", name = "compos # Graphs & Plots koalaplot-core = { module = "io.github.koalaplot:koalaplot-core", version = "0.11.0" } -# Dependency Injection -kotlinInject-compilerKsp = { module = "me.tatarka.inject:kotlin-inject-compiler-ksp", version.ref = "kotlinInject" } -kotlinInject-runtimeKmp = { module = "me.tatarka.inject:kotlin-inject-runtime-kmp", version.ref = "kotlinInject" } - # Local storage androidx-datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } @@ -99,6 +93,6 @@ screenshot = { id = "com.android.compose.screenshot", version.ref = "screenshot" jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin"} -ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } +metro = { id = "dev.zacsweers.metro", version.ref = "metro" }