Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ afterEvaluate {
if (extension.useKoin) {
ksp {
// enable compile time check
arg("KOIN_CONFIG_CHECK", "false")
arg("KOIN_CONFIG_CHECK", "true")
// disable default module generation
arg("KOIN_DEFAULT_MODULE", "false")
}
Expand All @@ -32,6 +32,10 @@ afterEvaluate {
dependencies {
if (extension.useKoin) {
add("kspCommonMainMetadata", libs.koin.ksp.compiler)
add("kspAndroid", libs.koin.ksp.compiler)
add("kspIosX64", libs.koin.ksp.compiler)
add("kspIosArm64", libs.koin.ksp.compiler)
add("kspIosSimulatorArm64", libs.koin.ksp.compiler)
}

// Enable source generation by KSP to commonMain only
Expand Down
12 changes: 6 additions & 6 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
[versions]
agp = "8.9.0"
kotlin = "2.1.10"
ksp = "2.1.10-1.0.31" # Must be compatible with: `kotlin`
agp = "8.11.0"
kotlin = "2.1.20"
ksp = "2.1.20-1.0.32" # Must be compatible with: `kotlin`
desugarLibs = "2.1.5"
androidxLifecycle = "2.8.6"
androidxComposeBom = "2024.10.00"
jetbrainsComposeRuntime = "1.7.0" # Needs to align with version in Compose BOM as closely as possible
androidxActivity = "1.9.2"
decompose = "3.1.0"
essenty = "2.1.0"
koin = "3.5.6"
koinAnnotations = "1.4.0" # Must be compatible with: `ksp`
koin = "4.1.0"
koinAnnotations = "2.1.0" # Must be compatible with: `ksp`
kotlinx-coroutines = "1.8.1"
kotlinx-immutableCollections = "0.3.8"
kotlinx-dateTime = "0.6.1"
Expand All @@ -24,7 +24,7 @@ ktor = "3.1.0" # Must be compatible with: `ktorfit`
kotlinx-serialization = "1.8.0" # Must be compatible with: `kotlin`
timber = "5.0.1"
kermit = "2.0.2"
skie = "0.10.1" # Must be compatible with: `kotlin`
skie = "0.10.2" # Must be compatible with: `kotlin`
buildkonfig = "0.15.2"
nsExceptionKt = "1.0.0-BETA-7"
datastore = "1.1.1"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package app.futured.kmptemplate.app.injection
import app.futured.kmptemplate.feature.injection.FeatureModule
import app.futured.kmptemplate.network.graphql.injection.NetworkGraphqlModule
import app.futured.kmptemplate.network.rest.injection.NetworkRestModule
import app.futured.kmptemplate.persistence.injection.persistenceModule
import app.futured.kmptemplate.persistence.injection.PersistenceModule
import app.futured.kmptemplate.platform.binding.PlatformBindings
import app.futured.kmptemplate.platform.injection.platformModule
import org.koin.core.context.startKoin
Expand All @@ -30,7 +30,7 @@ internal object AppInjection {
FeatureModule().module,
NetworkGraphqlModule().module,
NetworkRestModule().module,
persistenceModule(),
PersistenceModule().module,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package app.futured.kmptemplate.feature.domain

import app.futured.arkitekt.crusecases.UseCase
import app.futured.kmptemplate.persistence.persistence.user.UserPersistence
import org.koin.core.annotation.Factory

@Factory
class IsUserLoggedInUseCase(private val userPersistence: UserPersistence) : UseCase<Unit, Boolean>() {
override suspend fun build(args: Unit): Boolean = userPersistence.isUserLoggedIn()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package app.futured.kmptemplate.feature.domain

import app.futured.arkitekt.crusecases.UseCase
import app.futured.kmptemplate.persistence.persistence.user.UserPersistence
import org.koin.core.annotation.Factory

@Factory
class SetUserLoggedInUseCase(private val userPersistence: UserPersistence) : UseCase<SetUserLoggedInUseCase.Args, Unit>() {

override suspend fun build(args: Args) = userPersistence.setUserLoggedIn(args.isLoggedIn)

data class Args(val isLoggedIn: Boolean)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app.futured.kmptemplate.feature.navigation.root

import app.futured.arkitekt.decompose.ext.asStateFlow
import app.futured.kmptemplate.feature.domain.IsUserLoggedInUseCase
import app.futured.kmptemplate.feature.navigation.deepLink.DeepLinkDestination
import app.futured.kmptemplate.feature.navigation.deepLink.DeepLinkResolver
import app.futured.kmptemplate.feature.navigation.signedIn.SignedInNavHostComponentFactory
Expand All @@ -18,8 +19,11 @@ import org.koin.core.annotation.Factory
import org.koin.core.annotation.InjectedParam

@Factory
internal class RootNavHostComponent(@InjectedParam componentContext: AppComponentContext, private val deepLinkResolver: DeepLinkResolver) :
AppComponent<RootNavHostViewState, Nothing>(componentContext, RootNavHostViewState),
internal class RootNavHostComponent(
@InjectedParam componentContext: AppComponentContext,
private val deepLinkResolver: DeepLinkResolver,
private val isUserLoggedInUseCase: IsUserLoggedInUseCase,
) : AppComponent<RootNavHostViewState, Nothing>(componentContext, RootNavHostViewState),
RootNavHost {

private val rootNavigator: RootNavHostNavigation = RootNavHostNavigator()
Expand Down Expand Up @@ -55,7 +59,7 @@ internal class RootNavHostComponent(@InjectedParam componentContext: AppComponen
init {
doOnCreate {
if (!consumeDeepLink()) {
rootNavigator.slotNavigator.activate(RootConfig.Login)
checkUserLoggedIn()
}
}
}
Expand Down Expand Up @@ -84,4 +88,16 @@ internal class RootNavHostComponent(@InjectedParam componentContext: AppComponen
rootNavigator.slotNavigator.activate(deepLinkConfig)
return true
}

private fun checkUserLoggedIn() {
isUserLoggedInUseCase.execute {
onSuccess { isLoggedIn ->
if (isLoggedIn) {
rootNavigator.slotNavigator.activate(RootConfig.SignedIn())
} else {
rootNavigator.slotNavigator.activate(RootConfig.Login)
}
}
}
}
Comment on lines +92 to +102
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Race-condition: deep link received after async login check

checkUserLoggedIn() is async; if a deep link arrives while the coroutine is running, the slot may first activate Login/SignedIn and then immediately re-activate the deep-link config, causing a visible “flash”.

Consider cancelling the job when a deep link is consumed or delaying slot activation until the first router decision is final.

🤖 Prompt for AI Agents
In
shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/navigation/root/RootNavHostComponent.kt
around lines 92 to 102, the async checkUserLoggedIn function can cause a race
condition with deep link handling, leading to UI flashing due to multiple slot
activations. To fix this, implement cancellation of the ongoing login check
coroutine when a deep link is received or delay activating the slot navigator
until the login check completes and the first routing decision is finalized,
ensuring only one activation occurs.

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app.futured.kmptemplate.feature.ui.loginScreen

import app.futured.factorygenerator.annotation.GenerateFactory
import app.futured.kmptemplate.feature.domain.SetUserLoggedInUseCase
import app.futured.kmptemplate.feature.ui.base.AppComponentContext
import app.futured.kmptemplate.feature.ui.base.ScreenComponent
import kotlinx.coroutines.flow.StateFlow
Expand All @@ -12,6 +13,7 @@ import org.koin.core.annotation.InjectedParam
internal class LoginComponent(
@InjectedParam componentContext: AppComponentContext,
@InjectedParam override val navigation: LoginScreenNavigation,
private val setUserLoggedInUseCase: SetUserLoggedInUseCase,
) : ScreenComponent<LoginViewState, Nothing, LoginScreenNavigation>(
componentContext = componentContext,
defaultState = LoginViewState,
Expand All @@ -23,5 +25,11 @@ internal class LoginComponent(
override val actions: LoginScreen.Actions = this
override val viewState: StateFlow<LoginViewState> = componentState

override fun onLoginClick() = navigateToSignedIn()
override fun onLoginClick() {
setUserLoggedInUseCase.execute(SetUserLoggedInUseCase.Args(true)) {
onSuccess {
navigateToSignedIn()
}
}
}
Comment on lines +28 to +34
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Missing loading / failure handling in login flow

execute { onSuccess { … } } ignores the onError and onStart / onComplete callbacks that UseCase exposes.
Users can tap the button repeatedly while the coroutine is running, or the call can fail silently and leave the UI stalled. At minimum, disable the button during the operation and surface an error if the persistence write fails.

 override fun onLoginClick() {
-    setUserLoggedInUseCase.execute(SetUserLoggedInUseCase.Args(true)) {
-        onSuccess {
-            navigateToSignedIn()
-        }
-    }
+    setUserLoggedInUseCase.execute(SetUserLoggedInUseCase.Args(true)) {
+        onStart   { setLoading(true)  }
+        onSuccess { navigateToSignedIn() }
+        onError   { showError(it); setLoading(false) }
+        onComplete{ setLoading(false) }
+    }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
override fun onLoginClick() {
setUserLoggedInUseCase.execute(SetUserLoggedInUseCase.Args(true)) {
onSuccess {
navigateToSignedIn()
}
}
}
override fun onLoginClick() {
- setUserLoggedInUseCase.execute(SetUserLoggedInUseCase.Args(true)) {
- onSuccess {
- navigateToSignedIn()
- }
- }
+ setUserLoggedInUseCase.execute(SetUserLoggedInUseCase.Args(true)) {
+ onStart { setLoading(true) }
+ onSuccess { navigateToSignedIn() }
+ onError { showError(it); setLoading(false) }
+ onComplete { setLoading(false) }
+ }
}
🤖 Prompt for AI Agents
In
shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/ui/loginScreen/LoginComponent.kt
between lines 28 and 34, the onLoginClick function currently only handles the
onSuccess callback of the use case execution, ignoring onError and
onStart/onComplete. To fix this, update the execute call to handle onStart by
disabling the login button, onComplete by re-enabling it, and onError by showing
an error message to the user. This will prevent multiple taps during the
operation and provide feedback if the login persistence fails.

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app.futured.kmptemplate.feature.ui.profileScreen

import app.futured.factorygenerator.annotation.GenerateFactory
import app.futured.kmptemplate.feature.domain.SetUserLoggedInUseCase
import app.futured.kmptemplate.feature.ui.base.AppComponentContext
import app.futured.kmptemplate.feature.ui.base.ScreenComponent
import kotlinx.coroutines.flow.StateFlow
Expand All @@ -12,6 +13,7 @@ import org.koin.core.annotation.InjectedParam
internal class ProfileComponent(
@InjectedParam componentContext: AppComponentContext,
@InjectedParam override val navigation: ProfileScreenNavigation,
private val setUserLoggedInUseCase: SetUserLoggedInUseCase,
) : ScreenComponent<ProfileViewState, Nothing, ProfileScreenNavigation>(
componentContext,
ProfileViewState,
Expand All @@ -22,6 +24,12 @@ internal class ProfileComponent(

override val actions: ProfileScreen.Actions = this
override val viewState: StateFlow<ProfileViewState> = componentState
override fun onLogout() = navigateToLogin()
override fun onLogout() {
setUserLoggedInUseCase.execute(SetUserLoggedInUseCase.Args(false)) {
onSuccess {
navigateToLogin()
}
}
}
Comment on lines +27 to +33
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Handle failure path of SetUserLoggedInUseCase

Currently only the success branch is handled; if the persistence write fails the user stays on the profile screen with no feedback. At minimum capture onError and surface it (toast / snackbar) or still navigate to login if failure is non-critical.

-        setUserLoggedInUseCase.execute(SetUserLoggedInUseCase.Args(false)) {
-            onSuccess {
-                navigateToLogin()
-            }
-        }
+        setUserLoggedInUseCase.execute(SetUserLoggedInUseCase.Args(false)) {
+            onSuccess { navigateToLogin() }
+            onError { /* TODO: show error or decide to navigate anyway */ }
+        }

Leaving errors unhandled can hide persistence issues and confuse users.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
override fun onLogout() {
setUserLoggedInUseCase.execute(SetUserLoggedInUseCase.Args(false)) {
onSuccess {
navigateToLogin()
}
}
}
override fun onLogout() {
setUserLoggedInUseCase.execute(SetUserLoggedInUseCase.Args(false)) {
onSuccess { navigateToLogin() }
onError { /* TODO: show error or decide to navigate anyway */ }
}
}
🤖 Prompt for AI Agents
In
shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/ui/profileScreen/ProfileComponent.kt
around lines 27 to 33, the onLogout function only handles the success case of
setUserLoggedInUseCase.execute and ignores failures. Add an onError handler to
capture errors from the use case execution. In the onError block, either show a
user-visible message like a toast or snackbar to indicate the failure or decide
to navigate to the login screen anyway if the failure is not critical, ensuring
the user is not left without feedback.

override fun onThird() = navigateToThird("hello third from profile")
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import app.futured.kmptemplate.network.rest.FlavorConstants
import app.futured.kmptemplate.platform.binding.Platform
import io.ktor.client.HttpClientConfig
import io.ktor.client.plugins.UserAgent
import org.koin.core.annotation.Provided
import org.koin.core.annotation.Single

@Single
internal class UserAgentPlugin(platform: Platform) : HttpClientPlugin {
internal class UserAgentPlugin(@Provided platform: Platform) : HttpClientPlugin {

private val userAgentString = with(platform) {
listOf(
Expand Down
8 changes: 8 additions & 0 deletions shared/persistence/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ plugins {
alias(libs.plugins.kotlin.serialization)

id(libs.plugins.conventions.lint.get().pluginId)
id(libs.plugins.conventions.annotationProcessing.get().pluginId)
}

annotations {
useKoin = true
}

kotlin {
Expand All @@ -24,8 +29,11 @@ kotlin {

sourceSets {
commonMain {
kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")

dependencies {
implementation(libs.koin.core)
implementation(libs.koin.annotations)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.logging.kermit)
implementation(libs.kotlinx.serialization.json)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package app.futured.kmptemplate.persistence.injection

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import app.futured.kmptemplate.persistence.tools.SETTINGS_DATASTORE_FILENAME
import okio.Path.Companion.toPath
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject

@Module
actual class DataStoreModule actual constructor() : KoinComponent {

private val context: Context by inject()

@Single
actual fun provideDataStore(): DataStore<Preferences> = PreferenceDataStoreFactory.createWithPath(
produceFile = { context.filesDir.resolve(SETTINGS_DATASTORE_FILENAME).absolutePath.toPath() },
)
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,31 +1,29 @@
package app.futured.kmptemplate.persistence.injection

import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import app.futured.kmptemplate.persistence.persistence.JsonPersistence
import app.futured.kmptemplate.persistence.persistence.PrimitivePersistence
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import kotlinx.serialization.json.Json
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single

fun persistenceModule() = module {
// expect/actual Koin module
includes(persistencePlatformModule())
@Module(includes = [DataStoreModule::class])
@ComponentScan("app.futured.kmptemplate.persistence")
class PersistenceModule {

single {
PreferenceDataStoreFactory.createWithPath(
produceFile = { get(Qualifiers.DataStorePath) },
)
@Single
@PersistenceJson
internal fun provideJson(): Json = Json {
encodeDefaults = true
isLenient = false
ignoreUnknownKeys = true
prettyPrint = false
}
}

singleOf(::PrimitivePersistence)
single { JsonPersistence(get(), get(Qualifiers.PersistenceJson)) }
@Module
expect class DataStoreModule() {

single(Qualifiers.PersistenceJson) {
Json {
encodeDefaults = true
isLenient = false
ignoreUnknownKeys = true
prettyPrint = false
}
}
@Single
fun provideDataStore(): DataStore<Preferences>
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
package app.futured.kmptemplate.persistence.injection

import org.koin.core.qualifier.named
import org.koin.core.annotation.Named

internal object Qualifiers {

val PersistenceJson = named("PersistenceJson")
val DataStorePath = named("DataStoreFilePath")
}
@Named
annotation class PersistenceJson
Comment on lines +3 to +6
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Wrong meta-annotation – this won’t compile

@Named requires a non-optional value argument (Named(val value: String)), so using it as a meta-annotation without a value produces a compilation error.
If you intend to create a custom qualifier, annotate it with @Qualifier instead (Koin will still recognise it), or pass an explicit value to @Named.

-import org.koin.core.annotation.Named
+import org.koin.core.annotation.Qualifier
@@
-@Named
-annotation class PersistenceJson
+@Qualifier
+annotation class PersistenceJson

(Optionally add @Retention(BINARY) / @Target(*) if you need finer control.)

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import org.koin.core.annotation.Named
internal object Qualifiers {
val PersistenceJson = named("PersistenceJson")
val DataStorePath = named("DataStoreFilePath")
}
@Named
annotation class PersistenceJson
import org.koin.core.annotation.Qualifier
@Qualifier
annotation class PersistenceJson
🤖 Prompt for AI Agents
In
shared/persistence/src/commonMain/kotlin/app/futured/kmptemplate/persistence/injection/Qualifiers.kt
lines 3 to 6, the @Named annotation is used without the required value argument,
causing a compilation error. Replace @Named with @Qualifier to define a custom
qualifier annotation correctly, and optionally add @Retention and @Target
annotations for finer control over its usage.

Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,23 @@ package app.futured.kmptemplate.persistence.persistence
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import app.futured.kmptemplate.persistence.injection.PersistenceJson
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Provided
import org.koin.core.annotation.Single

/**
* [DataStore]-backed Persistence which allows storage and observing of complex JSON objects.
* Uses [kotlinx.serialization] to serialize and deserialize objects into Strings.
*/
internal class JsonPersistence(private val dataStore: DataStore<Preferences>, private val json: Json) {
@Single
internal class JsonPersistence(@Provided private val dataStore: DataStore<Preferences>, @PersistenceJson private val json: Json) {

private val logger = Logger.withTag("JsonPersistence")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import androidx.datastore.preferences.core.edit
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
import org.koin.core.annotation.Provided
import org.koin.core.annotation.Single

/**
* [DataStore]-backed Persistence which allows storage and observing of persisted entities.
*/
internal class PrimitivePersistence(private val dataStore: DataStore<Preferences>) {
@Single
internal class PrimitivePersistence(@Provided private val dataStore: DataStore<Preferences>) {

suspend fun <T : Any> get(key: Preferences.Key<T>): T? = dataStore.data.firstOrNull()?.run {
this[key]
Expand Down
Loading
Loading