From fb100eefba458e57a2836b5e0fb537e64396d646 Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 20 Apr 2026 15:32:32 +0900 Subject: [PATCH 01/33] Set root = true in .editorconfig --- .editorconfig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.editorconfig b/.editorconfig index 2b1f9cd..da065b5 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,5 @@ +root = true + [*.kt] ktlint_function_naming_ignore_when_annotated_with = Composable ktlint_standard_multiline-expression-wrapping = disabled From f975f92deafa47d902b417d39026c9cc7e07d64f Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 20 Apr 2026 15:33:38 +0900 Subject: [PATCH 02/33] Implement `signUpWithEmailAndPassword` in `AuthRepository` and its implementation --- .../kotlin/dev/yaseyo/auth/api/AuthRepository.kt | 5 +++++ .../kotlin/dev/yaseyo/auth/impl/AuthRepositoryImpl.kt | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/libs/auth/api/src/commonMain/kotlin/dev/yaseyo/auth/api/AuthRepository.kt b/libs/auth/api/src/commonMain/kotlin/dev/yaseyo/auth/api/AuthRepository.kt index 0b239de..9f70327 100644 --- a/libs/auth/api/src/commonMain/kotlin/dev/yaseyo/auth/api/AuthRepository.kt +++ b/libs/auth/api/src/commonMain/kotlin/dev/yaseyo/auth/api/AuthRepository.kt @@ -12,5 +12,10 @@ interface AuthRepository { password: String, ): Result + suspend fun signUpWithEmailAndPassword( + email: String, + password: String, + ): Result + suspend fun signOut() } diff --git a/libs/auth/impl/src/commonMain/kotlin/dev/yaseyo/auth/impl/AuthRepositoryImpl.kt b/libs/auth/impl/src/commonMain/kotlin/dev/yaseyo/auth/impl/AuthRepositoryImpl.kt index 28d1d41..cbf4a6c 100644 --- a/libs/auth/impl/src/commonMain/kotlin/dev/yaseyo/auth/impl/AuthRepositoryImpl.kt +++ b/libs/auth/impl/src/commonMain/kotlin/dev/yaseyo/auth/impl/AuthRepositoryImpl.kt @@ -50,6 +50,17 @@ internal class AuthRepositoryImpl( ) } + override suspend fun signUpWithEmailAndPassword( + email: String, + password: String, + ): Result = + runCatching { + firebaseAuth.createUserWithEmailAndPassword( + email = email, + password = password, + ) + } + override suspend fun signOut() { firebaseAuth.signOut() } From cfc11a829a8b2d4cd8677ea69221b1dddc82dbb5 Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 20 Apr 2026 15:50:38 +0900 Subject: [PATCH 03/33] Add feature:onboarding:domain module and CheckAppStateUseCase --- feature/onboarding/domain/build.gradle.kts | 53 +++++++++++++++++ .../dev/yaseyo/onboarding/domain/AppState.kt | 16 +++++ .../onboarding/domain/CheckAppStateUseCase.kt | 23 ++++++++ .../domain/OnboardingProfileDraft.kt | 18 ++++++ .../domain/CheckAppStateUseCaseTest.kt | 40 +++++++++++++ .../onboarding/domain/FakeAuthRepository.kt | 26 +++++++++ .../onboarding/domain/FakeUserRepository.kt | 58 +++++++++++++++++++ settings.gradle.kts | 1 + 8 files changed, 235 insertions(+) create mode 100644 feature/onboarding/domain/build.gradle.kts create mode 100644 feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/AppState.kt create mode 100644 feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/CheckAppStateUseCase.kt create mode 100644 feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/OnboardingProfileDraft.kt create mode 100644 feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/CheckAppStateUseCaseTest.kt create mode 100644 feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/FakeAuthRepository.kt create mode 100644 feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/FakeUserRepository.kt diff --git a/feature/onboarding/domain/build.gradle.kts b/feature/onboarding/domain/build.gradle.kts new file mode 100644 index 0000000..3691f72 --- /dev/null +++ b/feature/onboarding/domain/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + id("yaseyo.kmp.library") +} + +kotlin { + androidLibrary { + namespace = "dev.yaseyo.onboarding.domain" + + withHostTestBuilder { + } + + withDeviceTestBuilder { + sourceSetTreeName = "test" + }.configure { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + } + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + implementation(libs.koin.core) + api(projects.libs.auth.api) + api(projects.libs.user.api) + } + } + + commonTest { + dependencies { + implementation(libs.kotlin.test) + } + } + + androidMain { + dependencies { + } + } + + getByName("androidDeviceTest") { + dependencies { + implementation(libs.androidx.runner) + implementation(libs.androidx.core) + implementation(libs.androidx.testExt.junit) + } + } + + iosMain { + dependencies { + } + } + } +} diff --git a/feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/AppState.kt b/feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/AppState.kt new file mode 100644 index 0000000..d024974 --- /dev/null +++ b/feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/AppState.kt @@ -0,0 +1,16 @@ +package dev.yaseyo.onboarding.domain + +/** + * Represents the initial destination the app should route to on launch, + * determined by [CheckAppStateUseCase]. + */ +sealed interface AppState { + /** No active authentication session — user must sign in or sign up. */ + data object Unauthenticated : AppState + + /** Signed in but no profile exists yet — user must complete the onboarding wizard. */ + data object RequiresProfileSetup : AppState + + /** Signed in with a complete profile — proceed directly to the main app. */ + data object FullySetup : AppState +} diff --git a/feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/CheckAppStateUseCase.kt b/feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/CheckAppStateUseCase.kt new file mode 100644 index 0000000..70c3250 --- /dev/null +++ b/feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/CheckAppStateUseCase.kt @@ -0,0 +1,23 @@ +package dev.yaseyo.onboarding.domain + +import dev.yaseyo.auth.api.AuthRepository +import dev.yaseyo.auth.api.AuthState +import dev.yaseyo.user.api.UserRepository + +class CheckAppStateUseCase( + private val authRepository: AuthRepository, + private val userRepository: UserRepository, +) { + suspend operator fun invoke(): AppState = + when (authRepository.authState.value) { + is AuthState.SignedOut -> AppState.Unauthenticated + is AuthState.SignedIn -> { + val user = userRepository.getCurrentUser() + if (user.isSuccess) { + AppState.FullySetup + } else { + AppState.RequiresProfileSetup + } + } + } +} diff --git a/feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/OnboardingProfileDraft.kt b/feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/OnboardingProfileDraft.kt new file mode 100644 index 0000000..e8e8757 --- /dev/null +++ b/feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/OnboardingProfileDraft.kt @@ -0,0 +1,18 @@ +package dev.yaseyo.onboarding.domain + +import dev.yaseyo.user.api.ActivityLevel +import dev.yaseyo.user.api.Sex +import kotlinx.datetime.LocalDate + +data class OnboardingProfileDraft( + val name: String = "", + val sex: Sex? = null, + val height: Double? = null, + val dateOfBirth: LocalDate? = null, + val activityLevel: ActivityLevel? = null, + val targetWeightKg: Double? = null, + val dailyDeficitKcal: Double? = null, + val targetProtein: Double? = null, + val targetCarbs: Double? = null, + val targetFat: Double? = null, +) diff --git a/feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/CheckAppStateUseCaseTest.kt b/feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/CheckAppStateUseCaseTest.kt new file mode 100644 index 0000000..0a0e332 --- /dev/null +++ b/feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/CheckAppStateUseCaseTest.kt @@ -0,0 +1,40 @@ +package dev.yaseyo.onboarding.domain + +import dev.yaseyo.auth.api.AuthState +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertIs + +class CheckAppStateUseCaseTest { + private fun useCase( + authRepository: FakeAuthRepository = FakeAuthRepository(), + userRepository: FakeUserRepository = FakeUserRepository(), + ) = CheckAppStateUseCase(authRepository, userRepository) + + @Test + fun `returns Unauthenticated when signed out`(): Unit = + runBlocking { + val result = useCase(authRepository = FakeAuthRepository(AuthState.SignedOut)).invoke() + assertIs(result) + } + + @Test + fun `returns FullySetup when signed in and profile exists`(): Unit = + runBlocking { + val result = useCase( + authRepository = FakeAuthRepository(AuthState.SignedIn(userId = "usr_abc123")), + userRepository = FakeUserRepository(Result.success(FakeUserRepository.DEFAULT_USER)), + ).invoke() + assertIs(result) + } + + @Test + fun `returns RequiresProfileSetup when signed in but no profile exists`(): Unit = + runBlocking { + val result = useCase( + authRepository = FakeAuthRepository(AuthState.SignedIn(userId = "usr_abc123")), + userRepository = FakeUserRepository(Result.failure(RuntimeException("not found"))), + ).invoke() + assertIs(result) + } +} diff --git a/feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/FakeAuthRepository.kt b/feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/FakeAuthRepository.kt new file mode 100644 index 0000000..bb3baa5 --- /dev/null +++ b/feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/FakeAuthRepository.kt @@ -0,0 +1,26 @@ +package dev.yaseyo.onboarding.domain + +import dev.yaseyo.auth.api.AuthRepository +import dev.yaseyo.auth.api.AuthState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +internal class FakeAuthRepository( + authState: AuthState = AuthState.SignedOut, +) : AuthRepository { + override val authState: StateFlow = MutableStateFlow(authState) + + override suspend fun getIdToken(): String? = null + + override suspend fun signInWithEmailAndPassword( + email: String, + password: String, + ): Result = Result.success(Unit) + + override suspend fun signUpWithEmailAndPassword( + email: String, + password: String, + ): Result = Result.success(Unit) + + override suspend fun signOut() = Unit +} diff --git a/feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/FakeUserRepository.kt b/feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/FakeUserRepository.kt new file mode 100644 index 0000000..6760de8 --- /dev/null +++ b/feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/FakeUserRepository.kt @@ -0,0 +1,58 @@ +package dev.yaseyo.onboarding.domain + +import dev.yaseyo.user.api.ActivityLevel +import dev.yaseyo.user.api.Sex +import dev.yaseyo.user.api.User +import dev.yaseyo.user.api.UserRepository +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate + +internal class FakeUserRepository( + private val userResult: Result = Result.success(DEFAULT_USER), +) : UserRepository { + override suspend fun getCurrentUser(): Result = userResult + + override suspend fun createUser( + name: String, + sex: Sex, + height: Double, + dateOfBirth: LocalDate, + activityLevel: ActivityLevel, + targetWeightKg: Double, + dailyDeficitKcal: Double, + targetProtein: Double, + targetCarbs: Double, + targetFat: Double, + ): Result = userResult + + override suspend fun updateUser( + name: String?, + sex: Sex?, + height: Double?, + dateOfBirth: LocalDate?, + activityLevel: ActivityLevel?, + targetWeightKg: Double?, + dailyDeficitKcal: Double?, + targetProtein: Double?, + targetCarbs: Double?, + targetFat: Double?, + ): Result = userResult + + companion object { + val DEFAULT_USER = User( + id = "usr_abc123", + name = "Alex Smith", + sex = Sex.MALE, + height = 178.0, + dateOfBirth = LocalDate(1990, 6, 15), + activityLevel = ActivityLevel.LightlyActive, + targetWeightKg = 80.0, + dailyDeficitKcal = 500.0, + targetProtein = 160.0, + targetCarbs = 200.0, + targetFat = 70.0, + createdAt = Instant.parse("2024-01-10T08:00:00Z"), + updatedAt = Instant.parse("2024-03-20T14:30:00Z"), + ) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index bf29ce8..a58a6f2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -42,3 +42,4 @@ include(":libs:auth:impl") include(":libs:coroutines") include(":libs:network") include(":libs:di") +include(":feature:onboarding:domain") From 37b67c93219e4fbfb7874118c73211be517e9045 Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 20 Apr 2026 15:59:26 +0900 Subject: [PATCH 04/33] Add onboarding:data module and configure DI for user and onboarding features --- feature/onboarding/data/build.gradle.kts | 54 +++++++++++++++++++ .../onboarding/data/OnboardingDataModule.kt | 13 +++++ libs/di/build.gradle.kts | 4 ++ .../kotlin/dev/yaseyo/di/AppModules.kt | 4 ++ .../kotlin/dev/yaseyo/user/di/UserModule.kt | 8 +++ settings.gradle.kts | 1 + 6 files changed, 84 insertions(+) create mode 100644 feature/onboarding/data/build.gradle.kts create mode 100644 feature/onboarding/data/src/commonMain/kotlin/dev/yaseyo/onboarding/data/OnboardingDataModule.kt create mode 100644 libs/user/src/commonMain/kotlin/dev/yaseyo/user/di/UserModule.kt diff --git a/feature/onboarding/data/build.gradle.kts b/feature/onboarding/data/build.gradle.kts new file mode 100644 index 0000000..a760b50 --- /dev/null +++ b/feature/onboarding/data/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + id("yaseyo.kmp.library") +} + +kotlin { + androidLibrary { + namespace = "dev.yaseyo.onboarding.data" + + withHostTestBuilder { + } + + withDeviceTestBuilder { + sourceSetTreeName = "test" + }.configure { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + } + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + implementation(libs.koin.core) + implementation(projects.feature.onboarding.domain) + implementation(projects.libs.auth.api) + implementation(projects.libs.user.api) + } + } + + commonTest { + dependencies { + implementation(libs.kotlin.test) + } + } + + androidMain { + dependencies { + } + } + + getByName("androidDeviceTest") { + dependencies { + implementation(libs.androidx.runner) + implementation(libs.androidx.core) + implementation(libs.androidx.testExt.junit) + } + } + + iosMain { + dependencies { + } + } + } +} diff --git a/feature/onboarding/data/src/commonMain/kotlin/dev/yaseyo/onboarding/data/OnboardingDataModule.kt b/feature/onboarding/data/src/commonMain/kotlin/dev/yaseyo/onboarding/data/OnboardingDataModule.kt new file mode 100644 index 0000000..cdc7f41 --- /dev/null +++ b/feature/onboarding/data/src/commonMain/kotlin/dev/yaseyo/onboarding/data/OnboardingDataModule.kt @@ -0,0 +1,13 @@ +package dev.yaseyo.onboarding.data + +import dev.yaseyo.onboarding.domain.CheckAppStateUseCase +import org.koin.dsl.module + +val onboardingDataModule = module { + factory { + CheckAppStateUseCase( + authRepository = get(), + userRepository = get(), + ) + } +} diff --git a/libs/di/build.gradle.kts b/libs/di/build.gradle.kts index 9f0afc5..4909cb3 100644 --- a/libs/di/build.gradle.kts +++ b/libs/di/build.gradle.kts @@ -34,6 +34,10 @@ kotlin { api(projects.libs.auth.api) implementation(projects.libs.auth) implementation(projects.libs.network) + implementation(projects.libs.user) + + // Features + implementation(projects.feature.onboarding.data) } } diff --git a/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt index 7eec342..b613de1 100644 --- a/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt +++ b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt @@ -3,11 +3,15 @@ package dev.yaseyo.di import dev.yaseyo.auth.di.authModule import dev.yaseyo.coroutines.coroutinesModule import dev.yaseyo.network.networkModule +import dev.yaseyo.onboarding.data.onboardingDataModule +import dev.yaseyo.user.di.userModule import org.koin.dsl.module internal val appModules = listOf( coroutinesModule, authModule, + userModule, module { single { networkConfig } }, networkModule, + onboardingDataModule, ) diff --git a/libs/user/src/commonMain/kotlin/dev/yaseyo/user/di/UserModule.kt b/libs/user/src/commonMain/kotlin/dev/yaseyo/user/di/UserModule.kt new file mode 100644 index 0000000..e040b3a --- /dev/null +++ b/libs/user/src/commonMain/kotlin/dev/yaseyo/user/di/UserModule.kt @@ -0,0 +1,8 @@ +package dev.yaseyo.user.di + +import dev.yaseyo.user.impl.userImplModule +import org.koin.dsl.module + +val userModule = module { + includes(userImplModule) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index a58a6f2..27c670c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -43,3 +43,4 @@ include(":libs:coroutines") include(":libs:network") include(":libs:di") include(":feature:onboarding:domain") +include(":feature:onboarding:data") From 70cfa6dcd8efacaf727d21e0b4f58153908b894b Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 20 Apr 2026 15:59:55 +0900 Subject: [PATCH 05/33] Update Instant import from kotlinx.datetime to kotlin.time in User API model --- libs/user/api/src/commonMain/kotlin/dev/yaseyo/user/api/User.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/user/api/src/commonMain/kotlin/dev/yaseyo/user/api/User.kt b/libs/user/api/src/commonMain/kotlin/dev/yaseyo/user/api/User.kt index d830433..6028aee 100644 --- a/libs/user/api/src/commonMain/kotlin/dev/yaseyo/user/api/User.kt +++ b/libs/user/api/src/commonMain/kotlin/dev/yaseyo/user/api/User.kt @@ -1,7 +1,7 @@ package dev.yaseyo.user.api -import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate +import kotlin.time.Instant data class User( val id: String, From 5bd207b6eb6f84bb84da28d2bc4cfb8af4f02b02 Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 20 Apr 2026 18:32:01 +0900 Subject: [PATCH 06/33] Add libs:presentation module and KmpObservable Swift wrapper --- gradle/libs.versions.toml | 1 + iosApp/iosApp/KmpObservable.swift | 19 +++++++++++ libs/presentation/build.gradle.kts | 51 ++++++++++++++++++++++++++++++ settings.gradle.kts | 1 + 4 files changed, 72 insertions(+) create mode 100644 iosApp/iosApp/KmpObservable.swift create mode 100644 libs/presentation/build.gradle.kts diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 171d4d3..936ab21 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,6 +39,7 @@ androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", vers androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } +androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } diff --git a/iosApp/iosApp/KmpObservable.swift b/iosApp/iosApp/KmpObservable.swift new file mode 100644 index 0000000..4266f2f --- /dev/null +++ b/iosApp/iosApp/KmpObservable.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Wraps a KMP ViewModel-like object as a SwiftUI ObservableObject. +/// The onDeinit closure is called when this holder is deallocated, +/// which is where you call kmp.onCleared() to cancel the coroutine scope. +@MainActor +final class KmpObservable: ObservableObject { + let kmp: T + private let onDeinit: () -> Void + + init(_ kmp: T, onDeinit: @escaping () -> Void) { + self.kmp = kmp + self.onDeinit = onDeinit + } + + deinit { + onDeinit() + } +} diff --git a/libs/presentation/build.gradle.kts b/libs/presentation/build.gradle.kts new file mode 100644 index 0000000..9011a88 --- /dev/null +++ b/libs/presentation/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + id("yaseyo.kmp.library") +} + +kotlin { + androidLibrary { + namespace = "dev.yaseyo.presentation" + + withHostTestBuilder { + } + + withDeviceTestBuilder { + sourceSetTreeName = "test" + }.configure { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + } + + sourceSets { + commonMain { + dependencies { + api(libs.androidx.lifecycle.viewmodel) + api(libs.kotlinx.coroutines.core) + } + } + + commonTest { + dependencies { + implementation(libs.kotlin.test) + } + } + + androidMain { + dependencies { + } + } + + getByName("androidDeviceTest") { + dependencies { + implementation(libs.androidx.runner) + implementation(libs.androidx.core) + implementation(libs.androidx.testExt.junit) + } + } + + iosMain { + dependencies { + } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 27c670c..e2e9a91 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -42,5 +42,6 @@ include(":libs:auth:impl") include(":libs:coroutines") include(":libs:network") include(":libs:di") +include(":libs:presentation") include(":feature:onboarding:domain") include(":feature:onboarding:data") From 91d82b873869f26bf92eb8ea983a6025ccde3929 Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 20 Apr 2026 18:36:03 +0900 Subject: [PATCH 07/33] Export user and presentation modules in libs:di and add UserDependencies for iOS --- libs/di/build.gradle.kts | 5 ++++- .../src/iosMain/kotlin/dev/yaseyo/user/UserDependencies.kt | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 libs/user/src/iosMain/kotlin/dev/yaseyo/user/UserDependencies.kt diff --git a/libs/di/build.gradle.kts b/libs/di/build.gradle.kts index 4909cb3..e6a423d 100644 --- a/libs/di/build.gradle.kts +++ b/libs/di/build.gradle.kts @@ -20,6 +20,8 @@ kotlin { ).forEach { target -> target.binaries.withType().configureEach { export(projects.libs.auth.api) + export(projects.libs.user) + export(projects.libs.presentation) } } @@ -34,7 +36,8 @@ kotlin { api(projects.libs.auth.api) implementation(projects.libs.auth) implementation(projects.libs.network) - implementation(projects.libs.user) + api(projects.libs.user) + api(projects.libs.presentation) // Features implementation(projects.feature.onboarding.data) diff --git a/libs/user/src/iosMain/kotlin/dev/yaseyo/user/UserDependencies.kt b/libs/user/src/iosMain/kotlin/dev/yaseyo/user/UserDependencies.kt new file mode 100644 index 0000000..b850894 --- /dev/null +++ b/libs/user/src/iosMain/kotlin/dev/yaseyo/user/UserDependencies.kt @@ -0,0 +1,6 @@ +package dev.yaseyo.user + +import dev.yaseyo.user.api.UserRepository +import org.koin.mp.KoinPlatform + +fun getUserRepository(): UserRepository = KoinPlatform.getKoin().get() From c5388bc880ce1643b6dcedef3d3d0182659bbc82 Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 20 Apr 2026 19:41:12 +0900 Subject: [PATCH 08/33] Add Claude skill for creating and working with UseCase classes --- .claude/skills/creating-use-case/SKILL.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .claude/skills/creating-use-case/SKILL.md diff --git a/.claude/skills/creating-use-case/SKILL.md b/.claude/skills/creating-use-case/SKILL.md new file mode 100644 index 0000000..e2e7546 --- /dev/null +++ b/.claude/skills/creating-use-case/SKILL.md @@ -0,0 +1,16 @@ +--- +name: working-with-use-cases +description: This skill is to instruct you how to go creating or working with UseCase classes +--- + +# How to teach me or explain to me + +## Instructions +- Should only be made when 2 or more repositories/data sources are needed +- Should have 1 function, `execute` in them +- Usages of the UseCase should have useCase as a suffix +- Ensure unit tests are updated or implemented when working with UseCases +- Should be platform-agnostic +- Should have KDocs written for them to better explain + +## Examples \ No newline at end of file From 40014387131e1bf50e6334596c74073dea5f9fe6 Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 20 Apr 2026 19:44:46 +0900 Subject: [PATCH 09/33] Add repository-layer-design Claude skill --- .claude/skills/repository-layer-design/SKILL.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .claude/skills/repository-layer-design/SKILL.md diff --git a/.claude/skills/repository-layer-design/SKILL.md b/.claude/skills/repository-layer-design/SKILL.md new file mode 100644 index 0000000..de72841 --- /dev/null +++ b/.claude/skills/repository-layer-design/SKILL.md @@ -0,0 +1,15 @@ +--- +name: repository-layer-design +description: This skill is to instruct you how to create or work with Repository layer classes +--- + +# How to teach me or explain to me + +## Instructions +- Should be framework and platform-agnostic +- Implementations should have `Impl` as a suffix +- Ensure getters are made for iOS in iOS main as these are Koin components +- Ensure test coverage is sufficient +- KDoc the contract for better understanding, the KDoc should not explicitly point to implementation details, but rather just simply explain the purpose of the various APIs + +## Examples \ No newline at end of file From e4e6b475a3d85589e0a2c66b6283a6cc9b6fcad1 Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 20 Apr 2026 19:48:49 +0900 Subject: [PATCH 10/33] Add presentation layer design skill for Claude tool documentation --- .claude/skills/presentation-layer-design/SKILL.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .claude/skills/presentation-layer-design/SKILL.md diff --git a/.claude/skills/presentation-layer-design/SKILL.md b/.claude/skills/presentation-layer-design/SKILL.md new file mode 100644 index 0000000..e88b660 --- /dev/null +++ b/.claude/skills/presentation-layer-design/SKILL.md @@ -0,0 +1,14 @@ +--- +name: presentation-layer-design +description: This skill is to instruct you how to go about designing and implementing the presentation layer +--- + +# How to teach me or explain to me + +## Instructions +- The project may have instances of shared Presenters or opt to go for platform specific presenters. This is decided on case by case basis +- Where possible, data sources should be owned by the repository or UseCase layer, not sourced from the Presenter +- For init logic, always go for Flow.onStart for KMP Presenters +- Inject Dispatchers from the Coroutines module to ensure testability + +## Examples \ No newline at end of file From a75ae8847ec0c6f293757f24b56a4eea1091db22 Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 20 Apr 2026 19:50:36 +0900 Subject: [PATCH 11/33] Add feature:onboarding:presentation module and implement presenters --- .../onboarding/presentation/build.gradle.kts | 56 ++++++++++++ .../onboarding/presentation/AuthPresenter.kt | 66 ++++++++++++++ .../OnboardingPresentationModule.kt | 10 +++ .../presentation/ProfileSetupPresenter.kt | 85 +++++++++++++++++++ .../presentation/SplashPresenter.kt | 23 +++++ .../presentation/OnboardingDependencies.kt | 9 ++ gradle/libs.versions.toml | 1 + libs/di/build.gradle.kts | 2 + .../kotlin/dev/yaseyo/di/AppModules.kt | 2 + settings.gradle.kts | 1 + 10 files changed, 255 insertions(+) create mode 100644 feature/onboarding/presentation/build.gradle.kts create mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/AuthPresenter.kt create mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingPresentationModule.kt create mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/ProfileSetupPresenter.kt create mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/SplashPresenter.kt create mode 100644 feature/onboarding/presentation/src/iosMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingDependencies.kt diff --git a/feature/onboarding/presentation/build.gradle.kts b/feature/onboarding/presentation/build.gradle.kts new file mode 100644 index 0000000..9b868be --- /dev/null +++ b/feature/onboarding/presentation/build.gradle.kts @@ -0,0 +1,56 @@ +plugins { + id("yaseyo.kmp.library") +} + +kotlin { + androidLibrary { + namespace = "dev.yaseyo.onboarding.presentation" + + withHostTestBuilder { + } + + withDeviceTestBuilder { + sourceSetTreeName = "test" + }.configure { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + } + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + api(projects.libs.presentation) + implementation(libs.koin.core) + implementation(libs.koin.core.viewmodel) + implementation(projects.feature.onboarding.domain) + implementation(projects.libs.auth.api) + implementation(projects.libs.user.api) + } + } + + commonTest { + dependencies { + implementation(libs.kotlin.test) + } + } + + androidMain { + dependencies { + } + } + + getByName("androidDeviceTest") { + dependencies { + implementation(libs.androidx.runner) + implementation(libs.androidx.core) + implementation(libs.androidx.testExt.junit) + } + } + + iosMain { + dependencies { + } + } + } +} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/AuthPresenter.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/AuthPresenter.kt new file mode 100644 index 0000000..f34c708 --- /dev/null +++ b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/AuthPresenter.kt @@ -0,0 +1,66 @@ +package dev.yaseyo.onboarding.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.yaseyo.auth.api.AuthRepository +import dev.yaseyo.onboarding.domain.AppState +import dev.yaseyo.onboarding.domain.CheckAppStateUseCase +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +sealed interface AuthUiState { + data object Idle : AuthUiState + + data object Loading : AuthUiState + + data class Success( + val appState: AppState, + ) : AuthUiState + + data class Error( + val message: String, + ) : AuthUiState +} + +class AuthPresenter( + private val authRepository: AuthRepository, + private val checkAppState: CheckAppStateUseCase, +) : ViewModel() { + private val _state = MutableStateFlow(AuthUiState.Idle) + val state: StateFlow = _state + + fun signIn( + email: String, + password: String, + ) { + viewModelScope.launch { + _state.value = AuthUiState.Loading + authRepository + .signInWithEmailAndPassword(email, password) + .fold( + onSuccess = { _state.value = AuthUiState.Success(checkAppState()) }, + onFailure = { _state.value = AuthUiState.Error(it.message ?: "Sign in failed") }, + ) + } + } + + fun signUp( + email: String, + password: String, + ) { + viewModelScope.launch { + _state.value = AuthUiState.Loading + authRepository + .signUpWithEmailAndPassword(email, password) + .fold( + onSuccess = { _state.value = AuthUiState.Success(checkAppState()) }, + onFailure = { _state.value = AuthUiState.Error(it.message ?: "Sign up failed") }, + ) + } + } + + fun clearError() { + _state.value = AuthUiState.Idle + } +} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingPresentationModule.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingPresentationModule.kt new file mode 100644 index 0000000..28d67b9 --- /dev/null +++ b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingPresentationModule.kt @@ -0,0 +1,10 @@ +package dev.yaseyo.onboarding.presentation + +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val onboardingPresentationModule = module { + viewModel { SplashPresenter(get()) } + viewModel { AuthPresenter(get(), get()) } + viewModel { ProfileSetupPresenter(get()) } +} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/ProfileSetupPresenter.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/ProfileSetupPresenter.kt new file mode 100644 index 0000000..5b11841 --- /dev/null +++ b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/ProfileSetupPresenter.kt @@ -0,0 +1,85 @@ +package dev.yaseyo.onboarding.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.yaseyo.onboarding.domain.OnboardingProfileDraft +import dev.yaseyo.user.api.ActivityLevel +import dev.yaseyo.user.api.Sex +import dev.yaseyo.user.api.UserRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDate + +enum class ProfileSetupStep { PersonalInfo, Goals } + +data class ProfileSetupUiState( + val draft: OnboardingProfileDraft = OnboardingProfileDraft(), + val step: ProfileSetupStep = ProfileSetupStep.PersonalInfo, + val isLoading: Boolean = false, + val error: String? = null, + val isComplete: Boolean = false, +) + +class ProfileSetupPresenter( + private val userRepository: UserRepository, +) : ViewModel() { + private val _state = MutableStateFlow(ProfileSetupUiState()) + val state: StateFlow = _state + + fun updateName(value: String) = _state.update { it.copy(draft = it.draft.copy(name = value)) } + + fun updateSex(value: Sex) = _state.update { it.copy(draft = it.draft.copy(sex = value)) } + + fun updateHeight(value: Double) = _state.update { it.copy(draft = it.draft.copy(height = value)) } + + fun updateDateOfBirth(value: LocalDate) = _state.update { it.copy(draft = it.draft.copy(dateOfBirth = value)) } + + fun updateActivityLevel(value: ActivityLevel) = _state.update { it.copy(draft = it.draft.copy(activityLevel = value)) } + + fun updateTargetWeightKg(value: Double) = _state.update { it.copy(draft = it.draft.copy(targetWeightKg = value)) } + + fun updateDailyDeficitKcal(value: Double) = _state.update { it.copy(draft = it.draft.copy(dailyDeficitKcal = value)) } + + fun updateTargetProtein(value: Double) = _state.update { it.copy(draft = it.draft.copy(targetProtein = value)) } + + fun updateTargetCarbs(value: Double) = _state.update { it.copy(draft = it.draft.copy(targetCarbs = value)) } + + fun updateTargetFat(value: Double) = _state.update { it.copy(draft = it.draft.copy(targetFat = value)) } + + fun nextStep() { + _state.update { it.copy(step = ProfileSetupStep.Goals) } + } + + fun previousStep() { + _state.update { it.copy(step = ProfileSetupStep.PersonalInfo) } + } + + fun submit() { + val draft = _state.value.draft + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + userRepository + .createUser( + name = draft.name, + sex = draft.sex ?: return@launch, + height = draft.height ?: return@launch, + dateOfBirth = draft.dateOfBirth ?: return@launch, + activityLevel = draft.activityLevel ?: return@launch, + targetWeightKg = draft.targetWeightKg ?: return@launch, + dailyDeficitKcal = draft.dailyDeficitKcal ?: return@launch, + targetProtein = draft.targetProtein ?: return@launch, + targetCarbs = draft.targetCarbs ?: return@launch, + targetFat = draft.targetFat ?: return@launch, + ).fold( + onSuccess = { _state.update { it.copy(isLoading = false, isComplete = true) } }, + onFailure = { e -> _state.update { it.copy(isLoading = false, error = e.message ?: "Profile creation failed") } }, + ) + } + } + + fun clearError() { + _state.update { it.copy(error = null) } + } +} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/SplashPresenter.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/SplashPresenter.kt new file mode 100644 index 0000000..1bd7f41 --- /dev/null +++ b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/SplashPresenter.kt @@ -0,0 +1,23 @@ +package dev.yaseyo.onboarding.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.yaseyo.onboarding.domain.AppState +import dev.yaseyo.onboarding.domain.CheckAppStateUseCase +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class SplashPresenter( + private val checkAppState: CheckAppStateUseCase, +) : ViewModel() { + val destination: StateFlow = emptyFlow() + .onStart { emit(checkAppState()) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = null, + ) +} diff --git a/feature/onboarding/presentation/src/iosMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingDependencies.kt b/feature/onboarding/presentation/src/iosMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingDependencies.kt new file mode 100644 index 0000000..19945c5 --- /dev/null +++ b/feature/onboarding/presentation/src/iosMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingDependencies.kt @@ -0,0 +1,9 @@ +package dev.yaseyo.onboarding.presentation + +import org.koin.mp.KoinPlatform + +fun getSplashPresenter(): SplashPresenter = KoinPlatform.getKoin().get() + +fun getAuthPresenter(): AuthPresenter = KoinPlatform.getKoin().get() + +fun getProfileSetupPresenter(): ProfileSetupPresenter = KoinPlatform.getKoin().get() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 936ab21..d0c05b9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -52,6 +52,7 @@ firebase-auth = { module = "dev.gitlive:firebase-auth", version.ref = "firebase- firebase-android-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-android-bom" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } +koin-core-viewmodel = { module = "io.insert-koin:koin-core-viewmodel", version.ref = "koin" } androidx-startup-runtime = { module = "androidx.startup:startup-runtime", version.ref = "androidx-startup" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } diff --git a/libs/di/build.gradle.kts b/libs/di/build.gradle.kts index e6a423d..ba0f5b6 100644 --- a/libs/di/build.gradle.kts +++ b/libs/di/build.gradle.kts @@ -22,6 +22,7 @@ kotlin { export(projects.libs.auth.api) export(projects.libs.user) export(projects.libs.presentation) + export(projects.feature.onboarding.presentation) } } @@ -41,6 +42,7 @@ kotlin { // Features implementation(projects.feature.onboarding.data) + api(projects.feature.onboarding.presentation) } } diff --git a/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt index b613de1..7f8bbef 100644 --- a/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt +++ b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt @@ -4,6 +4,7 @@ import dev.yaseyo.auth.di.authModule import dev.yaseyo.coroutines.coroutinesModule import dev.yaseyo.network.networkModule import dev.yaseyo.onboarding.data.onboardingDataModule +import dev.yaseyo.onboarding.presentation.onboardingPresentationModule import dev.yaseyo.user.di.userModule import org.koin.dsl.module @@ -14,4 +15,5 @@ internal val appModules = listOf( module { single { networkConfig } }, networkModule, onboardingDataModule, + onboardingPresentationModule, ) diff --git a/settings.gradle.kts b/settings.gradle.kts index e2e9a91..266079a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,3 +45,4 @@ include(":libs:di") include(":libs:presentation") include(":feature:onboarding:domain") include(":feature:onboarding:data") +include(":feature:onboarding:presentation") From 8f668954a06e8bc62e84450245d0866e5af66c7d Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 20 Apr 2026 19:53:57 +0900 Subject: [PATCH 12/33] Update presentation layer design guidelines for component structure and architecture --- .claude/skills/presentation-layer-design/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.claude/skills/presentation-layer-design/SKILL.md b/.claude/skills/presentation-layer-design/SKILL.md index e88b660..82284e6 100644 --- a/.claude/skills/presentation-layer-design/SKILL.md +++ b/.claude/skills/presentation-layer-design/SKILL.md @@ -7,6 +7,8 @@ description: This skill is to instruct you how to go about designing and impleme ## Instructions - The project may have instances of shared Presenters or opt to go for platform specific presenters. This is decided on case by case basis +- The presentation layer should always consist of the Presenter, UiState, EventHandler interface (which is implemented by the ViewModel/Presenter) and UiAction for side effects triggered by Presenter. +- Presentation components should be in different files - Where possible, data sources should be owned by the repository or UseCase layer, not sourced from the Presenter - For init logic, always go for Flow.onStart for KMP Presenters - Inject Dispatchers from the Coroutines module to ensure testability From aaf5298559720b6a433917a44725f20643bcf3ee Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Apr 2026 11:51:58 +0900 Subject: [PATCH 13/33] Refactor onboarding presentation layer to use ViewModels and MVI-style patterns - Rename `SplashPresenter`, `AuthPresenter`, and `ProfileSetupPresenter` to `SplashViewModel`, `AuthViewModel`, and `ProfileSetupViewModel` - Implement `EventHandler` interfaces and `UiAction` channels for navigation and side effects - Decouple presentation components into dedicated files and packages - Integrate `DispatcherProvider` for explicit coroutine context management - Remove `libs:presentation` module and consolidate dependencies in `:feature:onboarding:presentation` - Update Koin modules and iOS dependency injection helpers to reflect renaming --- .../skills/presentation-layer-design/SKILL.md | 2 +- .../onboarding/presentation/build.gradle.kts | 4 +- .../OnboardingPresentationModule.kt | 9 +- .../presentation/ProfileSetupPresenter.kt | 85 ------------------- .../presentation/SplashPresenter.kt | 23 ----- .../presentation/auth/AuthEventHandler.kt | 15 ++++ .../presentation/auth/AuthUiAction.kt | 9 ++ .../presentation/auth/AuthUiState.kt | 11 +++ .../AuthViewModel.kt} | 44 +++++----- .../profile/ProfileSetupEventHandler.kt | 35 ++++++++ .../profile/ProfileSetupUiAction.kt | 5 ++ .../profile/ProfileSetupUiState.kt | 12 +++ .../profile/ProfileSetupViewModel.kt | 82 ++++++++++++++++++ .../presentation/splash/SplashUiAction.kt | 9 ++ .../presentation/splash/SplashUiState.kt | 5 ++ .../presentation/splash/SplashViewModel.kt | 34 ++++++++ .../presentation/OnboardingDependencies.kt | 9 +- libs/di/build.gradle.kts | 2 - libs/presentation/build.gradle.kts | 51 ----------- settings.gradle.kts | 1 - 20 files changed, 252 insertions(+), 195 deletions(-) delete mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/ProfileSetupPresenter.kt delete mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/SplashPresenter.kt create mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthEventHandler.kt create mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthUiAction.kt create mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthUiState.kt rename feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/{AuthPresenter.kt => auth/AuthViewModel.kt} (60%) create mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupEventHandler.kt create mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupUiAction.kt create mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupUiState.kt create mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupViewModel.kt create mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashUiAction.kt create mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashUiState.kt create mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashViewModel.kt delete mode 100644 libs/presentation/build.gradle.kts diff --git a/.claude/skills/presentation-layer-design/SKILL.md b/.claude/skills/presentation-layer-design/SKILL.md index 82284e6..8289472 100644 --- a/.claude/skills/presentation-layer-design/SKILL.md +++ b/.claude/skills/presentation-layer-design/SKILL.md @@ -7,7 +7,7 @@ description: This skill is to instruct you how to go about designing and impleme ## Instructions - The project may have instances of shared Presenters or opt to go for platform specific presenters. This is decided on case by case basis -- The presentation layer should always consist of the Presenter, UiState, EventHandler interface (which is implemented by the ViewModel/Presenter) and UiAction for side effects triggered by Presenter. +- The presentation layer should always consist of the Presenter, UiState, EventHandler interface (which is implemented by the ViewModel/Presenter) and UiAction for side effects triggered by Presenter. This has one exception, and that is for cases where the logic does not have enough complexity to warrant this. Eg. just one side effect is not enough to create a UiAction or just one state is not enough to create a UiState - Presentation components should be in different files - Where possible, data sources should be owned by the repository or UseCase layer, not sourced from the Presenter - For init logic, always go for Flow.onStart for KMP Presenters diff --git a/feature/onboarding/presentation/build.gradle.kts b/feature/onboarding/presentation/build.gradle.kts index 9b868be..762dad1 100644 --- a/feature/onboarding/presentation/build.gradle.kts +++ b/feature/onboarding/presentation/build.gradle.kts @@ -20,12 +20,14 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) - api(projects.libs.presentation) + api(libs.androidx.lifecycle.viewmodel) + api(libs.kotlinx.coroutines.core) implementation(libs.koin.core) implementation(libs.koin.core.viewmodel) implementation(projects.feature.onboarding.domain) implementation(projects.libs.auth.api) implementation(projects.libs.user.api) + implementation(projects.libs.coroutines) } } diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingPresentationModule.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingPresentationModule.kt index 28d67b9..c49ce45 100644 --- a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingPresentationModule.kt +++ b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingPresentationModule.kt @@ -1,10 +1,13 @@ package dev.yaseyo.onboarding.presentation +import dev.yaseyo.onboarding.presentation.auth.AuthViewModel +import dev.yaseyo.onboarding.presentation.profile.ProfileSetupViewModel +import dev.yaseyo.onboarding.presentation.splash.SplashViewModel import org.koin.core.module.dsl.viewModel import org.koin.dsl.module val onboardingPresentationModule = module { - viewModel { SplashPresenter(get()) } - viewModel { AuthPresenter(get(), get()) } - viewModel { ProfileSetupPresenter(get()) } + viewModel { SplashViewModel(get(), get()) } + viewModel { AuthViewModel(get(), get(), get()) } + viewModel { ProfileSetupViewModel(get(), get()) } } diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/ProfileSetupPresenter.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/ProfileSetupPresenter.kt deleted file mode 100644 index 5b11841..0000000 --- a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/ProfileSetupPresenter.kt +++ /dev/null @@ -1,85 +0,0 @@ -package dev.yaseyo.onboarding.presentation - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dev.yaseyo.onboarding.domain.OnboardingProfileDraft -import dev.yaseyo.user.api.ActivityLevel -import dev.yaseyo.user.api.Sex -import dev.yaseyo.user.api.UserRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.datetime.LocalDate - -enum class ProfileSetupStep { PersonalInfo, Goals } - -data class ProfileSetupUiState( - val draft: OnboardingProfileDraft = OnboardingProfileDraft(), - val step: ProfileSetupStep = ProfileSetupStep.PersonalInfo, - val isLoading: Boolean = false, - val error: String? = null, - val isComplete: Boolean = false, -) - -class ProfileSetupPresenter( - private val userRepository: UserRepository, -) : ViewModel() { - private val _state = MutableStateFlow(ProfileSetupUiState()) - val state: StateFlow = _state - - fun updateName(value: String) = _state.update { it.copy(draft = it.draft.copy(name = value)) } - - fun updateSex(value: Sex) = _state.update { it.copy(draft = it.draft.copy(sex = value)) } - - fun updateHeight(value: Double) = _state.update { it.copy(draft = it.draft.copy(height = value)) } - - fun updateDateOfBirth(value: LocalDate) = _state.update { it.copy(draft = it.draft.copy(dateOfBirth = value)) } - - fun updateActivityLevel(value: ActivityLevel) = _state.update { it.copy(draft = it.draft.copy(activityLevel = value)) } - - fun updateTargetWeightKg(value: Double) = _state.update { it.copy(draft = it.draft.copy(targetWeightKg = value)) } - - fun updateDailyDeficitKcal(value: Double) = _state.update { it.copy(draft = it.draft.copy(dailyDeficitKcal = value)) } - - fun updateTargetProtein(value: Double) = _state.update { it.copy(draft = it.draft.copy(targetProtein = value)) } - - fun updateTargetCarbs(value: Double) = _state.update { it.copy(draft = it.draft.copy(targetCarbs = value)) } - - fun updateTargetFat(value: Double) = _state.update { it.copy(draft = it.draft.copy(targetFat = value)) } - - fun nextStep() { - _state.update { it.copy(step = ProfileSetupStep.Goals) } - } - - fun previousStep() { - _state.update { it.copy(step = ProfileSetupStep.PersonalInfo) } - } - - fun submit() { - val draft = _state.value.draft - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - userRepository - .createUser( - name = draft.name, - sex = draft.sex ?: return@launch, - height = draft.height ?: return@launch, - dateOfBirth = draft.dateOfBirth ?: return@launch, - activityLevel = draft.activityLevel ?: return@launch, - targetWeightKg = draft.targetWeightKg ?: return@launch, - dailyDeficitKcal = draft.dailyDeficitKcal ?: return@launch, - targetProtein = draft.targetProtein ?: return@launch, - targetCarbs = draft.targetCarbs ?: return@launch, - targetFat = draft.targetFat ?: return@launch, - ).fold( - onSuccess = { _state.update { it.copy(isLoading = false, isComplete = true) } }, - onFailure = { e -> _state.update { it.copy(isLoading = false, error = e.message ?: "Profile creation failed") } }, - ) - } - } - - fun clearError() { - _state.update { it.copy(error = null) } - } -} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/SplashPresenter.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/SplashPresenter.kt deleted file mode 100644 index 1bd7f41..0000000 --- a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/SplashPresenter.kt +++ /dev/null @@ -1,23 +0,0 @@ -package dev.yaseyo.onboarding.presentation - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dev.yaseyo.onboarding.domain.AppState -import dev.yaseyo.onboarding.domain.CheckAppStateUseCase -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn - -class SplashPresenter( - private val checkAppState: CheckAppStateUseCase, -) : ViewModel() { - val destination: StateFlow = emptyFlow() - .onStart { emit(checkAppState()) } - .stateIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = null, - ) -} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthEventHandler.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthEventHandler.kt new file mode 100644 index 0000000..b5cd232 --- /dev/null +++ b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthEventHandler.kt @@ -0,0 +1,15 @@ +package dev.yaseyo.onboarding.presentation.auth + +interface AuthEventHandler { + fun signIn( + email: String, + password: String, + ) + + fun signUp( + email: String, + password: String, + ) + + fun clearError() +} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthUiAction.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthUiAction.kt new file mode 100644 index 0000000..9553205 --- /dev/null +++ b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthUiAction.kt @@ -0,0 +1,9 @@ +package dev.yaseyo.onboarding.presentation.auth + +import dev.yaseyo.onboarding.domain.AppState + +sealed interface AuthUiAction { + data class NavigateTo( + val destination: AppState, + ) : AuthUiAction +} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthUiState.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthUiState.kt new file mode 100644 index 0000000..2b2ea61 --- /dev/null +++ b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthUiState.kt @@ -0,0 +1,11 @@ +package dev.yaseyo.onboarding.presentation.auth + +sealed interface AuthUiState { + data object Idle : AuthUiState + + data object Loading : AuthUiState + + data class Error( + val message: String, + ) : AuthUiState +} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/AuthPresenter.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthViewModel.kt similarity index 60% rename from feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/AuthPresenter.kt rename to feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthViewModel.kt index f34c708..94018a4 100644 --- a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/AuthPresenter.kt +++ b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthViewModel.kt @@ -1,66 +1,60 @@ -package dev.yaseyo.onboarding.presentation +package dev.yaseyo.onboarding.presentation.auth import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.yaseyo.auth.api.AuthRepository -import dev.yaseyo.onboarding.domain.AppState +import dev.yaseyo.coroutines.DispatcherProvider import dev.yaseyo.onboarding.domain.CheckAppStateUseCase +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch -sealed interface AuthUiState { - data object Idle : AuthUiState - - data object Loading : AuthUiState - - data class Success( - val appState: AppState, - ) : AuthUiState - - data class Error( - val message: String, - ) : AuthUiState -} - -class AuthPresenter( +class AuthViewModel( private val authRepository: AuthRepository, private val checkAppState: CheckAppStateUseCase, -) : ViewModel() { + private val dispatchers: DispatcherProvider, +) : ViewModel(), + AuthEventHandler { private val _state = MutableStateFlow(AuthUiState.Idle) val state: StateFlow = _state - fun signIn( + private val _actions = Channel(Channel.BUFFERED) + val actions: Flow = _actions.receiveAsFlow() + + override fun signIn( email: String, password: String, ) { - viewModelScope.launch { + viewModelScope.launch(dispatchers.io) { _state.value = AuthUiState.Loading authRepository .signInWithEmailAndPassword(email, password) .fold( - onSuccess = { _state.value = AuthUiState.Success(checkAppState()) }, + onSuccess = { _actions.send(AuthUiAction.NavigateTo(checkAppState())) }, onFailure = { _state.value = AuthUiState.Error(it.message ?: "Sign in failed") }, ) } } - fun signUp( + override fun signUp( email: String, password: String, ) { - viewModelScope.launch { + viewModelScope.launch(dispatchers.io) { _state.value = AuthUiState.Loading authRepository .signUpWithEmailAndPassword(email, password) .fold( - onSuccess = { _state.value = AuthUiState.Success(checkAppState()) }, + onSuccess = { _actions.send(AuthUiAction.NavigateTo(checkAppState())) }, onFailure = { _state.value = AuthUiState.Error(it.message ?: "Sign up failed") }, ) } } - fun clearError() { + override fun clearError() { _state.value = AuthUiState.Idle } } diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupEventHandler.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupEventHandler.kt new file mode 100644 index 0000000..580b16b --- /dev/null +++ b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupEventHandler.kt @@ -0,0 +1,35 @@ +package dev.yaseyo.onboarding.presentation.profile + +import dev.yaseyo.user.api.ActivityLevel +import dev.yaseyo.user.api.Sex +import kotlinx.datetime.LocalDate + +interface ProfileSetupEventHandler { + fun updateName(value: String) + + fun updateSex(value: Sex) + + fun updateHeight(value: Double) + + fun updateDateOfBirth(value: LocalDate) + + fun updateActivityLevel(value: ActivityLevel) + + fun updateTargetWeightKg(value: Double) + + fun updateDailyDeficitKcal(value: Double) + + fun updateTargetProtein(value: Double) + + fun updateTargetCarbs(value: Double) + + fun updateTargetFat(value: Double) + + fun nextStep() + + fun previousStep() + + fun submit() + + fun clearError() +} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupUiAction.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupUiAction.kt new file mode 100644 index 0000000..865338e --- /dev/null +++ b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupUiAction.kt @@ -0,0 +1,5 @@ +package dev.yaseyo.onboarding.presentation.profile + +sealed interface ProfileSetupUiAction { + data object NavigateToHome : ProfileSetupUiAction +} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupUiState.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupUiState.kt new file mode 100644 index 0000000..0528c7e --- /dev/null +++ b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupUiState.kt @@ -0,0 +1,12 @@ +package dev.yaseyo.onboarding.presentation.profile + +import dev.yaseyo.onboarding.domain.OnboardingProfileDraft + +enum class ProfileSetupStep { PersonalInfo, Goals } + +data class ProfileSetupUiState( + val draft: OnboardingProfileDraft = OnboardingProfileDraft(), + val step: ProfileSetupStep = ProfileSetupStep.PersonalInfo, + val isLoading: Boolean = false, + val error: String? = null, +) diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupViewModel.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupViewModel.kt new file mode 100644 index 0000000..90fb98b --- /dev/null +++ b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupViewModel.kt @@ -0,0 +1,82 @@ +package dev.yaseyo.onboarding.presentation.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.yaseyo.coroutines.DispatcherProvider +import dev.yaseyo.user.api.ActivityLevel +import dev.yaseyo.user.api.Sex +import dev.yaseyo.user.api.UserRepository +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDate + +class ProfileSetupViewModel( + private val userRepository: UserRepository, + private val dispatchers: DispatcherProvider, +) : ViewModel(), + ProfileSetupEventHandler { + private val _state = MutableStateFlow(ProfileSetupUiState()) + val state: StateFlow = _state + + private val _actions = Channel(Channel.BUFFERED) + val actions: Flow = _actions.receiveAsFlow() + + override fun updateName(value: String) = _state.update { it.copy(draft = it.draft.copy(name = value)) } + + override fun updateSex(value: Sex) = _state.update { it.copy(draft = it.draft.copy(sex = value)) } + + override fun updateHeight(value: Double) = _state.update { it.copy(draft = it.draft.copy(height = value)) } + + override fun updateDateOfBirth(value: LocalDate) = _state.update { it.copy(draft = it.draft.copy(dateOfBirth = value)) } + + override fun updateActivityLevel(value: ActivityLevel) = _state.update { it.copy(draft = it.draft.copy(activityLevel = value)) } + + override fun updateTargetWeightKg(value: Double) = _state.update { it.copy(draft = it.draft.copy(targetWeightKg = value)) } + + override fun updateDailyDeficitKcal(value: Double) = _state.update { it.copy(draft = it.draft.copy(dailyDeficitKcal = value)) } + + override fun updateTargetProtein(value: Double) = _state.update { it.copy(draft = it.draft.copy(targetProtein = value)) } + + override fun updateTargetCarbs(value: Double) = _state.update { it.copy(draft = it.draft.copy(targetCarbs = value)) } + + override fun updateTargetFat(value: Double) = _state.update { it.copy(draft = it.draft.copy(targetFat = value)) } + + override fun nextStep() = _state.update { it.copy(step = ProfileSetupStep.Goals) } + + override fun previousStep() = _state.update { it.copy(step = ProfileSetupStep.PersonalInfo) } + + override fun submit() { + val draft = _state.value.draft + viewModelScope.launch(dispatchers.io) { + _state.update { it.copy(isLoading = true, error = null) } + userRepository + .createUser( + name = draft.name, + sex = draft.sex ?: return@launch, + height = draft.height ?: return@launch, + dateOfBirth = draft.dateOfBirth ?: return@launch, + activityLevel = draft.activityLevel ?: return@launch, + targetWeightKg = draft.targetWeightKg ?: return@launch, + dailyDeficitKcal = draft.dailyDeficitKcal ?: return@launch, + targetProtein = draft.targetProtein ?: return@launch, + targetCarbs = draft.targetCarbs ?: return@launch, + targetFat = draft.targetFat ?: return@launch, + ).fold( + onSuccess = { + _state.update { it.copy(isLoading = false) } + _actions.send(ProfileSetupUiAction.NavigateToHome) + }, + onFailure = { e -> + _state.update { it.copy(isLoading = false, error = e.message ?: "Profile creation failed") } + }, + ) + } + } + + override fun clearError() = _state.update { it.copy(error = null) } +} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashUiAction.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashUiAction.kt new file mode 100644 index 0000000..a8aa131 --- /dev/null +++ b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashUiAction.kt @@ -0,0 +1,9 @@ +package dev.yaseyo.onboarding.presentation.splash + +import dev.yaseyo.onboarding.domain.AppState + +sealed interface SplashUiAction { + data class NavigateTo( + val destination: AppState, + ) : SplashUiAction +} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashUiState.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashUiState.kt new file mode 100644 index 0000000..383cf54 --- /dev/null +++ b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashUiState.kt @@ -0,0 +1,5 @@ +package dev.yaseyo.onboarding.presentation.splash + +data class SplashUiState( + val isLoading: Boolean = true, +) diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashViewModel.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashViewModel.kt new file mode 100644 index 0000000..18793e8 --- /dev/null +++ b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashViewModel.kt @@ -0,0 +1,34 @@ +package dev.yaseyo.onboarding.presentation.splash + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.yaseyo.coroutines.DispatcherProvider +import dev.yaseyo.onboarding.domain.CheckAppStateUseCase +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn + +class SplashViewModel( + private val checkAppState: CheckAppStateUseCase, + private val dispatchers: DispatcherProvider, +) : ViewModel() { + private val _actions = Channel(Channel.BUFFERED) + val actions: Flow = _actions.receiveAsFlow() + + val state: StateFlow = + emptyFlow() + .onStart { + emit(SplashUiState(isLoading = true)) + _actions.send(SplashUiAction.NavigateTo(checkAppState())) + emit(SplashUiState(isLoading = false)) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = SplashUiState(), + ) +} diff --git a/feature/onboarding/presentation/src/iosMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingDependencies.kt b/feature/onboarding/presentation/src/iosMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingDependencies.kt index 19945c5..2aa41da 100644 --- a/feature/onboarding/presentation/src/iosMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingDependencies.kt +++ b/feature/onboarding/presentation/src/iosMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingDependencies.kt @@ -1,9 +1,12 @@ package dev.yaseyo.onboarding.presentation +import dev.yaseyo.onboarding.presentation.auth.AuthViewModel +import dev.yaseyo.onboarding.presentation.profile.ProfileSetupViewModel +import dev.yaseyo.onboarding.presentation.splash.SplashViewModel import org.koin.mp.KoinPlatform -fun getSplashPresenter(): SplashPresenter = KoinPlatform.getKoin().get() +fun getSplashViewModel(): SplashViewModel = KoinPlatform.getKoin().get() -fun getAuthPresenter(): AuthPresenter = KoinPlatform.getKoin().get() +fun getAuthViewModel(): AuthViewModel = KoinPlatform.getKoin().get() -fun getProfileSetupPresenter(): ProfileSetupPresenter = KoinPlatform.getKoin().get() +fun getProfileSetupViewModel(): ProfileSetupViewModel = KoinPlatform.getKoin().get() diff --git a/libs/di/build.gradle.kts b/libs/di/build.gradle.kts index ba0f5b6..ce2a439 100644 --- a/libs/di/build.gradle.kts +++ b/libs/di/build.gradle.kts @@ -21,7 +21,6 @@ kotlin { target.binaries.withType().configureEach { export(projects.libs.auth.api) export(projects.libs.user) - export(projects.libs.presentation) export(projects.feature.onboarding.presentation) } } @@ -38,7 +37,6 @@ kotlin { implementation(projects.libs.auth) implementation(projects.libs.network) api(projects.libs.user) - api(projects.libs.presentation) // Features implementation(projects.feature.onboarding.data) diff --git a/libs/presentation/build.gradle.kts b/libs/presentation/build.gradle.kts deleted file mode 100644 index 9011a88..0000000 --- a/libs/presentation/build.gradle.kts +++ /dev/null @@ -1,51 +0,0 @@ -plugins { - id("yaseyo.kmp.library") -} - -kotlin { - androidLibrary { - namespace = "dev.yaseyo.presentation" - - withHostTestBuilder { - } - - withDeviceTestBuilder { - sourceSetTreeName = "test" - }.configure { - instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - } - - sourceSets { - commonMain { - dependencies { - api(libs.androidx.lifecycle.viewmodel) - api(libs.kotlinx.coroutines.core) - } - } - - commonTest { - dependencies { - implementation(libs.kotlin.test) - } - } - - androidMain { - dependencies { - } - } - - getByName("androidDeviceTest") { - dependencies { - implementation(libs.androidx.runner) - implementation(libs.androidx.core) - implementation(libs.androidx.testExt.junit) - } - } - - iosMain { - dependencies { - } - } - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 266079a..d61eeb1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -42,7 +42,6 @@ include(":libs:auth:impl") include(":libs:coroutines") include(":libs:network") include(":libs:di") -include(":libs:presentation") include(":feature:onboarding:domain") include(":feature:onboarding:data") include(":feature:onboarding:presentation") From 160a557ce46fc08ef691581fdd3542b394e36749 Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Apr 2026 13:12:51 +0900 Subject: [PATCH 14/33] Add AndroidX Navigation3 and Lifecycle Navigation3 dependencies --- composeApp/build.gradle.kts | 8 +++++++- gradle/libs.versions.toml | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ce94afc..4abef40 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -17,11 +17,17 @@ kotlin { sourceSets { androidMain.dependencies { + // Libs + implementation(projects.libs.di) + + // Dependencies + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.activity.compose) implementation(libs.koin.android) implementation(libs.koin.compose) - implementation(projects.libs.di) } commonMain.dependencies { implementation(libs.koin.core) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d0c05b9..e38f649 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,8 @@ ktlint = "1.5.0" ktor = "3.4.2" kotlinx-serialization = "1.11.0" kotlinx-datetime = "0.7.1" +nav3Core = "1.1.0" +lifecycleViewmodelNav3 = "2.11.0-alpha03" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -71,6 +73,9 @@ ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx ktor-testing = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } From 9e19004a8357402c59c0c1c88f73df186cf9ed10 Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Apr 2026 13:26:33 +0900 Subject: [PATCH 15/33] Reset --- .../onboarding/{data => api}/build.gradle.kts | 7 +- .../onboarding/data/OnboardingDataModule.kt | 13 --- .../dev/yaseyo/onboarding/domain/AppState.kt | 16 ---- .../onboarding/domain/CheckAppStateUseCase.kt | 23 ------ .../domain/OnboardingProfileDraft.kt | 18 ---- .../domain/CheckAppStateUseCaseTest.kt | 40 --------- .../onboarding/domain/FakeAuthRepository.kt | 26 ------ .../onboarding/domain/FakeUserRepository.kt | 58 ------------- .../{domain => impl}/build.gradle.kts | 7 +- .../onboarding/presentation/build.gradle.kts | 58 ------------- .../OnboardingPresentationModule.kt | 13 --- .../presentation/auth/AuthEventHandler.kt | 15 ---- .../presentation/auth/AuthUiAction.kt | 9 -- .../presentation/auth/AuthUiState.kt | 11 --- .../presentation/auth/AuthViewModel.kt | 60 -------------- .../profile/ProfileSetupEventHandler.kt | 35 -------- .../profile/ProfileSetupUiAction.kt | 5 -- .../profile/ProfileSetupUiState.kt | 12 --- .../profile/ProfileSetupViewModel.kt | 82 ------------------- .../presentation/splash/SplashUiAction.kt | 9 -- .../presentation/splash/SplashUiState.kt | 5 -- .../presentation/splash/SplashViewModel.kt | 34 -------- .../presentation/OnboardingDependencies.kt | 12 --- libs/di/build.gradle.kts | 6 +- settings.gradle.kts | 5 +- 25 files changed, 11 insertions(+), 568 deletions(-) rename feature/onboarding/{data => api}/build.gradle.kts (78%) delete mode 100644 feature/onboarding/data/src/commonMain/kotlin/dev/yaseyo/onboarding/data/OnboardingDataModule.kt delete mode 100644 feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/AppState.kt delete mode 100644 feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/CheckAppStateUseCase.kt delete mode 100644 feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/OnboardingProfileDraft.kt delete mode 100644 feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/CheckAppStateUseCaseTest.kt delete mode 100644 feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/FakeAuthRepository.kt delete mode 100644 feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/FakeUserRepository.kt rename feature/onboarding/{domain => impl}/build.gradle.kts (88%) delete mode 100644 feature/onboarding/presentation/build.gradle.kts delete mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingPresentationModule.kt delete mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthEventHandler.kt delete mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthUiAction.kt delete mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthUiState.kt delete mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthViewModel.kt delete mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupEventHandler.kt delete mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupUiAction.kt delete mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupUiState.kt delete mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupViewModel.kt delete mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashUiAction.kt delete mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashUiState.kt delete mode 100644 feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashViewModel.kt delete mode 100644 feature/onboarding/presentation/src/iosMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingDependencies.kt diff --git a/feature/onboarding/data/build.gradle.kts b/feature/onboarding/api/build.gradle.kts similarity index 78% rename from feature/onboarding/data/build.gradle.kts rename to feature/onboarding/api/build.gradle.kts index a760b50..88e50e4 100644 --- a/feature/onboarding/data/build.gradle.kts +++ b/feature/onboarding/api/build.gradle.kts @@ -4,7 +4,7 @@ plugins { kotlin { androidLibrary { - namespace = "dev.yaseyo.onboarding.data" + namespace = "dev.yaseyo.onboarding.api" withHostTestBuilder { } @@ -20,10 +20,7 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) - implementation(libs.koin.core) - implementation(projects.feature.onboarding.domain) - implementation(projects.libs.auth.api) - implementation(projects.libs.user.api) + api(libs.kotlinx.coroutines.core) } } diff --git a/feature/onboarding/data/src/commonMain/kotlin/dev/yaseyo/onboarding/data/OnboardingDataModule.kt b/feature/onboarding/data/src/commonMain/kotlin/dev/yaseyo/onboarding/data/OnboardingDataModule.kt deleted file mode 100644 index cdc7f41..0000000 --- a/feature/onboarding/data/src/commonMain/kotlin/dev/yaseyo/onboarding/data/OnboardingDataModule.kt +++ /dev/null @@ -1,13 +0,0 @@ -package dev.yaseyo.onboarding.data - -import dev.yaseyo.onboarding.domain.CheckAppStateUseCase -import org.koin.dsl.module - -val onboardingDataModule = module { - factory { - CheckAppStateUseCase( - authRepository = get(), - userRepository = get(), - ) - } -} diff --git a/feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/AppState.kt b/feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/AppState.kt deleted file mode 100644 index d024974..0000000 --- a/feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/AppState.kt +++ /dev/null @@ -1,16 +0,0 @@ -package dev.yaseyo.onboarding.domain - -/** - * Represents the initial destination the app should route to on launch, - * determined by [CheckAppStateUseCase]. - */ -sealed interface AppState { - /** No active authentication session — user must sign in or sign up. */ - data object Unauthenticated : AppState - - /** Signed in but no profile exists yet — user must complete the onboarding wizard. */ - data object RequiresProfileSetup : AppState - - /** Signed in with a complete profile — proceed directly to the main app. */ - data object FullySetup : AppState -} diff --git a/feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/CheckAppStateUseCase.kt b/feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/CheckAppStateUseCase.kt deleted file mode 100644 index 70c3250..0000000 --- a/feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/CheckAppStateUseCase.kt +++ /dev/null @@ -1,23 +0,0 @@ -package dev.yaseyo.onboarding.domain - -import dev.yaseyo.auth.api.AuthRepository -import dev.yaseyo.auth.api.AuthState -import dev.yaseyo.user.api.UserRepository - -class CheckAppStateUseCase( - private val authRepository: AuthRepository, - private val userRepository: UserRepository, -) { - suspend operator fun invoke(): AppState = - when (authRepository.authState.value) { - is AuthState.SignedOut -> AppState.Unauthenticated - is AuthState.SignedIn -> { - val user = userRepository.getCurrentUser() - if (user.isSuccess) { - AppState.FullySetup - } else { - AppState.RequiresProfileSetup - } - } - } -} diff --git a/feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/OnboardingProfileDraft.kt b/feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/OnboardingProfileDraft.kt deleted file mode 100644 index e8e8757..0000000 --- a/feature/onboarding/domain/src/commonMain/kotlin/dev/yaseyo/onboarding/domain/OnboardingProfileDraft.kt +++ /dev/null @@ -1,18 +0,0 @@ -package dev.yaseyo.onboarding.domain - -import dev.yaseyo.user.api.ActivityLevel -import dev.yaseyo.user.api.Sex -import kotlinx.datetime.LocalDate - -data class OnboardingProfileDraft( - val name: String = "", - val sex: Sex? = null, - val height: Double? = null, - val dateOfBirth: LocalDate? = null, - val activityLevel: ActivityLevel? = null, - val targetWeightKg: Double? = null, - val dailyDeficitKcal: Double? = null, - val targetProtein: Double? = null, - val targetCarbs: Double? = null, - val targetFat: Double? = null, -) diff --git a/feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/CheckAppStateUseCaseTest.kt b/feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/CheckAppStateUseCaseTest.kt deleted file mode 100644 index 0a0e332..0000000 --- a/feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/CheckAppStateUseCaseTest.kt +++ /dev/null @@ -1,40 +0,0 @@ -package dev.yaseyo.onboarding.domain - -import dev.yaseyo.auth.api.AuthState -import kotlinx.coroutines.runBlocking -import kotlin.test.Test -import kotlin.test.assertIs - -class CheckAppStateUseCaseTest { - private fun useCase( - authRepository: FakeAuthRepository = FakeAuthRepository(), - userRepository: FakeUserRepository = FakeUserRepository(), - ) = CheckAppStateUseCase(authRepository, userRepository) - - @Test - fun `returns Unauthenticated when signed out`(): Unit = - runBlocking { - val result = useCase(authRepository = FakeAuthRepository(AuthState.SignedOut)).invoke() - assertIs(result) - } - - @Test - fun `returns FullySetup when signed in and profile exists`(): Unit = - runBlocking { - val result = useCase( - authRepository = FakeAuthRepository(AuthState.SignedIn(userId = "usr_abc123")), - userRepository = FakeUserRepository(Result.success(FakeUserRepository.DEFAULT_USER)), - ).invoke() - assertIs(result) - } - - @Test - fun `returns RequiresProfileSetup when signed in but no profile exists`(): Unit = - runBlocking { - val result = useCase( - authRepository = FakeAuthRepository(AuthState.SignedIn(userId = "usr_abc123")), - userRepository = FakeUserRepository(Result.failure(RuntimeException("not found"))), - ).invoke() - assertIs(result) - } -} diff --git a/feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/FakeAuthRepository.kt b/feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/FakeAuthRepository.kt deleted file mode 100644 index bb3baa5..0000000 --- a/feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/FakeAuthRepository.kt +++ /dev/null @@ -1,26 +0,0 @@ -package dev.yaseyo.onboarding.domain - -import dev.yaseyo.auth.api.AuthRepository -import dev.yaseyo.auth.api.AuthState -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -internal class FakeAuthRepository( - authState: AuthState = AuthState.SignedOut, -) : AuthRepository { - override val authState: StateFlow = MutableStateFlow(authState) - - override suspend fun getIdToken(): String? = null - - override suspend fun signInWithEmailAndPassword( - email: String, - password: String, - ): Result = Result.success(Unit) - - override suspend fun signUpWithEmailAndPassword( - email: String, - password: String, - ): Result = Result.success(Unit) - - override suspend fun signOut() = Unit -} diff --git a/feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/FakeUserRepository.kt b/feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/FakeUserRepository.kt deleted file mode 100644 index 6760de8..0000000 --- a/feature/onboarding/domain/src/commonTest/kotlin/dev/yaseyo/onboarding/domain/FakeUserRepository.kt +++ /dev/null @@ -1,58 +0,0 @@ -package dev.yaseyo.onboarding.domain - -import dev.yaseyo.user.api.ActivityLevel -import dev.yaseyo.user.api.Sex -import dev.yaseyo.user.api.User -import dev.yaseyo.user.api.UserRepository -import kotlinx.datetime.Instant -import kotlinx.datetime.LocalDate - -internal class FakeUserRepository( - private val userResult: Result = Result.success(DEFAULT_USER), -) : UserRepository { - override suspend fun getCurrentUser(): Result = userResult - - override suspend fun createUser( - name: String, - sex: Sex, - height: Double, - dateOfBirth: LocalDate, - activityLevel: ActivityLevel, - targetWeightKg: Double, - dailyDeficitKcal: Double, - targetProtein: Double, - targetCarbs: Double, - targetFat: Double, - ): Result = userResult - - override suspend fun updateUser( - name: String?, - sex: Sex?, - height: Double?, - dateOfBirth: LocalDate?, - activityLevel: ActivityLevel?, - targetWeightKg: Double?, - dailyDeficitKcal: Double?, - targetProtein: Double?, - targetCarbs: Double?, - targetFat: Double?, - ): Result = userResult - - companion object { - val DEFAULT_USER = User( - id = "usr_abc123", - name = "Alex Smith", - sex = Sex.MALE, - height = 178.0, - dateOfBirth = LocalDate(1990, 6, 15), - activityLevel = ActivityLevel.LightlyActive, - targetWeightKg = 80.0, - dailyDeficitKcal = 500.0, - targetProtein = 160.0, - targetCarbs = 200.0, - targetFat = 70.0, - createdAt = Instant.parse("2024-01-10T08:00:00Z"), - updatedAt = Instant.parse("2024-03-20T14:30:00Z"), - ) - } -} diff --git a/feature/onboarding/domain/build.gradle.kts b/feature/onboarding/impl/build.gradle.kts similarity index 88% rename from feature/onboarding/domain/build.gradle.kts rename to feature/onboarding/impl/build.gradle.kts index 3691f72..4328c12 100644 --- a/feature/onboarding/domain/build.gradle.kts +++ b/feature/onboarding/impl/build.gradle.kts @@ -2,9 +2,11 @@ plugins { id("yaseyo.kmp.library") } +kmpLibrary {} + kotlin { androidLibrary { - namespace = "dev.yaseyo.onboarding.domain" + namespace = "dev.yaseyo.onboarding.impl" withHostTestBuilder { } @@ -20,9 +22,8 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(projects.feature.onboarding.api) implementation(libs.koin.core) - api(projects.libs.auth.api) - api(projects.libs.user.api) } } diff --git a/feature/onboarding/presentation/build.gradle.kts b/feature/onboarding/presentation/build.gradle.kts deleted file mode 100644 index 762dad1..0000000 --- a/feature/onboarding/presentation/build.gradle.kts +++ /dev/null @@ -1,58 +0,0 @@ -plugins { - id("yaseyo.kmp.library") -} - -kotlin { - androidLibrary { - namespace = "dev.yaseyo.onboarding.presentation" - - withHostTestBuilder { - } - - withDeviceTestBuilder { - sourceSetTreeName = "test" - }.configure { - instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - } - - sourceSets { - commonMain { - dependencies { - implementation(libs.kotlin.stdlib) - api(libs.androidx.lifecycle.viewmodel) - api(libs.kotlinx.coroutines.core) - implementation(libs.koin.core) - implementation(libs.koin.core.viewmodel) - implementation(projects.feature.onboarding.domain) - implementation(projects.libs.auth.api) - implementation(projects.libs.user.api) - implementation(projects.libs.coroutines) - } - } - - commonTest { - dependencies { - implementation(libs.kotlin.test) - } - } - - androidMain { - dependencies { - } - } - - getByName("androidDeviceTest") { - dependencies { - implementation(libs.androidx.runner) - implementation(libs.androidx.core) - implementation(libs.androidx.testExt.junit) - } - } - - iosMain { - dependencies { - } - } - } -} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingPresentationModule.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingPresentationModule.kt deleted file mode 100644 index c49ce45..0000000 --- a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingPresentationModule.kt +++ /dev/null @@ -1,13 +0,0 @@ -package dev.yaseyo.onboarding.presentation - -import dev.yaseyo.onboarding.presentation.auth.AuthViewModel -import dev.yaseyo.onboarding.presentation.profile.ProfileSetupViewModel -import dev.yaseyo.onboarding.presentation.splash.SplashViewModel -import org.koin.core.module.dsl.viewModel -import org.koin.dsl.module - -val onboardingPresentationModule = module { - viewModel { SplashViewModel(get(), get()) } - viewModel { AuthViewModel(get(), get(), get()) } - viewModel { ProfileSetupViewModel(get(), get()) } -} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthEventHandler.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthEventHandler.kt deleted file mode 100644 index b5cd232..0000000 --- a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthEventHandler.kt +++ /dev/null @@ -1,15 +0,0 @@ -package dev.yaseyo.onboarding.presentation.auth - -interface AuthEventHandler { - fun signIn( - email: String, - password: String, - ) - - fun signUp( - email: String, - password: String, - ) - - fun clearError() -} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthUiAction.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthUiAction.kt deleted file mode 100644 index 9553205..0000000 --- a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthUiAction.kt +++ /dev/null @@ -1,9 +0,0 @@ -package dev.yaseyo.onboarding.presentation.auth - -import dev.yaseyo.onboarding.domain.AppState - -sealed interface AuthUiAction { - data class NavigateTo( - val destination: AppState, - ) : AuthUiAction -} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthUiState.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthUiState.kt deleted file mode 100644 index 2b2ea61..0000000 --- a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthUiState.kt +++ /dev/null @@ -1,11 +0,0 @@ -package dev.yaseyo.onboarding.presentation.auth - -sealed interface AuthUiState { - data object Idle : AuthUiState - - data object Loading : AuthUiState - - data class Error( - val message: String, - ) : AuthUiState -} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthViewModel.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthViewModel.kt deleted file mode 100644 index 94018a4..0000000 --- a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/auth/AuthViewModel.kt +++ /dev/null @@ -1,60 +0,0 @@ -package dev.yaseyo.onboarding.presentation.auth - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dev.yaseyo.auth.api.AuthRepository -import dev.yaseyo.coroutines.DispatcherProvider -import dev.yaseyo.onboarding.domain.CheckAppStateUseCase -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch - -class AuthViewModel( - private val authRepository: AuthRepository, - private val checkAppState: CheckAppStateUseCase, - private val dispatchers: DispatcherProvider, -) : ViewModel(), - AuthEventHandler { - private val _state = MutableStateFlow(AuthUiState.Idle) - val state: StateFlow = _state - - private val _actions = Channel(Channel.BUFFERED) - val actions: Flow = _actions.receiveAsFlow() - - override fun signIn( - email: String, - password: String, - ) { - viewModelScope.launch(dispatchers.io) { - _state.value = AuthUiState.Loading - authRepository - .signInWithEmailAndPassword(email, password) - .fold( - onSuccess = { _actions.send(AuthUiAction.NavigateTo(checkAppState())) }, - onFailure = { _state.value = AuthUiState.Error(it.message ?: "Sign in failed") }, - ) - } - } - - override fun signUp( - email: String, - password: String, - ) { - viewModelScope.launch(dispatchers.io) { - _state.value = AuthUiState.Loading - authRepository - .signUpWithEmailAndPassword(email, password) - .fold( - onSuccess = { _actions.send(AuthUiAction.NavigateTo(checkAppState())) }, - onFailure = { _state.value = AuthUiState.Error(it.message ?: "Sign up failed") }, - ) - } - } - - override fun clearError() { - _state.value = AuthUiState.Idle - } -} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupEventHandler.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupEventHandler.kt deleted file mode 100644 index 580b16b..0000000 --- a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupEventHandler.kt +++ /dev/null @@ -1,35 +0,0 @@ -package dev.yaseyo.onboarding.presentation.profile - -import dev.yaseyo.user.api.ActivityLevel -import dev.yaseyo.user.api.Sex -import kotlinx.datetime.LocalDate - -interface ProfileSetupEventHandler { - fun updateName(value: String) - - fun updateSex(value: Sex) - - fun updateHeight(value: Double) - - fun updateDateOfBirth(value: LocalDate) - - fun updateActivityLevel(value: ActivityLevel) - - fun updateTargetWeightKg(value: Double) - - fun updateDailyDeficitKcal(value: Double) - - fun updateTargetProtein(value: Double) - - fun updateTargetCarbs(value: Double) - - fun updateTargetFat(value: Double) - - fun nextStep() - - fun previousStep() - - fun submit() - - fun clearError() -} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupUiAction.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupUiAction.kt deleted file mode 100644 index 865338e..0000000 --- a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupUiAction.kt +++ /dev/null @@ -1,5 +0,0 @@ -package dev.yaseyo.onboarding.presentation.profile - -sealed interface ProfileSetupUiAction { - data object NavigateToHome : ProfileSetupUiAction -} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupUiState.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupUiState.kt deleted file mode 100644 index 0528c7e..0000000 --- a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupUiState.kt +++ /dev/null @@ -1,12 +0,0 @@ -package dev.yaseyo.onboarding.presentation.profile - -import dev.yaseyo.onboarding.domain.OnboardingProfileDraft - -enum class ProfileSetupStep { PersonalInfo, Goals } - -data class ProfileSetupUiState( - val draft: OnboardingProfileDraft = OnboardingProfileDraft(), - val step: ProfileSetupStep = ProfileSetupStep.PersonalInfo, - val isLoading: Boolean = false, - val error: String? = null, -) diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupViewModel.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupViewModel.kt deleted file mode 100644 index 90fb98b..0000000 --- a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/profile/ProfileSetupViewModel.kt +++ /dev/null @@ -1,82 +0,0 @@ -package dev.yaseyo.onboarding.presentation.profile - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dev.yaseyo.coroutines.DispatcherProvider -import dev.yaseyo.user.api.ActivityLevel -import dev.yaseyo.user.api.Sex -import dev.yaseyo.user.api.UserRepository -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.datetime.LocalDate - -class ProfileSetupViewModel( - private val userRepository: UserRepository, - private val dispatchers: DispatcherProvider, -) : ViewModel(), - ProfileSetupEventHandler { - private val _state = MutableStateFlow(ProfileSetupUiState()) - val state: StateFlow = _state - - private val _actions = Channel(Channel.BUFFERED) - val actions: Flow = _actions.receiveAsFlow() - - override fun updateName(value: String) = _state.update { it.copy(draft = it.draft.copy(name = value)) } - - override fun updateSex(value: Sex) = _state.update { it.copy(draft = it.draft.copy(sex = value)) } - - override fun updateHeight(value: Double) = _state.update { it.copy(draft = it.draft.copy(height = value)) } - - override fun updateDateOfBirth(value: LocalDate) = _state.update { it.copy(draft = it.draft.copy(dateOfBirth = value)) } - - override fun updateActivityLevel(value: ActivityLevel) = _state.update { it.copy(draft = it.draft.copy(activityLevel = value)) } - - override fun updateTargetWeightKg(value: Double) = _state.update { it.copy(draft = it.draft.copy(targetWeightKg = value)) } - - override fun updateDailyDeficitKcal(value: Double) = _state.update { it.copy(draft = it.draft.copy(dailyDeficitKcal = value)) } - - override fun updateTargetProtein(value: Double) = _state.update { it.copy(draft = it.draft.copy(targetProtein = value)) } - - override fun updateTargetCarbs(value: Double) = _state.update { it.copy(draft = it.draft.copy(targetCarbs = value)) } - - override fun updateTargetFat(value: Double) = _state.update { it.copy(draft = it.draft.copy(targetFat = value)) } - - override fun nextStep() = _state.update { it.copy(step = ProfileSetupStep.Goals) } - - override fun previousStep() = _state.update { it.copy(step = ProfileSetupStep.PersonalInfo) } - - override fun submit() { - val draft = _state.value.draft - viewModelScope.launch(dispatchers.io) { - _state.update { it.copy(isLoading = true, error = null) } - userRepository - .createUser( - name = draft.name, - sex = draft.sex ?: return@launch, - height = draft.height ?: return@launch, - dateOfBirth = draft.dateOfBirth ?: return@launch, - activityLevel = draft.activityLevel ?: return@launch, - targetWeightKg = draft.targetWeightKg ?: return@launch, - dailyDeficitKcal = draft.dailyDeficitKcal ?: return@launch, - targetProtein = draft.targetProtein ?: return@launch, - targetCarbs = draft.targetCarbs ?: return@launch, - targetFat = draft.targetFat ?: return@launch, - ).fold( - onSuccess = { - _state.update { it.copy(isLoading = false) } - _actions.send(ProfileSetupUiAction.NavigateToHome) - }, - onFailure = { e -> - _state.update { it.copy(isLoading = false, error = e.message ?: "Profile creation failed") } - }, - ) - } - } - - override fun clearError() = _state.update { it.copy(error = null) } -} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashUiAction.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashUiAction.kt deleted file mode 100644 index a8aa131..0000000 --- a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashUiAction.kt +++ /dev/null @@ -1,9 +0,0 @@ -package dev.yaseyo.onboarding.presentation.splash - -import dev.yaseyo.onboarding.domain.AppState - -sealed interface SplashUiAction { - data class NavigateTo( - val destination: AppState, - ) : SplashUiAction -} diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashUiState.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashUiState.kt deleted file mode 100644 index 383cf54..0000000 --- a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashUiState.kt +++ /dev/null @@ -1,5 +0,0 @@ -package dev.yaseyo.onboarding.presentation.splash - -data class SplashUiState( - val isLoading: Boolean = true, -) diff --git a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashViewModel.kt b/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashViewModel.kt deleted file mode 100644 index 18793e8..0000000 --- a/feature/onboarding/presentation/src/commonMain/kotlin/dev/yaseyo/onboarding/presentation/splash/SplashViewModel.kt +++ /dev/null @@ -1,34 +0,0 @@ -package dev.yaseyo.onboarding.presentation.splash - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dev.yaseyo.coroutines.DispatcherProvider -import dev.yaseyo.onboarding.domain.CheckAppStateUseCase -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.stateIn - -class SplashViewModel( - private val checkAppState: CheckAppStateUseCase, - private val dispatchers: DispatcherProvider, -) : ViewModel() { - private val _actions = Channel(Channel.BUFFERED) - val actions: Flow = _actions.receiveAsFlow() - - val state: StateFlow = - emptyFlow() - .onStart { - emit(SplashUiState(isLoading = true)) - _actions.send(SplashUiAction.NavigateTo(checkAppState())) - emit(SplashUiState(isLoading = false)) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = SplashUiState(), - ) -} diff --git a/feature/onboarding/presentation/src/iosMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingDependencies.kt b/feature/onboarding/presentation/src/iosMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingDependencies.kt deleted file mode 100644 index 2aa41da..0000000 --- a/feature/onboarding/presentation/src/iosMain/kotlin/dev/yaseyo/onboarding/presentation/OnboardingDependencies.kt +++ /dev/null @@ -1,12 +0,0 @@ -package dev.yaseyo.onboarding.presentation - -import dev.yaseyo.onboarding.presentation.auth.AuthViewModel -import dev.yaseyo.onboarding.presentation.profile.ProfileSetupViewModel -import dev.yaseyo.onboarding.presentation.splash.SplashViewModel -import org.koin.mp.KoinPlatform - -fun getSplashViewModel(): SplashViewModel = KoinPlatform.getKoin().get() - -fun getAuthViewModel(): AuthViewModel = KoinPlatform.getKoin().get() - -fun getProfileSetupViewModel(): ProfileSetupViewModel = KoinPlatform.getKoin().get() diff --git a/libs/di/build.gradle.kts b/libs/di/build.gradle.kts index ce2a439..db9d093 100644 --- a/libs/di/build.gradle.kts +++ b/libs/di/build.gradle.kts @@ -21,7 +21,7 @@ kotlin { target.binaries.withType().configureEach { export(projects.libs.auth.api) export(projects.libs.user) - export(projects.feature.onboarding.presentation) + export(projects.feature.onboarding.api) } } @@ -39,8 +39,8 @@ kotlin { api(projects.libs.user) // Features - implementation(projects.feature.onboarding.data) - api(projects.feature.onboarding.presentation) + implementation(projects.feature.onboarding.impl) + api(projects.feature.onboarding.api) } } diff --git a/settings.gradle.kts b/settings.gradle.kts index d61eeb1..905cea3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -42,6 +42,5 @@ include(":libs:auth:impl") include(":libs:coroutines") include(":libs:network") include(":libs:di") -include(":feature:onboarding:domain") -include(":feature:onboarding:data") -include(":feature:onboarding:presentation") +include(":feature:onboarding:api") +include(":feature:onboarding:impl") From 42d06a0d5abe43a2cb415396eaf54aec66d8285b Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Apr 2026 13:38:59 +0900 Subject: [PATCH 16/33] Add libs:navigation module and define AppRoute interface --- libs/navigation/build.gradle.kts | 17 +++++++++++++++++ .../dev/yaseyo/navigation/AppRoute.android.kt | 5 +++++ .../kotlin/dev/yaseyo/navigation/AppRoute.kt | 5 +++++ .../dev/yaseyo/navigation/AppRoute.ios.kt | 3 +++ settings.gradle.kts | 1 + 5 files changed, 31 insertions(+) create mode 100644 libs/navigation/build.gradle.kts create mode 100644 libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/AppRoute.android.kt create mode 100644 libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRoute.kt create mode 100644 libs/navigation/src/iosMain/kotlin/dev/yaseyo/navigation/AppRoute.ios.kt diff --git a/libs/navigation/build.gradle.kts b/libs/navigation/build.gradle.kts new file mode 100644 index 0000000..b9c3400 --- /dev/null +++ b/libs/navigation/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id("yaseyo.kmp.library") +} + +kotlin { + androidLibrary { + namespace = "dev.yaseyo.navigation" + } + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + } + } + } +} diff --git a/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/AppRoute.android.kt b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/AppRoute.android.kt new file mode 100644 index 0000000..db64fe0 --- /dev/null +++ b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/AppRoute.android.kt @@ -0,0 +1,5 @@ +package dev.yaseyo.navigation + +import androidx.navigation3.runtime.NavKey + +actual interface AppRoute : NavKey diff --git a/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRoute.kt b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRoute.kt new file mode 100644 index 0000000..7023c38 --- /dev/null +++ b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRoute.kt @@ -0,0 +1,5 @@ +package dev.yaseyo.navigation + +expect interface AppRoute + +data object Home : AppRoute diff --git a/libs/navigation/src/iosMain/kotlin/dev/yaseyo/navigation/AppRoute.ios.kt b/libs/navigation/src/iosMain/kotlin/dev/yaseyo/navigation/AppRoute.ios.kt new file mode 100644 index 0000000..3eed979 --- /dev/null +++ b/libs/navigation/src/iosMain/kotlin/dev/yaseyo/navigation/AppRoute.ios.kt @@ -0,0 +1,3 @@ +package dev.yaseyo.navigation + +actual interface AppRoute diff --git a/settings.gradle.kts b/settings.gradle.kts index 905cea3..2385209 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -40,6 +40,7 @@ include(":libs:user:impl") include(":libs:auth:api") include(":libs:auth:impl") include(":libs:coroutines") +include(":libs:navigation") include(":libs:network") include(":libs:di") include(":feature:onboarding:api") From 7719195d44cf0c6ec599a216cbb1a02879949ab7 Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Apr 2026 13:43:37 +0900 Subject: [PATCH 17/33] Add Android-specific navigation-runtime dependency to libs:navigation module --- libs/navigation/build.gradle.kts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/libs/navigation/build.gradle.kts b/libs/navigation/build.gradle.kts index b9c3400..55e8157 100644 --- a/libs/navigation/build.gradle.kts +++ b/libs/navigation/build.gradle.kts @@ -13,5 +13,11 @@ kotlin { implementation(libs.kotlin.stdlib) } } + + androidMain { + dependencies { + implementation(libs.androidx.navigation3.runtime) + } + } } } From e0b3610088cc3b7dc43b5d6a3ae14bdea0871254 Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Apr 2026 14:33:08 +0900 Subject: [PATCH 18/33] Add libs:design module and implement YaseyoTheme with light and dark color schemes --- libs/design/build.gradle.kts | 26 ++++++ .../kotlin/dev/yaseyo/design/YaseyoTheme.kt | 29 +++++++ .../kotlin/dev/yaseyo/design/AppColors.kt | 82 +++++++++++++++++++ settings.gradle.kts | 1 + 4 files changed, 138 insertions(+) create mode 100644 libs/design/build.gradle.kts create mode 100644 libs/design/src/androidMain/kotlin/dev/yaseyo/design/YaseyoTheme.kt create mode 100644 libs/design/src/commonMain/kotlin/dev/yaseyo/design/AppColors.kt diff --git a/libs/design/build.gradle.kts b/libs/design/build.gradle.kts new file mode 100644 index 0000000..564cc9d --- /dev/null +++ b/libs/design/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + id("yaseyo.kmp.library") + alias(libs.plugins.composeCompiler) +} + +kotlin { + androidLibrary { + namespace = "dev.yaseyo.design" + } + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + } + } + + androidMain { + dependencies { + implementation(libs.compose.ui) + implementation(libs.compose.material3) + implementation(libs.compose.foundation) + } + } + } +} diff --git a/libs/design/src/androidMain/kotlin/dev/yaseyo/design/YaseyoTheme.kt b/libs/design/src/androidMain/kotlin/dev/yaseyo/design/YaseyoTheme.kt new file mode 100644 index 0000000..0819429 --- /dev/null +++ b/libs/design/src/androidMain/kotlin/dev/yaseyo/design/YaseyoTheme.kt @@ -0,0 +1,29 @@ +package dev.yaseyo.design + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf + +private val LocalAppColors = staticCompositionLocalOf { + error("No AppColors provided — wrap your UI in YaseyoTheme { }") +} + +@Composable +fun YaseyoTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + CompositionLocalProvider( + LocalAppColors provides if (darkTheme) DarkAppColors else LightAppColors, + content = content, + ) +} + +object YaseyoTheme { + val colors: AppColors + @Composable + @ReadOnlyComposable + get() = LocalAppColors.current +} diff --git a/libs/design/src/commonMain/kotlin/dev/yaseyo/design/AppColors.kt b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/AppColors.kt new file mode 100644 index 0000000..d93be2b --- /dev/null +++ b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/AppColors.kt @@ -0,0 +1,82 @@ +package dev.yaseyo.design + +data class AppColors( + // ── Backgrounds ────────────────────────────────────────────────────────── + val backgroundBase: Long, // app root background + val backgroundSubtle: Long, // recessed areas, input fills + val backgroundElevated: Long, // cards, sheets, dialogs + // ── Content (text & icons) ─────────────────────────────────────────────── + val contentPrimary: Long, // body text, primary icons + val contentSecondary: Long, // labels, captions + val contentTertiary: Long, // placeholder, hints + val contentDisabled: Long, // disabled state + val contentOnAccent: Long, // text / icons sitting on accent fill + // ── Accent (brand action) ──────────────────────────────────────────────── + val accentDefault: Long, // buttons, active controls, links + val accentHover: Long, // accentDefault darkened for hover/press + val accentSubtle: Long, // tinted backgrounds, selected rows + val accentMuted: Long, // very light tint, chips, tags + // ── Borders ─────────────────────────────────────────────────────────────── + val borderSubtle: Long, // dividers, hairlines + val borderDefault: Long, // input rings, card edges + val borderStrong: Long, // focused input, emphasis + // ── Feedback ────────────────────────────────────────────────────────────── + val feedbackPositive: Long, + val feedbackPositiveSubtle: Long, + val feedbackCritical: Long, + val feedbackCriticalSubtle: Long, + val feedbackWarning: Long, + val feedbackWarningSubtle: Long, + // ── Scrim ───────────────────────────────────────────────────────────────── + val scrim: Long, +) + +val LightAppColors = AppColors( + backgroundBase = 0xFFFBFDF9L, + backgroundSubtle = 0xFFEEF2EFL, + backgroundElevated = 0xFFFFFFFFL, + contentPrimary = 0xFF191C1AL, + contentSecondary = 0xFF404943L, + contentTertiary = 0xFF707973L, + contentDisabled = 0xFFC0C9C2L, + contentOnAccent = 0xFFFFFFFFL, + accentDefault = 0xFF1B7A5EL, + accentHover = 0xFF145C46L, + accentSubtle = 0xFFA5F2D6L, + accentMuted = 0xFFDCE5DEL, + borderSubtle = 0xFFDCE5DEL, + borderDefault = 0xFFC0C9C2L, + borderStrong = 0xFF707973L, + feedbackPositive = 0xFF1B7A5EL, + feedbackPositiveSubtle = 0xFFA5F2D6L, + feedbackCritical = 0xFFBA1A1AL, + feedbackCriticalSubtle = 0xFFFFDAD6L, + feedbackWarning = 0xFF7D5700L, + feedbackWarningSubtle = 0xFFFFDFA3L, + scrim = 0x99000000L, +) + +val DarkAppColors = AppColors( + backgroundBase = 0xFF191C1AL, + backgroundSubtle = 0xFF1F2421L, + backgroundElevated = 0xFF2E3130L, + contentPrimary = 0xFFE1E3DFL, + contentSecondary = 0xFFC0C9C2L, + contentTertiary = 0xFF8A938CL, + contentDisabled = 0xFF404943L, + contentOnAccent = 0xFF00382AL, + accentDefault = 0xFF89D5B5L, + accentHover = 0xFFA5E8C9L, + accentSubtle = 0xFF005140L, + accentMuted = 0xFF404943L, + borderSubtle = 0xFF2E3130L, + borderDefault = 0xFF404943L, + borderStrong = 0xFF8A938CL, + feedbackPositive = 0xFF89D5B5L, + feedbackPositiveSubtle = 0xFF005140L, + feedbackCritical = 0xFFFFB4ABL, + feedbackCriticalSubtle = 0xFF93000AL, + feedbackWarning = 0xFFF5BE48L, + feedbackWarningSubtle = 0xFF4C3800L, + scrim = 0xCC000000L, +) diff --git a/settings.gradle.kts b/settings.gradle.kts index 2385209..a606e23 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -41,6 +41,7 @@ include(":libs:auth:api") include(":libs:auth:impl") include(":libs:coroutines") include(":libs:navigation") +include(":libs:design") include(":libs:network") include(":libs:di") include(":feature:onboarding:api") From c55c1125394a65d3d96d5a05a1b5f93140044aae Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Apr 2026 14:53:05 +0900 Subject: [PATCH 19/33] Add AppState model to onboarding API module --- .../kotlin/dev/yaseyo/onboarding/api/model/AppState.kt | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/model/AppState.kt diff --git a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/model/AppState.kt b/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/model/AppState.kt new file mode 100644 index 0000000..ef17749 --- /dev/null +++ b/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/model/AppState.kt @@ -0,0 +1,9 @@ +package dev.yaseyo.onboarding.api.model + +sealed interface AppState { + data object FullySetup : AppState + + data object RequiresProfileSetup : AppState + + data object RequiresLogin : AppState +} From d2eb6ec4e6028cc6d94cf7d7d64f3207ab6258ef Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Apr 2026 15:16:40 +0900 Subject: [PATCH 20/33] Add ResolveAppStateUseCase to onboarding:api --- .../onboarding/api/ResolveAppStateUseCase.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/ResolveAppStateUseCase.kt diff --git a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/ResolveAppStateUseCase.kt b/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/ResolveAppStateUseCase.kt new file mode 100644 index 0000000..06ea145 --- /dev/null +++ b/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/ResolveAppStateUseCase.kt @@ -0,0 +1,25 @@ +package dev.yaseyo.onboarding.api + +import dev.yaseyo.auth.api.AuthRepository +import dev.yaseyo.auth.api.AuthState +import dev.yaseyo.onboarding.api.model.AppState +import dev.yaseyo.user.api.UserRepository + +class ResolveAppStateUseCase( + private val authRepository: AuthRepository, + private val userRepository: UserRepository, +) { + suspend fun execute(): AppState = + when (authRepository.authState.value) { + is AuthState.SignedOut -> AppState.RequiresLogin + + is AuthState.SignedIn -> { + val user = userRepository.getCurrentUser() + if (user.isSuccess) { + AppState.FullySetup + } else { + AppState.RequiresProfileSetup + } + } + } +} From 65c30bdc831aadd74aa610900c99f90e475aeb54 Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Apr 2026 15:17:02 +0900 Subject: [PATCH 21/33] Add OnboardingRoutes to onboarding:api module --- .../yaseyo/onboarding/api/navigation/OnboardingRoutes.kt | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/navigation/OnboardingRoutes.kt diff --git a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/navigation/OnboardingRoutes.kt b/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/navigation/OnboardingRoutes.kt new file mode 100644 index 0000000..2baac56 --- /dev/null +++ b/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/navigation/OnboardingRoutes.kt @@ -0,0 +1,9 @@ +package dev.yaseyo.onboarding.api.navigation + +import dev.yaseyo.navigation.AppRoute + +sealed interface OnboardingRoutes : AppRoute { + data object Auth : OnboardingRoutes + + data object ProfileSetup : OnboardingRoutes +} From ca5440878f685db1732f3c271e4caeb1b90c0425 Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Apr 2026 15:18:50 +0900 Subject: [PATCH 22/33] Add AppViewModel to onboarding:api module --- .../dev/yaseyo/onboarding/api/AppViewModel.kt | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/AppViewModel.kt diff --git a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/AppViewModel.kt b/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/AppViewModel.kt new file mode 100644 index 0000000..5ddfccd --- /dev/null +++ b/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/AppViewModel.kt @@ -0,0 +1,26 @@ +package dev.yaseyo.onboarding.api + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.yaseyo.onboarding.api.model.AppState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class AppViewModel( + private val resolveAppStateUseCase: ResolveAppStateUseCase, +) : ViewModel() { + private val _appState = MutableStateFlow(null) + val appState = _appState + .onStart { + viewModelScope.launch { + _appState.value = resolveAppStateUseCase.execute() + } + }.stateIn( + scope = viewModelScope, + initialValue = null, + started = SharingStarted.WhileSubscribed(5_000), + ) +} From d7a90043565b6bae1b9e4d765469f263da5b92fd Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Apr 2026 15:41:01 +0900 Subject: [PATCH 23/33] Add auth and user library dependencies to feature:onboarding:api module --- feature/onboarding/api/build.gradle.kts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/feature/onboarding/api/build.gradle.kts b/feature/onboarding/api/build.gradle.kts index 88e50e4..f276a14 100644 --- a/feature/onboarding/api/build.gradle.kts +++ b/feature/onboarding/api/build.gradle.kts @@ -21,6 +21,11 @@ kotlin { dependencies { implementation(libs.kotlin.stdlib) api(libs.kotlinx.coroutines.core) + + // Projects + implementation(projects.libs.auth) + implementation(projects.libs.user) + implementation(projects.libs.navigation) } } From f913d37599faa0d4ab6fd8e5e6ce3c4de04b9834 Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Apr 2026 15:45:11 +0900 Subject: [PATCH 24/33] Move AppViewModel to androidMain as MainViewModel and reorganize composeApp dependencies --- composeApp/build.gradle.kts | 18 ++++++++---------- .../kotlin/dev/yaseyo/MainViewModel.kt | 7 ++++--- 2 files changed, 12 insertions(+), 13 deletions(-) rename feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/AppViewModel.kt => composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt (84%) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 4abef40..b75876e 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -21,15 +21,6 @@ kotlin { implementation(projects.libs.di) // Dependencies - implementation(libs.androidx.navigation3.ui) - implementation(libs.androidx.navigation3.runtime) - implementation(libs.androidx.lifecycle.viewmodel.navigation3) - implementation(libs.compose.uiToolingPreview) - implementation(libs.androidx.activity.compose) - implementation(libs.koin.android) - implementation(libs.koin.compose) - } - commonMain.dependencies { implementation(libs.koin.core) implementation(libs.compose.runtime) implementation(libs.compose.foundation) @@ -39,8 +30,15 @@ kotlin { implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) + implementation(libs.compose.uiToolingPreview) + implementation(libs.androidx.activity.compose) + implementation(libs.koin.android) + implementation(libs.koin.compose) } - commonTest.dependencies { + androidUnitTest.dependencies { implementation(libs.kotlin.test) } } diff --git a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/AppViewModel.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt similarity index 84% rename from feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/AppViewModel.kt rename to composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt index 5ddfccd..c332846 100644 --- a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/AppViewModel.kt +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt @@ -1,7 +1,8 @@ -package dev.yaseyo.onboarding.api +package dev.yaseyo import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dev.yaseyo.onboarding.api.ResolveAppStateUseCase import dev.yaseyo.onboarding.api.model.AppState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -9,7 +10,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -class AppViewModel( +class MainViewModel( private val resolveAppStateUseCase: ResolveAppStateUseCase, ) : ViewModel() { private val _appState = MutableStateFlow(null) @@ -21,6 +22,6 @@ class AppViewModel( }.stateIn( scope = viewModelScope, initialValue = null, - started = SharingStarted.WhileSubscribed(5_000), + started = SharingStarted.Lazily, ) } From f498e2d3012bd83fce88a10297451a1349c181d0 Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Apr 2026 19:38:27 +0900 Subject: [PATCH 25/33] Add Android SplashScreen and App Startup for Koin module initialization - Integrate `androidx.splashscreen` and configure `Theme.Yaseyo.Starting`. - Implement `MainModuleInitializer` using `androidx.startup` to load Koin modules. - Add `MainViewModel` to manage app state during the splash screen transition. - Update `libs/navigation` and `feature/onboarding/api` to use `api` configuration for navigation dependencies. - Define `mainModule` for Android-specific dependency injection. --- composeApp/build.gradle.kts | 2 ++ composeApp/src/androidMain/AndroidManifest.xml | 18 +++++++++++++++--- .../kotlin/dev/yaseyo/MainActivity.kt | 9 +++++++++ .../kotlin/dev/yaseyo/di/MainModule.kt | 11 +++++++++++ .../dev/yaseyo/di/MainModuleInitializer.kt | 13 +++++++++++++ .../src/androidMain/res/values/themes.xml | 8 ++++++++ feature/onboarding/api/build.gradle.kts | 2 +- gradle/libs.versions.toml | 2 ++ .../kotlin/dev/yaseyo/di/AppModules.kt | 4 ---- libs/navigation/build.gradle.kts | 2 +- 10 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/dev/yaseyo/di/MainModule.kt create mode 100644 composeApp/src/androidMain/kotlin/dev/yaseyo/di/MainModuleInitializer.kt create mode 100644 composeApp/src/androidMain/res/values/themes.xml diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b75876e..0f1f404 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -21,6 +21,7 @@ kotlin { implementation(projects.libs.di) // Dependencies + implementation(libs.androidx.startup.runtime) implementation(libs.koin.core) implementation(libs.compose.runtime) implementation(libs.compose.foundation) @@ -37,6 +38,7 @@ kotlin { implementation(libs.androidx.activity.compose) implementation(libs.koin.android) implementation(libs.koin.compose) + implementation(libs.androidx.splashscreen) } androidUnitTest.dependencies { implementation(libs.kotlin.test) diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 8bbe7bd..de2b44a 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -1,5 +1,6 @@ - + + android:theme="@style/Theme.Yaseyo"> + android:name=".MainActivity" + android:theme="@style/Theme.Yaseyo.Starting"> + + + + diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt index 7858cb9..a196140 100644 --- a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt @@ -6,12 +6,21 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import org.koin.androidx.viewmodel.ext.android.viewModel class MainActivity : ComponentActivity() { + private val viewModel: MainViewModel by viewModel() + override fun onCreate(savedInstanceState: Bundle?) { + val splashScreen = installSplashScreen() enableEdgeToEdge() super.onCreate(savedInstanceState) + splashScreen.setKeepOnScreenCondition { + viewModel.appState.value == null + } + setContent { App() } diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/di/MainModule.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/di/MainModule.kt new file mode 100644 index 0000000..9c5d032 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/di/MainModule.kt @@ -0,0 +1,11 @@ +package dev.yaseyo.di + +import dev.yaseyo.MainViewModel +import dev.yaseyo.onboarding.api.ResolveAppStateUseCase +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val mainModule = module { + single { ResolveAppStateUseCase(get(), get()) } + viewModel { MainViewModel(get()) } +} diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/di/MainModuleInitializer.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/di/MainModuleInitializer.kt new file mode 100644 index 0000000..badab17 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/di/MainModuleInitializer.kt @@ -0,0 +1,13 @@ +package dev.yaseyo.di + +import android.content.Context +import androidx.startup.Initializer +import org.koin.core.context.loadKoinModules + +class MainModuleInitializer : Initializer { + override fun create(context: Context) { + loadKoinModules(mainModule) + } + + override fun dependencies(): List>> = listOf(KoinInitializer::class.java) +} diff --git a/composeApp/src/androidMain/res/values/themes.xml b/composeApp/src/androidMain/res/values/themes.xml new file mode 100644 index 0000000..32f3672 --- /dev/null +++ b/composeApp/src/androidMain/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/feature/onboarding/api/build.gradle.kts b/feature/onboarding/api/build.gradle.kts index f276a14..3034f44 100644 --- a/feature/onboarding/api/build.gradle.kts +++ b/feature/onboarding/api/build.gradle.kts @@ -25,7 +25,7 @@ kotlin { // Projects implementation(projects.libs.auth) implementation(projects.libs.user) - implementation(projects.libs.navigation) + api(projects.libs.navigation) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e38f649..4f1add3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ material3 = "1.10.0-alpha05" kotlinStdlib = "2.3.20" koin = "4.2.1" androidx-startup = "1.2.0" +androidx-splashscreen = "1.0.1" runner = "1.7.0" core = "1.7.0" skie = "0.10.11" @@ -56,6 +57,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-core-viewmodel = { module = "io.insert-koin:koin-core-viewmodel", version.ref = "koin" } androidx-startup-runtime = { module = "androidx.startup:startup-runtime", version.ref = "androidx-startup" } +androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlinStdlib" } diff --git a/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt index 7f8bbef..7e0c9ce 100644 --- a/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt +++ b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt @@ -3,8 +3,6 @@ package dev.yaseyo.di import dev.yaseyo.auth.di.authModule import dev.yaseyo.coroutines.coroutinesModule import dev.yaseyo.network.networkModule -import dev.yaseyo.onboarding.data.onboardingDataModule -import dev.yaseyo.onboarding.presentation.onboardingPresentationModule import dev.yaseyo.user.di.userModule import org.koin.dsl.module @@ -14,6 +12,4 @@ internal val appModules = listOf( userModule, module { single { networkConfig } }, networkModule, - onboardingDataModule, - onboardingPresentationModule, ) diff --git a/libs/navigation/build.gradle.kts b/libs/navigation/build.gradle.kts index 55e8157..11abcdb 100644 --- a/libs/navigation/build.gradle.kts +++ b/libs/navigation/build.gradle.kts @@ -16,7 +16,7 @@ kotlin { androidMain { dependencies { - implementation(libs.androidx.navigation3.runtime) + api(libs.androidx.navigation3.runtime) } } } From afbf4f236120aa30d5b9e0e9e5490d9ca149640d Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Apr 2026 23:45:09 +0900 Subject: [PATCH 26/33] Add onboardingModule and migrate ResolveAppStateUseCase to Koin dependency injection --- .../src/androidMain/kotlin/dev/yaseyo/MainActivity.kt | 4 ++++ .../src/androidMain/kotlin/dev/yaseyo/di/MainModule.kt | 2 -- feature/onboarding/api/build.gradle.kts | 1 + .../dev/yaseyo/onboarding/api/di/OnboardingModule.kt | 9 +++++++++ feature/onboarding/impl/build.gradle.kts | 2 +- .../di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt | 2 ++ 6 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/di/OnboardingModule.kt diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt index a196140..57ba673 100644 --- a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt @@ -5,8 +5,10 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.tooling.preview.Preview import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.androidx.viewmodel.ext.android.viewModel class MainActivity : ComponentActivity() { @@ -22,6 +24,8 @@ class MainActivity : ComponentActivity() { } setContent { + val appState by viewModel.appState.collectAsStateWithLifecycle() + App() } } diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/di/MainModule.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/di/MainModule.kt index 9c5d032..6571d4f 100644 --- a/composeApp/src/androidMain/kotlin/dev/yaseyo/di/MainModule.kt +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/di/MainModule.kt @@ -1,11 +1,9 @@ package dev.yaseyo.di import dev.yaseyo.MainViewModel -import dev.yaseyo.onboarding.api.ResolveAppStateUseCase import org.koin.core.module.dsl.viewModel import org.koin.dsl.module val mainModule = module { - single { ResolveAppStateUseCase(get(), get()) } viewModel { MainViewModel(get()) } } diff --git a/feature/onboarding/api/build.gradle.kts b/feature/onboarding/api/build.gradle.kts index 3034f44..03c96ea 100644 --- a/feature/onboarding/api/build.gradle.kts +++ b/feature/onboarding/api/build.gradle.kts @@ -20,6 +20,7 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.koin.core) api(libs.kotlinx.coroutines.core) // Projects diff --git a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/di/OnboardingModule.kt b/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/di/OnboardingModule.kt new file mode 100644 index 0000000..2c0d265 --- /dev/null +++ b/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/di/OnboardingModule.kt @@ -0,0 +1,9 @@ +package dev.yaseyo.onboarding.api.di + +import dev.yaseyo.onboarding.api.ResolveAppStateUseCase +import org.koin.core.module.dsl.factoryOf +import org.koin.dsl.module + +val onboardingModule = module { + factoryOf(::ResolveAppStateUseCase) +} diff --git a/feature/onboarding/impl/build.gradle.kts b/feature/onboarding/impl/build.gradle.kts index 4328c12..2a8b356 100644 --- a/feature/onboarding/impl/build.gradle.kts +++ b/feature/onboarding/impl/build.gradle.kts @@ -22,7 +22,7 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) - implementation(projects.feature.onboarding.api) + api(projects.feature.onboarding.api) implementation(libs.koin.core) } } diff --git a/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt index 7e0c9ce..7faaca3 100644 --- a/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt +++ b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt @@ -3,6 +3,7 @@ package dev.yaseyo.di import dev.yaseyo.auth.di.authModule import dev.yaseyo.coroutines.coroutinesModule import dev.yaseyo.network.networkModule +import dev.yaseyo.onboarding.api.di.onboardingModule import dev.yaseyo.user.di.userModule import org.koin.dsl.module @@ -12,4 +13,5 @@ internal val appModules = listOf( userModule, module { single { networkConfig } }, networkModule, + onboardingModule, ) From b90d6319f8b41c169c7d8f0eeff2570e61e1b128 Mon Sep 17 00:00:00 2001 From: Taki Date: Wed, 22 Apr 2026 00:12:19 +0900 Subject: [PATCH 27/33] Add Navigator and integrate it with Koin and MainActivity --- .../kotlin/dev/yaseyo/MainActivity.kt | 30 ++++++++++++++++++- libs/di/build.gradle.kts | 1 + .../kotlin/dev/yaseyo/di/KoinInitializer.kt | 3 +- libs/navigation/build.gradle.kts | 2 ++ .../dev/yaseyo/navigation/NavigationModule.kt | 11 +++++++ .../kotlin/dev/yaseyo/navigation/Navigator.kt | 24 +++++++++++++++ .../kotlin/dev/yaseyo/navigation/AppRoute.kt | 2 ++ 7 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/NavigationModule.kt create mode 100644 libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/Navigator.kt diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt index 57ba673..4adcc5f 100644 --- a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt @@ -5,13 +5,26 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.tooling.preview.Preview import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.yaseyo.navigation.Home +import dev.yaseyo.navigation.Navigator +import dev.yaseyo.onboarding.api.model.AppState +import dev.yaseyo.onboarding.api.navigation.OnboardingRoutes +import org.koin.android.ext.android.get +import org.koin.android.scope.AndroidScopeComponent +import org.koin.androidx.scope.activityRetainedScope import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.scope.Scope + +class MainActivity : + ComponentActivity(), + AndroidScopeComponent { + override val scope: Scope by activityRetainedScope() -class MainActivity : ComponentActivity() { private val viewModel: MainViewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { @@ -24,8 +37,23 @@ class MainActivity : ComponentActivity() { } setContent { + val navigator: Navigator = get() val appState by viewModel.appState.collectAsStateWithLifecycle() + LaunchedEffect(appState) { + appState?.let { + val newRootDestination = when (it) { + AppState.FullySetup -> Home + + AppState.RequiresLogin -> OnboardingRoutes.Auth + + AppState.RequiresProfileSetup -> OnboardingRoutes.ProfileSetup + } + + navigator.replaceRootDestination(destination = newRootDestination) + } + } + App() } } diff --git a/libs/di/build.gradle.kts b/libs/di/build.gradle.kts index db9d093..7b078ea 100644 --- a/libs/di/build.gradle.kts +++ b/libs/di/build.gradle.kts @@ -37,6 +37,7 @@ kotlin { implementation(projects.libs.auth) implementation(projects.libs.network) api(projects.libs.user) + implementation(projects.libs.navigation) // Features implementation(projects.feature.onboarding.impl) diff --git a/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt b/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt index edfbe32..16d05f9 100644 --- a/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt +++ b/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt @@ -2,6 +2,7 @@ package dev.yaseyo.di import android.content.Context import androidx.startup.Initializer +import dev.yaseyo.navigation.navigationModule import org.koin.android.ext.koin.androidContext import org.koin.core.KoinApplication import org.koin.core.context.startKoin @@ -10,7 +11,7 @@ class KoinInitializer : Initializer { override fun create(context: Context): KoinApplication = startKoin { androidContext(context) - modules(appModules) // Add modules here + modules(appModules + navigationModule) } override fun dependencies(): List>> = emptyList() diff --git a/libs/navigation/build.gradle.kts b/libs/navigation/build.gradle.kts index 11abcdb..780653e 100644 --- a/libs/navigation/build.gradle.kts +++ b/libs/navigation/build.gradle.kts @@ -17,6 +17,8 @@ kotlin { androidMain { dependencies { api(libs.androidx.navigation3.runtime) + implementation(libs.koin.core) + implementation(libs.koin.android) } } } diff --git a/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/NavigationModule.kt b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/NavigationModule.kt new file mode 100644 index 0000000..87e588c --- /dev/null +++ b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/NavigationModule.kt @@ -0,0 +1,11 @@ +package dev.yaseyo.navigation + +import org.koin.androidx.scope.dsl.activityRetainedScope +import org.koin.core.module.dsl.scopedOf +import org.koin.dsl.module + +val navigationModule = module { + activityRetainedScope { + scopedOf(::Navigator) + } +} diff --git a/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/Navigator.kt b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/Navigator.kt new file mode 100644 index 0000000..a53fcc0 --- /dev/null +++ b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/Navigator.kt @@ -0,0 +1,24 @@ +package dev.yaseyo.navigation + +import androidx.compose.runtime.mutableStateListOf + +class Navigator { + val backStack = mutableStateListOf(RootDestination) + + fun goTo(destination: AppRoute) { + backStack.add(destination) + } + + fun replaceRootDestination(destination: AppRoute) { + if (backStack.lastOrNull() == destination) return + + backStack.add(destination) + backStack.removeAll { + it != destination + } + } + + fun goBack() { + backStack.removeLastOrNull() + } +} diff --git a/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRoute.kt b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRoute.kt index 7023c38..3f62754 100644 --- a/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRoute.kt +++ b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRoute.kt @@ -2,4 +2,6 @@ package dev.yaseyo.navigation expect interface AppRoute +data object RootDestination : AppRoute + data object Home : AppRoute From 4b33ffca508fc40a713420b1c4405d0198482794 Mon Sep 17 00:00:00 2001 From: Taki Date: Wed, 22 Apr 2026 01:14:16 +0900 Subject: [PATCH 28/33] Integrate Navigation3 with Koin and refactor start destination logic - Implement `NavDisplay` in `App.kt` using `koinEntryProvider` and standard entry decorators. - Add `koin-compose-navigation3` dependency to `composeApp` and `feature:onboarding:api`. - Refactor `MainViewModel` to resolve the `startDestination` flow based on `AppState`. - Update `MainActivity` to handle initial navigation via `LaunchedEffect` and the `Navigator` singleton. - Simplify `Navigator` by removing `replaceRootDestination` and initializing with an empty backstack. - Remove unused `RootDestination` from `AppRoute`. --- composeApp/build.gradle.kts | 1 + .../src/androidMain/kotlin/dev/yaseyo/App.kt | 60 ++++++++----------- .../kotlin/dev/yaseyo/MainActivity.kt | 42 +++++-------- .../kotlin/dev/yaseyo/MainViewModel.kt | 36 +++++++---- feature/onboarding/api/build.gradle.kts | 1 + gradle/libs.versions.toml | 1 + .../kotlin/dev/yaseyo/navigation/Navigator.kt | 11 +--- .../kotlin/dev/yaseyo/navigation/AppRoute.kt | 2 - 8 files changed, 67 insertions(+), 87 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 0f1f404..34e0785 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -33,6 +33,7 @@ kotlin { implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.navigation3.runtime) + implementation(libs.koin.compose.nav3) implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.activity.compose) diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt index 3a1b7d5..0d78523 100644 --- a/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt @@ -1,47 +1,35 @@ package dev.yaseyo -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.safeContentPadding -import androidx.compose.material3.Button +import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import dev.yaseyo.navigation.AppRoute +import dev.yaseyo.navigation.Navigator +import org.koin.compose.navigation3.koinEntryProvider +import org.koin.core.annotation.KoinExperimentalAPI +@OptIn(KoinExperimentalAPI::class) @Composable -@Preview -fun App() { +fun App(navigator: Navigator) { + val entryProvider = koinEntryProvider() + MaterialTheme { - var showContent by remember { mutableStateOf(false) } - Column( - modifier = - Modifier - .background(MaterialTheme.colorScheme.primaryContainer) - .safeContentPadding() - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Button(onClick = { showContent = !showContent }) { - Text("Click me!") - } - AnimatedVisibility(showContent) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text("Compose: KMP") - } - } + Scaffold { padding -> + NavDisplay( + backStack = navigator.backStack, + modifier = Modifier.padding(padding), + onBack = { navigator.goBack() }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = entryProvider, + ) } } } diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt index 4adcc5f..495de6b 100644 --- a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt @@ -4,17 +4,14 @@ 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.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.yaseyo.navigation.Home import dev.yaseyo.navigation.Navigator -import dev.yaseyo.onboarding.api.model.AppState -import dev.yaseyo.onboarding.api.navigation.OnboardingRoutes -import org.koin.android.ext.android.get import org.koin.android.scope.AndroidScopeComponent import org.koin.androidx.scope.activityRetainedScope import org.koin.androidx.viewmodel.ext.android.viewModel @@ -26,6 +23,7 @@ class MainActivity : override val scope: Scope by activityRetainedScope() private val viewModel: MainViewModel by viewModel() + private val navigator: Navigator by lazy { scope.get() } override fun onCreate(savedInstanceState: Bundle?) { val splashScreen = installSplashScreen() @@ -33,34 +31,26 @@ class MainActivity : super.onCreate(savedInstanceState) splashScreen.setKeepOnScreenCondition { - viewModel.appState.value == null + viewModel.startDestination.value == null } setContent { - val navigator: Navigator = get() - val appState by viewModel.appState.collectAsStateWithLifecycle() + val startDestination by viewModel.startDestination.collectAsStateWithLifecycle() - LaunchedEffect(appState) { - appState?.let { - val newRootDestination = when (it) { - AppState.FullySetup -> Home - - AppState.RequiresLogin -> OnboardingRoutes.Auth - - AppState.RequiresProfileSetup -> OnboardingRoutes.ProfileSetup - } + var showApp by remember { + mutableStateOf(false) + } - navigator.replaceRootDestination(destination = newRootDestination) + LaunchedEffect(startDestination) { + startDestination?.let { startDestination -> + navigator.goTo(startDestination) + showApp = true } } - App() + if (showApp) { + App(navigator = navigator) + } } } } - -@Preview -@Composable -fun AppAndroidPreview() { - App() -} diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt index c332846..166fcca 100644 --- a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt @@ -2,26 +2,36 @@ package dev.yaseyo import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dev.yaseyo.navigation.AppRoute +import dev.yaseyo.navigation.Home import dev.yaseyo.onboarding.api.ResolveAppStateUseCase import dev.yaseyo.onboarding.api.model.AppState -import kotlinx.coroutines.flow.MutableStateFlow +import dev.yaseyo.onboarding.api.navigation.OnboardingRoutes import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch class MainViewModel( private val resolveAppStateUseCase: ResolveAppStateUseCase, ) : ViewModel() { - private val _appState = MutableStateFlow(null) - val appState = _appState - .onStart { - viewModelScope.launch { - _appState.value = resolveAppStateUseCase.execute() - } - }.stateIn( - scope = viewModelScope, - initialValue = null, - started = SharingStarted.Lazily, + val startDestination: StateFlow = flow { + val appState = resolveAppStateUseCase.execute() + + emit( + value = appState.let { + when (it) { + AppState.FullySetup -> Home + + AppState.RequiresLogin -> OnboardingRoutes.Auth + + AppState.RequiresProfileSetup -> OnboardingRoutes.ProfileSetup + } + }, ) + }.stateIn( + scope = viewModelScope, + initialValue = null, + started = SharingStarted.Eagerly, + ) } diff --git a/feature/onboarding/api/build.gradle.kts b/feature/onboarding/api/build.gradle.kts index 03c96ea..451a97b 100644 --- a/feature/onboarding/api/build.gradle.kts +++ b/feature/onboarding/api/build.gradle.kts @@ -38,6 +38,7 @@ kotlin { androidMain { dependencies { + implementation(libs.koin.compose.nav3) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4f1add3..6bed243 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -60,6 +60,7 @@ androidx-startup-runtime = { module = "androidx.startup:startup-runtime", versio androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } +koin-compose-nav3 = { module = "io.insert-koin:koin-compose-navigation3", version.ref = "koin" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlinStdlib" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } androidx-core = { group = "androidx.test", name = "core", version.ref = "core" } diff --git a/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/Navigator.kt b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/Navigator.kt index a53fcc0..d6f2530 100644 --- a/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/Navigator.kt +++ b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/Navigator.kt @@ -3,21 +3,12 @@ package dev.yaseyo.navigation import androidx.compose.runtime.mutableStateListOf class Navigator { - val backStack = mutableStateListOf(RootDestination) + val backStack = mutableStateListOf() fun goTo(destination: AppRoute) { backStack.add(destination) } - fun replaceRootDestination(destination: AppRoute) { - if (backStack.lastOrNull() == destination) return - - backStack.add(destination) - backStack.removeAll { - it != destination - } - } - fun goBack() { backStack.removeLastOrNull() } diff --git a/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRoute.kt b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRoute.kt index 3f62754..7023c38 100644 --- a/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRoute.kt +++ b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRoute.kt @@ -2,6 +2,4 @@ package dev.yaseyo.navigation expect interface AppRoute -data object RootDestination : AppRoute - data object Home : AppRoute From 9089fcbfeba57ced903477a9d143704cc1623fb5 Mon Sep 17 00:00:00 2001 From: Taki Date: Wed, 22 Apr 2026 22:36:00 +0900 Subject: [PATCH 29/33] Prevent redundant navigation by checking `showApp` state in `MainActivity` --- .../src/androidMain/kotlin/dev/yaseyo/MainActivity.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt index 495de6b..6e3b7f8 100644 --- a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt @@ -42,9 +42,11 @@ class MainActivity : } LaunchedEffect(startDestination) { - startDestination?.let { startDestination -> - navigator.goTo(startDestination) - showApp = true + if (!showApp) { + startDestination?.let { startDestination -> + navigator.goTo(startDestination) + showApp = true + } } } From 35d600dcee853eeff56e79dac0b4355ea2feef6d Mon Sep 17 00:00:00 2001 From: Taki Date: Wed, 22 Apr 2026 23:12:33 +0900 Subject: [PATCH 30/33] Update `OnboardingFeatureNavigation` visibility to internal --- .../src/androidMain/kotlin/dev/yaseyo/App.kt | 16 +++++++++++++--- feature/onboarding/impl/build.gradle.kts | 4 ++++ .../impl/OnboardingFeatureAndroidModule.kt | 10 ++++++++++ .../impl/OnboardingFeatureNavigation.kt | 19 +++++++++++++++++++ .../kotlin/dev/yaseyo/di/KoinInitializer.kt | 5 +++-- .../yaseyo/navigation/FeatureNavigation.kt | 7 +++++++ .../dev/yaseyo/navigation/NavigationModule.kt | 2 +- 7 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 feature/onboarding/impl/src/androidMain/kotlin/dev/yaseyo/onboarding/impl/OnboardingFeatureAndroidModule.kt create mode 100644 feature/onboarding/impl/src/androidMain/kotlin/dev/yaseyo/onboarding/impl/OnboardingFeatureNavigation.kt create mode 100644 libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/FeatureNavigation.kt diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt index 0d78523..b678bda 100644 --- a/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt @@ -6,17 +6,20 @@ import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import dev.yaseyo.navigation.AppRoute +import dev.yaseyo.navigation.FeatureNavigation import dev.yaseyo.navigation.Navigator -import org.koin.compose.navigation3.koinEntryProvider +import org.koin.compose.getKoin import org.koin.core.annotation.KoinExperimentalAPI @OptIn(KoinExperimentalAPI::class) @Composable fun App(navigator: Navigator) { - val entryProvider = koinEntryProvider() + val allFeatureNavigation = getKoin().getAll() MaterialTheme { Scaffold { padding -> @@ -28,8 +31,15 @@ fun App(navigator: Navigator) { rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator(), ), - entryProvider = entryProvider, + entryProvider = allFeatureNavigation.registerAll(), ) } } } + +private fun List.registerAll(): (AppRoute) -> NavEntry = + entryProvider { + forEach { + it.navEntryProvider(this) + } + } diff --git a/feature/onboarding/impl/build.gradle.kts b/feature/onboarding/impl/build.gradle.kts index 2a8b356..f0945d2 100644 --- a/feature/onboarding/impl/build.gradle.kts +++ b/feature/onboarding/impl/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("yaseyo.kmp.library") + alias(libs.plugins.composeCompiler) } kmpLibrary {} @@ -35,6 +36,9 @@ kotlin { androidMain { dependencies { + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.koin.android) } } diff --git a/feature/onboarding/impl/src/androidMain/kotlin/dev/yaseyo/onboarding/impl/OnboardingFeatureAndroidModule.kt b/feature/onboarding/impl/src/androidMain/kotlin/dev/yaseyo/onboarding/impl/OnboardingFeatureAndroidModule.kt new file mode 100644 index 0000000..21cb721 --- /dev/null +++ b/feature/onboarding/impl/src/androidMain/kotlin/dev/yaseyo/onboarding/impl/OnboardingFeatureAndroidModule.kt @@ -0,0 +1,10 @@ +package dev.yaseyo.onboarding.impl + +import dev.yaseyo.navigation.FeatureNavigation +import org.koin.dsl.module + +val onboardingFeatureAndroidModule = module { + factory { + OnboardingFeatureNavigation() + } +} diff --git a/feature/onboarding/impl/src/androidMain/kotlin/dev/yaseyo/onboarding/impl/OnboardingFeatureNavigation.kt b/feature/onboarding/impl/src/androidMain/kotlin/dev/yaseyo/onboarding/impl/OnboardingFeatureNavigation.kt new file mode 100644 index 0000000..9aceb1f --- /dev/null +++ b/feature/onboarding/impl/src/androidMain/kotlin/dev/yaseyo/onboarding/impl/OnboardingFeatureNavigation.kt @@ -0,0 +1,19 @@ +package dev.yaseyo.onboarding.impl + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.navigation3.runtime.EntryProviderScope +import dev.yaseyo.navigation.AppRoute +import dev.yaseyo.navigation.FeatureNavigation +import dev.yaseyo.onboarding.api.navigation.OnboardingRoutes + +internal class OnboardingFeatureNavigation : FeatureNavigation { + override val navEntryProvider: EntryProviderScope.() -> Unit = { + entry { + Box(modifier = Modifier.fillMaxSize().background(Color.Red)) + } + } +} diff --git a/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt b/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt index 16d05f9..9b6aad9 100644 --- a/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt +++ b/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt @@ -2,7 +2,8 @@ package dev.yaseyo.di import android.content.Context import androidx.startup.Initializer -import dev.yaseyo.navigation.navigationModule +import dev.yaseyo.navigation.navigationAndroidModule +import dev.yaseyo.onboarding.impl.onboardingFeatureAndroidModule import org.koin.android.ext.koin.androidContext import org.koin.core.KoinApplication import org.koin.core.context.startKoin @@ -11,7 +12,7 @@ class KoinInitializer : Initializer { override fun create(context: Context): KoinApplication = startKoin { androidContext(context) - modules(appModules + navigationModule) + modules(appModules + navigationAndroidModule + onboardingFeatureAndroidModule) } override fun dependencies(): List>> = emptyList() diff --git a/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/FeatureNavigation.kt b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/FeatureNavigation.kt new file mode 100644 index 0000000..aaab96d --- /dev/null +++ b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/FeatureNavigation.kt @@ -0,0 +1,7 @@ +package dev.yaseyo.navigation + +import androidx.navigation3.runtime.EntryProviderScope + +interface FeatureNavigation { + val navEntryProvider: EntryProviderScope.() -> Unit +} diff --git a/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/NavigationModule.kt b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/NavigationModule.kt index 87e588c..230ecf8 100644 --- a/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/NavigationModule.kt +++ b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/NavigationModule.kt @@ -4,7 +4,7 @@ import org.koin.androidx.scope.dsl.activityRetainedScope import org.koin.core.module.dsl.scopedOf import org.koin.dsl.module -val navigationModule = module { +val navigationAndroidModule = module { activityRetainedScope { scopedOf(::Navigator) } From ea2ed55440688f914a63dc3f5a29112b790ac601 Mon Sep 17 00:00:00 2001 From: Taki Date: Thu, 23 Apr 2026 00:56:19 +0900 Subject: [PATCH 31/33] Implement parent proxy module pattern and update onboarding feature structure --- .claude/skills/create-new-feature/SKILL.md | 128 +++++++++++++++--- .claude/skills/wiring-up-koin/SKILL.md | 69 +++++++--- .claude/skills/working-with-gradle/SKILL.md | 26 ++++ feature/onboarding/build.gradle.kts | 17 +++ .../onboarding/OnboardingFeatureModule.kt | 9 ++ libs/di/build.gradle.kts | 5 +- .../kotlin/dev/yaseyo/di/KoinInitializer.kt | 4 +- settings.gradle.kts | 1 + 8 files changed, 219 insertions(+), 40 deletions(-) create mode 100644 feature/onboarding/build.gradle.kts create mode 100644 feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureModule.kt diff --git a/.claude/skills/create-new-feature/SKILL.md b/.claude/skills/create-new-feature/SKILL.md index e3089de..27181e0 100644 --- a/.claude/skills/create-new-feature/SKILL.md +++ b/.claude/skills/create-new-feature/SKILL.md @@ -3,20 +3,114 @@ name: create-new-feature description: This skill is to instruct you how to go about implementing a new feature --- -# How to teach me or explain to me - -## Instructions -- The structure of my project is a multi module KMP project -- No single `:shared` module, instead I have multiple shared KMP modules -- Among the shared KMP modules, there are 2 variants. `:lib` and `:feature`. -- `:lib` modules (eg. `:lib:auth`) is for core functionality or I/O of a certain data. -- `:lib` modules will always consist of two child modules. `:api` and `:impl` -- `:feature` modules (eg. `:feature:history`) would be for consumer facing functionalities -- `:feature` modules will always consist of three child modules. `:ui`, `:data` and `:domain` -- `:feature:someFeature:ui` will, however, live in the native codebases since that's where it's implemented -- The feature's data and ui child modules, will depend on domain -- When developing, ensure that you get my approval before each step. I need to be architecturally involved to ensure best quality -- Once a feature/functionality or tech stack is implemented, I want you to update the README for the - project to keep it up to date with our architecture and tech stack choice. - -## Examples \ No newline at end of file +# How to create a new feature + +## Module structure rules +- Multi-module KMP project. No single `:shared` module. +- `:lib` modules (e.g. `:lib:auth`) — core functionality or I/O. Usually `:api` and `:impl` children, plus a parent proxy module unless too simple to warrant it. +- `:feature` modules (e.g. `:feature:onboarding`) — user-facing features. Usually `:api` and `:impl` children, plus a parent proxy module. + - `:feature::api` — routes, use case interfaces, domain models, common Koin module. Lives in `commonMain`. + - `:feature::impl` — concrete implementations and platform-specific code. Android nav wiring lives in `androidMain`. + - `:feature:` (parent) — umbrella/proxy. Depends on `:api` (via `api(...)`) and `:impl`. Owns the aggregated Koin module that combines both. **This is what `:libs:di` and other consumers depend on — never `:api` or `:impl` directly.** +- Always get the user's architectural approval before each step. +- After any new feature or tech stack choice lands, update the project README. + +--- + +## Step-by-step: wiring a new feature into navigation + +### 1. Define routes in `:feature::api` +File: `feature//api/src/commonMain/kotlin/dev/yaseyo//api/navigation/Routes.kt` + +```kotlin +sealed interface Routes : AppRoute { + data object : Routes + // add more screens as needed +} +``` + +### 2. Create the `FeatureNavigation` impl in `:feature::impl` +File: `feature//impl/src/androidMain/kotlin/dev/yaseyo//impl/FeatureNavigation.kt` + +```kotlin +internal class FeatureNavigation : FeatureNavigation { + override val navEntryProvider: EntryProviderScope.() -> Unit = { + entry<Routes.> { + // Composable UI goes here + } + } +} +``` + +### 3. Create the Koin android module in `:feature::impl` +File: `feature//impl/src/androidMain/kotlin/dev/yaseyo//impl/FeatureAndroidModule.kt` + +```kotlin +val FeatureAndroidModule = module { + factory { FeatureNavigation() } +} +``` + +Key rule: **do not wrap in `activityRetainedScope`**. Register directly in the root scope so `getKoin().getAll()` can collect it. + +### 4. Create the parent proxy module's aggregated Koin module +File: `feature//src/androidMain/kotlin/dev/yaseyo//Module.kt` + +This is the single public entry point for all of this feature's DI. It includes both the `:api` and `:impl` modules: + +```kotlin +val Module = module { + includes(ApiModule, FeatureAndroidModule) +} +``` + +The parent's `build.gradle.kts` must depend on both children: +```kotlin +commonMain.dependencies { + api(projects.feature..api) // transitive — exposes the public API + implementation(projects.feature..impl) +} +``` + +### 5. Register the parent module in `KoinInitializer` +File: `libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt` + +```kotlin +modules(appModules + navigationAndroidModule + Module) +``` + +`:libs:di` must only depend on `projects.feature.` (the parent) — never on `:api` or `:impl` directly. + +--- + +## How `App.kt` collects all features (do not change this) + +`App.kt` uses `getKoin().getAll()` to collect every registered `FeatureNavigation` instance, then wires them all into the Nav3 `entryProvider` via the `registerAll()` extension: + +```kotlin +val allFeatureNavigation = getKoin().getAll() + +NavDisplay( + ... + entryProvider = allFeatureNavigation.registerAll(), +) + +private fun List.registerAll(): (AppRoute) -> NavEntry = + entryProvider { + forEach { it.navEntryProvider(this) } + } +``` + +Each feature only needs to register its `factory` — `App.kt` picks it up automatically. + +--- + +## Checklist for a new feature + +- [ ] Routes defined in `:api` as a `sealed interface` extending `AppRoute` +- [ ] `FeatureNavigation` created in `:impl/androidMain` +- [ ] `:impl` Koin module registering `factory` in root scope +- [ ] Parent proxy module created with aggregated Koin module +- [ ] `:libs:di` updated to depend on the parent (not `:api`/`:impl`) and include the aggregated module +- [ ] User approved architecture at each step +- [ ] README updated diff --git a/.claude/skills/wiring-up-koin/SKILL.md b/.claude/skills/wiring-up-koin/SKILL.md index 61ec073..93a3bce 100644 --- a/.claude/skills/wiring-up-koin/SKILL.md +++ b/.claude/skills/wiring-up-koin/SKILL.md @@ -3,21 +3,54 @@ name: wiring-up-koin description: This skill is to instruct you how to go about setting up the wiring of Koin --- -# How to teach me or explain to me - -## Instructions - -- Among the shared KMP modules, there are 2 variants. `:lib` and `:feature`. -- `:lib` modules (eg. `:lib:auth`) is for core functionality or I/O of a certain data. -- `:lib` modules will always consist of two child modules. `:api` and `:impl` -- `:feature` modules (eg. `:feature:history`) would be for consumer facing functionalities -- `:feature` modules will always consist of three child modules. `:ui`, `:data` and `:domain` -- `:feature:someFeature:ui` will, however, live in the native codebases since that's where it's - implemented -- The feature's data and ui child modules, will depend on domain -- Each parent module will have an aggregator DI Koin module, every child module will have its own - public Koin module -- which then gets included in the parent module's Koin module. -- The parent's Koin module is then added to the :lib:di module's Koin module. - -## Examples \ No newline at end of file +# How to wire up Koin + +## Module ownership rules + +- `:lib` and `:feature` modules each have `:api`, `:impl`, and a **parent proxy** module. +- `:api` owns a Koin module for its own definitions (use case bindings, interfaces, etc.). +- `:impl` owns a Koin module for its concrete implementations and platform-specific registrations. +- The **parent** module owns an **aggregated Koin module** that `includes(...)` both `:api` and `:impl` modules. This is the only Koin module that consumers (e.g. `:libs:di`) ever reference. +- `:libs:di` collects all parent aggregated modules and passes them to `startKoin { modules(...) }`. +- Consumers must **never** depend on `:api` or `:impl` directly — always depend on the parent. + +## Scope rules + +- Register definitions in the **root scope** (`module { factory { ... } }`) unless there is a specific reason to scope them. +- Do **not** wrap definitions in `activityRetainedScope { }` unless the definition must be tied to an activity's retained lifecycle. Scoped definitions are invisible to root-scope lookups like `getKoin().getAll()`. + +## Multi-binding with `getAll` + +When multiple modules contribute implementations of the same interface (e.g. `FeatureNavigation`), each registers its own `factory`. Call `getKoin().getAll()` to collect all of them at the call site. + +## Structure per module type + +### `:feature::api` (commonMain) +```kotlin +val ApiModule = module { + // interfaces, use case factories, etc. +} +``` + +### `:feature::impl` (androidMain or commonMain) +```kotlin +val FeatureAndroidModule = module { + factory { FeatureNavigation() } + // other impl-specific bindings +} +``` + +### `:feature:` parent (androidMain) +```kotlin +val Module = module { + includes(ApiModule, FeatureAndroidModule) +} +``` + +### `:libs:di` `KoinInitializer` +```kotlin +startKoin { + androidContext(context) + modules(appModules + navigationAndroidModule + Module + ...) +} +``` diff --git a/.claude/skills/working-with-gradle/SKILL.md b/.claude/skills/working-with-gradle/SKILL.md index 51e3b56..f73af82 100644 --- a/.claude/skills/working-with-gradle/SKILL.md +++ b/.claude/skills/working-with-gradle/SKILL.md @@ -12,4 +12,30 @@ description: This skill is to instruct you how to go about configuring a new gra existing convention plugin or suggesting the creation of a new one. - When adding a plugin, don't forget to add the plugin to the project level build.gradle.kts too! +## Parent proxy module pattern + +Every `:feature:` and `:lib:` group uses a parent proxy module. The parent: +- Has its own `build.gradle.kts` with **no namespace** (it is not an Android library itself, just a KMP aggregator) +- Declares `api(projects.feature..api)` so consumers get the public API transitively +- Declares `implementation(projects.feature..impl)` to pull in the concrete implementations +- Hosts the aggregated Koin module (see `wiring-up-koin` skill) + +Consumers (e.g. `:libs:di`, `:composeApp`) depend **only** on the parent — never on `:api` or `:impl` directly. This keeps the dependency graph clean and prevents leaking implementation details. + +Example parent `build.gradle.kts`: +```kotlin +plugins { + id("yaseyo.kmp.library") +} + +kotlin { + sourceSets { + commonMain.dependencies { + api(projects.feature..api) + implementation(projects.feature..impl) + } + } +} +``` + ## Examples \ No newline at end of file diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts new file mode 100644 index 0000000..4bba8a8 --- /dev/null +++ b/feature/onboarding/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id("yaseyo.kmp.library") +} + +kotlin { + androidLibrary { + namespace = "dev.yaseyo.onboarding" + } + + sourceSets { + androidMain.dependencies { + api(projects.feature.onboarding.api) + implementation(projects.feature.onboarding.impl) + implementation(libs.koin.core) + } + } +} diff --git a/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureModule.kt b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureModule.kt new file mode 100644 index 0000000..b96a822 --- /dev/null +++ b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureModule.kt @@ -0,0 +1,9 @@ +package dev.yaseyo.onboarding + +import dev.yaseyo.onboarding.api.di.onboardingModule +import dev.yaseyo.onboarding.impl.onboardingFeatureAndroidModule +import org.koin.dsl.module + +val onboardingFeatureModule = module { + includes(onboardingModule, onboardingFeatureAndroidModule) +} diff --git a/libs/di/build.gradle.kts b/libs/di/build.gradle.kts index 7b078ea..bc74730 100644 --- a/libs/di/build.gradle.kts +++ b/libs/di/build.gradle.kts @@ -21,7 +21,7 @@ kotlin { target.binaries.withType().configureEach { export(projects.libs.auth.api) export(projects.libs.user) - export(projects.feature.onboarding.api) + export(projects.feature.onboarding) } } @@ -40,8 +40,7 @@ kotlin { implementation(projects.libs.navigation) // Features - implementation(projects.feature.onboarding.impl) - api(projects.feature.onboarding.api) + api(projects.feature.onboarding) } } diff --git a/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt b/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt index 9b6aad9..7d9bbc4 100644 --- a/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt +++ b/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt @@ -3,7 +3,7 @@ package dev.yaseyo.di import android.content.Context import androidx.startup.Initializer import dev.yaseyo.navigation.navigationAndroidModule -import dev.yaseyo.onboarding.impl.onboardingFeatureAndroidModule +import dev.yaseyo.onboarding.onboardingFeatureModule import org.koin.android.ext.koin.androidContext import org.koin.core.KoinApplication import org.koin.core.context.startKoin @@ -12,7 +12,7 @@ class KoinInitializer : Initializer { override fun create(context: Context): KoinApplication = startKoin { androidContext(context) - modules(appModules + navigationAndroidModule + onboardingFeatureAndroidModule) + modules(appModules + navigationAndroidModule + onboardingFeatureModule) } override fun dependencies(): List>> = emptyList() diff --git a/settings.gradle.kts b/settings.gradle.kts index a606e23..31f8558 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -44,5 +44,6 @@ include(":libs:navigation") include(":libs:design") include(":libs:network") include(":libs:di") +include(":feature:onboarding") include(":feature:onboarding:api") include(":feature:onboarding:impl") From 72eebb749c991f29a8bf2c2bfacef075f67343f5 Mon Sep 17 00:00:00 2001 From: Taki Date: Thu, 23 Apr 2026 01:50:53 +0900 Subject: [PATCH 32/33] Refactor onboarding module structure and implement module boundary enforcement - Rename `:feature:onboarding:impl` to `:feature:onboarding:android` and convert it to a standard Android library. - Add `AndroidLibraryConventionPlugin` to handle common Android library configurations. - Implement `enforceInternalModuleBoundaries` logic to restrict access to `:impl` and `:android` modules. - Update Koin initialization to use the new onboarding Android module. - Clean up dependencies in onboarding API and aggregate modules. --- build-logic/convention/build.gradle.kts | 4 ++ .../yaseyo/AndroidLibraryConventionPlugin.kt | 48 +++++++++++++++ .../dev/yaseyo/KmpLibraryConventionPlugin.kt | 2 + .../dev/yaseyo/ModuleBoundaryEnforcement.kt | 46 +++++++++++++++ feature/onboarding/android/build.gradle.kts | 19 ++++++ .../OnboardingFeatureAndroidModule.kt | 6 +- .../android}/OnboardingFeatureNavigation.kt | 2 +- feature/onboarding/api/build.gradle.kts | 7 --- feature/onboarding/build.gradle.kts | 4 +- feature/onboarding/impl/build.gradle.kts | 58 ------------------- .../onboarding/OnboardingFeatureModule.kt | 9 --- libs/di/build.gradle.kts | 2 + .../kotlin/dev/yaseyo/di/KoinInitializer.kt | 4 +- settings.gradle.kts | 2 +- 14 files changed, 128 insertions(+), 85 deletions(-) create mode 100644 build-logic/convention/src/main/kotlin/dev/yaseyo/AndroidLibraryConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/dev/yaseyo/ModuleBoundaryEnforcement.kt create mode 100644 feature/onboarding/android/build.gradle.kts rename feature/onboarding/{impl/src/androidMain/kotlin/dev/yaseyo/onboarding/impl => android/src/main/kotlin/dev/yaseyo/onboarding/android}/OnboardingFeatureAndroidModule.kt (52%) rename feature/onboarding/{impl/src/androidMain/kotlin/dev/yaseyo/onboarding/impl => android/src/main/kotlin/dev/yaseyo/onboarding/android}/OnboardingFeatureNavigation.kt (94%) delete mode 100644 feature/onboarding/impl/build.gradle.kts delete mode 100644 feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureModule.kt diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index bba5527..ae85e57 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -20,5 +20,9 @@ gradlePlugin { id = "yaseyo.kmp.library" implementationClass = "dev.yaseyo.KmpLibraryConventionPlugin" } + register("androidLibrary") { + id = "yaseyo.android.library" + implementationClass = "dev.yaseyo.AndroidLibraryConventionPlugin" + } } } diff --git a/build-logic/convention/src/main/kotlin/dev/yaseyo/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/dev/yaseyo/AndroidLibraryConventionPlugin.kt new file mode 100644 index 0000000..5ff4333 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/dev/yaseyo/AndroidLibraryConventionPlugin.kt @@ -0,0 +1,48 @@ +package dev.yaseyo + +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.JavaVersion +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +class AndroidLibraryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("com.android.library") + apply("org.jetbrains.kotlin.android") + } + } + + target.enforceInternalModuleBoundaries() + + val libs = + target + .extensions + .getByType(VersionCatalogsExtension::class.java) + .named("libs") + + val android = target.extensions.getByType(LibraryExtension::class.java) + android.compileSdk = + libs + .findVersion("android-compileSdk") + .get() + .requiredVersion + .toInt() + android.defaultConfig.minSdk = + libs + .findVersion("android-minSdk") + .get() + .requiredVersion + .toInt() + android.compileOptions.sourceCompatibility = JavaVersion.VERSION_11 + android.compileOptions.targetCompatibility = JavaVersion.VERSION_11 + + target.tasks.withType(KotlinCompile::class.java).configureEach { + compilerOptions.jvmTarget.set(JvmTarget.JVM_11) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/dev/yaseyo/KmpLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/dev/yaseyo/KmpLibraryConventionPlugin.kt index 2e1ba36..e561f3f 100644 --- a/build-logic/convention/src/main/kotlin/dev/yaseyo/KmpLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/dev/yaseyo/KmpLibraryConventionPlugin.kt @@ -43,6 +43,8 @@ class KmpLibraryConventionPlugin : Plugin { ) } + target.enforceInternalModuleBoundaries() + target.afterEvaluate { val frameworkName = extension.iosFrameworkName.orNull diff --git a/build-logic/convention/src/main/kotlin/dev/yaseyo/ModuleBoundaryEnforcement.kt b/build-logic/convention/src/main/kotlin/dev/yaseyo/ModuleBoundaryEnforcement.kt new file mode 100644 index 0000000..4e37244 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/dev/yaseyo/ModuleBoundaryEnforcement.kt @@ -0,0 +1,46 @@ +package dev.yaseyo + +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.artifacts.ProjectDependency + +private val INTERNAL_MODULE_SUFFIXES = setOf(":impl", ":android") +private val INTERNAL_MODULE_CONSUMER_ALLOWLIST = setOf(":libs:di") + +fun Project.enforceInternalModuleBoundaries() { + afterEvaluate { + if (path in INTERNAL_MODULE_CONSUMER_ALLOWLIST) return@afterEvaluate + + configurations.forEach { configuration -> + configuration.dependencies + .withType(ProjectDependency::class.java) + .forEach { dep -> + val depPath = dep.dependencyProject.path + if (depPath == path) return@forEach + val suffix = INTERNAL_MODULE_SUFFIXES.firstOrNull { depPath.endsWith(it) } ?: return@forEach + val expectedParent = depPath.removeSuffix(suffix) + val isSameTree = path == expectedParent || path.startsWith("$expectedParent:") + if (!isSameTree) { + throw GradleException( + buildString { + appendLine() + appendLine("DESIGN BREACH ───────────────────────────────────────────────────") + appendLine(" Module : $path") + appendLine(" Depends on : $depPath ← $suffix is internal") + appendLine() + appendLine(" $suffix modules are internal to their parent. No module outside") + appendLine(" the parent ('$expectedParent') is allowed to depend on them.") + appendLine(" The parent re-exports the public API and aggregates Koin modules.") + appendLine() + appendLine(" In $path/build.gradle.kts, replace:") + appendLine(" implementation(projects${depPath.replace(":", ".")})") + appendLine(" with:") + appendLine(" implementation(projects${expectedParent.replace(":", ".")})") + appendLine("─────────────────────────────────────────────────────────────────") + }, + ) + } + } + } + } +} diff --git a/feature/onboarding/android/build.gradle.kts b/feature/onboarding/android/build.gradle.kts new file mode 100644 index 0000000..317d496 --- /dev/null +++ b/feature/onboarding/android/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("yaseyo.android.library") + alias(libs.plugins.composeCompiler) +} + +android { + namespace = "dev.yaseyo.onboarding.android" +} + +dependencies { + implementation(projects.feature.onboarding.api) + + implementation(libs.kotlin.stdlib) + implementation(libs.koin.core) + implementation(libs.koin.android) + implementation(libs.koin.compose.nav3) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) +} diff --git a/feature/onboarding/impl/src/androidMain/kotlin/dev/yaseyo/onboarding/impl/OnboardingFeatureAndroidModule.kt b/feature/onboarding/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureAndroidModule.kt similarity index 52% rename from feature/onboarding/impl/src/androidMain/kotlin/dev/yaseyo/onboarding/impl/OnboardingFeatureAndroidModule.kt rename to feature/onboarding/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureAndroidModule.kt index 21cb721..4173cec 100644 --- a/feature/onboarding/impl/src/androidMain/kotlin/dev/yaseyo/onboarding/impl/OnboardingFeatureAndroidModule.kt +++ b/feature/onboarding/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureAndroidModule.kt @@ -1,10 +1,8 @@ -package dev.yaseyo.onboarding.impl +package dev.yaseyo.onboarding.android import dev.yaseyo.navigation.FeatureNavigation import org.koin.dsl.module val onboardingFeatureAndroidModule = module { - factory { - OnboardingFeatureNavigation() - } + factory { OnboardingFeatureNavigation() } } diff --git a/feature/onboarding/impl/src/androidMain/kotlin/dev/yaseyo/onboarding/impl/OnboardingFeatureNavigation.kt b/feature/onboarding/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureNavigation.kt similarity index 94% rename from feature/onboarding/impl/src/androidMain/kotlin/dev/yaseyo/onboarding/impl/OnboardingFeatureNavigation.kt rename to feature/onboarding/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureNavigation.kt index 9aceb1f..69e3fbf 100644 --- a/feature/onboarding/impl/src/androidMain/kotlin/dev/yaseyo/onboarding/impl/OnboardingFeatureNavigation.kt +++ b/feature/onboarding/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureNavigation.kt @@ -1,4 +1,4 @@ -package dev.yaseyo.onboarding.impl +package dev.yaseyo.onboarding.android import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box diff --git a/feature/onboarding/api/build.gradle.kts b/feature/onboarding/api/build.gradle.kts index 451a97b..f4dfe60 100644 --- a/feature/onboarding/api/build.gradle.kts +++ b/feature/onboarding/api/build.gradle.kts @@ -23,7 +23,6 @@ kotlin { implementation(libs.koin.core) api(libs.kotlinx.coroutines.core) - // Projects implementation(projects.libs.auth) implementation(projects.libs.user) api(projects.libs.navigation) @@ -36,12 +35,6 @@ kotlin { } } - androidMain { - dependencies { - implementation(libs.koin.compose.nav3) - } - } - getByName("androidDeviceTest") { dependencies { implementation(libs.androidx.runner) diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index 4bba8a8..41fe8ea 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -8,10 +8,8 @@ kotlin { } sourceSets { - androidMain.dependencies { + commonMain.dependencies { api(projects.feature.onboarding.api) - implementation(projects.feature.onboarding.impl) - implementation(libs.koin.core) } } } diff --git a/feature/onboarding/impl/build.gradle.kts b/feature/onboarding/impl/build.gradle.kts deleted file mode 100644 index f0945d2..0000000 --- a/feature/onboarding/impl/build.gradle.kts +++ /dev/null @@ -1,58 +0,0 @@ -plugins { - id("yaseyo.kmp.library") - alias(libs.plugins.composeCompiler) -} - -kmpLibrary {} - -kotlin { - androidLibrary { - namespace = "dev.yaseyo.onboarding.impl" - - withHostTestBuilder { - } - - withDeviceTestBuilder { - sourceSetTreeName = "test" - }.configure { - instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - } - - sourceSets { - commonMain { - dependencies { - implementation(libs.kotlin.stdlib) - api(projects.feature.onboarding.api) - implementation(libs.koin.core) - } - } - - commonTest { - dependencies { - implementation(libs.kotlin.test) - } - } - - androidMain { - dependencies { - implementation(libs.compose.foundation) - implementation(libs.compose.material3) - implementation(libs.koin.android) - } - } - - getByName("androidDeviceTest") { - dependencies { - implementation(libs.androidx.runner) - implementation(libs.androidx.core) - implementation(libs.androidx.testExt.junit) - } - } - - iosMain { - dependencies { - } - } - } -} diff --git a/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureModule.kt b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureModule.kt deleted file mode 100644 index b96a822..0000000 --- a/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureModule.kt +++ /dev/null @@ -1,9 +0,0 @@ -package dev.yaseyo.onboarding - -import dev.yaseyo.onboarding.api.di.onboardingModule -import dev.yaseyo.onboarding.impl.onboardingFeatureAndroidModule -import org.koin.dsl.module - -val onboardingFeatureModule = module { - includes(onboardingModule, onboardingFeatureAndroidModule) -} diff --git a/libs/di/build.gradle.kts b/libs/di/build.gradle.kts index bc74730..1f92ceb 100644 --- a/libs/di/build.gradle.kts +++ b/libs/di/build.gradle.kts @@ -48,6 +48,8 @@ kotlin { dependencies { implementation(libs.koin.android) implementation(libs.androidx.startup.runtime) + + implementation(projects.feature.onboarding.android) } } diff --git a/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt b/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt index 7d9bbc4..24a1147 100644 --- a/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt +++ b/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt @@ -3,7 +3,7 @@ package dev.yaseyo.di import android.content.Context import androidx.startup.Initializer import dev.yaseyo.navigation.navigationAndroidModule -import dev.yaseyo.onboarding.onboardingFeatureModule +import dev.yaseyo.onboarding.android.onboardingFeatureAndroidModule import org.koin.android.ext.koin.androidContext import org.koin.core.KoinApplication import org.koin.core.context.startKoin @@ -12,7 +12,7 @@ class KoinInitializer : Initializer { override fun create(context: Context): KoinApplication = startKoin { androidContext(context) - modules(appModules + navigationAndroidModule + onboardingFeatureModule) + modules(appModules + navigationAndroidModule + onboardingFeatureAndroidModule) } override fun dependencies(): List>> = emptyList() diff --git a/settings.gradle.kts b/settings.gradle.kts index 31f8558..0234004 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -46,4 +46,4 @@ include(":libs:network") include(":libs:di") include(":feature:onboarding") include(":feature:onboarding:api") -include(":feature:onboarding:impl") +include(":feature:onboarding:android") From 96e14638cebbbcbe6ae716e075c62f640142532e Mon Sep 17 00:00:00 2001 From: Taki Date: Thu, 23 Apr 2026 02:00:46 +0900 Subject: [PATCH 33/33] Update module structure rules and Koin DI patterns in Claude skill guides * Revise `:feature` module structure to include `:api`, optional `:impl`, and `:android` (pure Android) modules. * Update parent proxy module pattern to focus on transitively re-exporting `:api` without hosting source code or aggregated Koin modules. * Centralize Koin DI composition in `:libs:di`, referencing platform-specific and common modules directly instead of using per-feature aggregators. * Document `ModuleBoundaryEnforcement` rules for internal module dependencies. * Update step-by-step feature creation and Koin wiring checklists to reflect the new architecture. --- .claude/skills/create-new-feature/SKILL.md | 155 +++++++++++--------- .claude/skills/wiring-up-koin/SKILL.md | 66 +++++---- .claude/skills/working-with-gradle/SKILL.md | 79 ++++++---- 3 files changed, 176 insertions(+), 124 deletions(-) diff --git a/.claude/skills/create-new-feature/SKILL.md b/.claude/skills/create-new-feature/SKILL.md index 27181e0..b87a852 100644 --- a/.claude/skills/create-new-feature/SKILL.md +++ b/.claude/skills/create-new-feature/SKILL.md @@ -6,111 +6,128 @@ description: This skill is to instruct you how to go about implementing a new fe # How to create a new feature ## Module structure rules -- Multi-module KMP project. No single `:shared` module. -- `:lib` modules (e.g. `:lib:auth`) — core functionality or I/O. Usually `:api` and `:impl` children, plus a parent proxy module unless too simple to warrant it. -- `:feature` modules (e.g. `:feature:onboarding`) — user-facing features. Usually `:api` and `:impl` children, plus a parent proxy module. - - `:feature::api` — routes, use case interfaces, domain models, common Koin module. Lives in `commonMain`. - - `:feature::impl` — concrete implementations and platform-specific code. Android nav wiring lives in `androidMain`. - - `:feature:` (parent) — umbrella/proxy. Depends on `:api` (via `api(...)`) and `:impl`. Owns the aggregated Koin module that combines both. **This is what `:libs:di` and other consumers depend on — never `:api` or `:impl` directly.** -- Always get the user's architectural approval before each step. -- After any new feature or tech stack choice lands, update the project README. + +Multi-module KMP project. No single `:shared` module. + +### `:feature` modules (e.g. `:feature:onboarding`) +User-facing features. Structure: + +``` +:feature: ← parent, thin proxy. No source. Just api(:api). + :api ← REQUIRED. KMP. Public surface: routes, use case interfaces, domain models, api Koin module. + :impl ← OPTIONAL. Only create when there's shared commonMain implementation to hide behind :api. + :android ← pure Android library. Nav entries, ViewModels, Compose, FeatureNavigation impl, Android Koin module. +``` + +Rules: +- `:api` is mandatory. +- `:impl` is case-by-case — add only when there's actual shared Kotlin implementation that warrants hiding. Don't create empty shells. +- `:android` depends on `:impl` when one exists, otherwise depends on `:api` directly. +- Parent has **no source**. It only exists to re-export `:api` via `api(projects.feature..api)`, so consumers depending on the parent get `:api` transitively without needing to know about the internal split. +- iOS presentation lives in the Xcode/SwiftUI project — **not** as a Gradle module. iOS consumes the umbrella `:libs:di` framework for use cases. + +### `:lib` modules (e.g. `:lib:auth`) +Core functionality or I/O. Structure is `:api` + `:impl` + parent aggregator (see existing `libs/auth` and `libs/user` for the pattern). Parent `api`-depends on `:api`, `implementation`-depends on `:impl`. + +## Always get architectural approval before each step. After any new feature lands, update the project README. --- -## Step-by-step: wiring a new feature into navigation +## Step-by-step: adding a new `:feature:` -### 1. Define routes in `:feature::api` -File: `feature//api/src/commonMain/kotlin/dev/yaseyo//api/navigation/Routes.kt` +### 1. Create `:feature::api` +File: `feature//api/build.gradle.kts` — applies `yaseyo.kmp.library`, namespace `dev.yaseyo..api`, commonMain deps for what the public API needs. +Place in `commonMain`: +- `dev.yaseyo..api.navigation.Routes` — `sealed interface Routes : AppRoute` +- `dev.yaseyo..api.model.*` — domain models +- `dev.yaseyo..api.*` — use case classes/interfaces +- `dev.yaseyo..api.di.Module.kt` — `val Module = module { ... }` with bindings that are platform-agnostic + +### 2. Create `:feature:` (parent proxy) +File: `feature//build.gradle.kts`: ```kotlin -sealed interface Routes : AppRoute { - data object : Routes - // add more screens as needed +plugins { + id("yaseyo.kmp.library") } -``` -### 2. Create the `FeatureNavigation` impl in `:feature::impl` -File: `feature//impl/src/androidMain/kotlin/dev/yaseyo//impl/FeatureNavigation.kt` +kotlin { + androidLibrary { namespace = "dev.yaseyo." } -```kotlin -internal class FeatureNavigation : FeatureNavigation { - override val navEntryProvider: EntryProviderScope.() -> Unit = { - entry<Routes.> { - // Composable UI goes here + sourceSets { + commonMain.dependencies { + api(projects.feature..api) } } } ``` +No `src/` directory. Nothing else. -### 3. Create the Koin android module in `:feature::impl` -File: `feature//impl/src/androidMain/kotlin/dev/yaseyo//impl/FeatureAndroidModule.kt` - -```kotlin -val FeatureAndroidModule = module { - factory { FeatureNavigation() } -} -``` - -Key rule: **do not wrap in `activityRetainedScope`**. Register directly in the root scope so `getKoin().getAll()` can collect it. +### 3. Create `:feature::android` +File: `feature//android/build.gradle.kts` — applies `yaseyo.android.library` (and `libs.plugins.composeCompiler` if the module uses Compose). Namespace `dev.yaseyo..android`. Depends on `projects.feature..api` (or `projects.feature..impl` if one exists). -### 4. Create the parent proxy module's aggregated Koin module -File: `feature//src/androidMain/kotlin/dev/yaseyo//Module.kt` +Place in `src/main/kotlin/dev/yaseyo//android/`: +- `FeatureNavigation.kt` — `internal class FeatureNavigation : FeatureNavigation { ... }` with nav entries +- `FeatureAndroidModule.kt` — `val FeatureAndroidModule = module { factory { FeatureNavigation() } }` -This is the single public entry point for all of this feature's DI. It includes both the `:api` and `:impl` modules: +Do **not** wrap in `activityRetainedScope`. Register in the root scope (see `wiring-up-koin` skill). +### 4. Register modules in `settings.gradle.kts` ```kotlin -val Module = module { - includes(ApiModule, FeatureAndroidModule) -} +include(":feature:") +include(":feature::api") +include(":feature::android") +// include(":feature::impl") only if :impl was created ``` -The parent's `build.gradle.kts` must depend on both children: +### 5. Wire up in `:libs:di` +`:libs:di` is the DI composition root — the ONE module allowed to depend on `:android` from outside its feature tree. + +In `libs/di/build.gradle.kts`: ```kotlin commonMain.dependencies { - api(projects.feature..api) // transitive — exposes the public API - implementation(projects.feature..impl) + api(projects.feature.) // transitive :api exposure +} +androidMain.dependencies { + implementation(projects.feature..android) } ``` -### 5. Register the parent module in `KoinInitializer` -File: `libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt` - +In `libs/di/src/commonMain/.../AppModules.kt`: ```kotlin -modules(appModules + navigationAndroidModule + Module) +import dev.yaseyo..api.di.Module +// add to appModules list ``` -`:libs:di` must only depend on `projects.feature.` (the parent) — never on `:api` or `:impl` directly. - ---- - -## How `App.kt` collects all features (do not change this) - -`App.kt` uses `getKoin().getAll()` to collect every registered `FeatureNavigation` instance, then wires them all into the Nav3 `entryProvider` via the `registerAll()` extension: +In `libs/di/src/androidMain/.../KoinInitializer.kt`: +```kotlin +import dev.yaseyo..android.FeatureAndroidModule +// add to startKoin { modules(...) } call +``` +### 6. iOS framework export (if the feature is consumed from Swift) +In `libs/di/build.gradle.kts` iOS target block: ```kotlin -val allFeatureNavigation = getKoin().getAll() +export(projects.feature.) +``` +Exports the parent, which transitively exposes `:api` types to Swift. -NavDisplay( - ... - entryProvider = allFeatureNavigation.registerAll(), -) +--- -private fun List.registerAll(): (AppRoute) -> NavEntry = - entryProvider { - forEach { it.navEntryProvider(this) } - } -``` +## How `App.kt` collects all Android nav entries (do not change this) -Each feature only needs to register its `factory` — `App.kt` picks it up automatically. +`App.kt` uses `getKoin().getAll()` to collect every `FeatureNavigation` binding, then wires them all into the Nav3 `entryProvider` via the `registerAll()` extension. Each `:android` module only needs to register its own `factory` — `App.kt` picks it up automatically. --- -## Checklist for a new feature +## Checklist -- [ ] Routes defined in `:api` as a `sealed interface` extending `AppRoute` -- [ ] `FeatureNavigation` created in `:impl/androidMain` -- [ ] `:impl` Koin module registering `factory` in root scope -- [ ] Parent proxy module created with aggregated Koin module -- [ ] `:libs:di` updated to depend on the parent (not `:api`/`:impl`) and include the aggregated module +- [ ] `:api` created (routes, use cases, common Koin module) +- [ ] Parent proxy created (no source, just `api(:api)`) +- [ ] `:android` created (nav impl, android Koin module) — depends on `:api` or `:impl` +- [ ] `:impl` added only if there's actual shared implementation to justify it +- [ ] `settings.gradle.kts` updated +- [ ] `:libs:di` wired up: commonMain imports `:api`'s module, androidMain imports `:android`'s module +- [ ] iOS export added if Swift consumption expected - [ ] User approved architecture at each step - [ ] README updated diff --git a/.claude/skills/wiring-up-koin/SKILL.md b/.claude/skills/wiring-up-koin/SKILL.md index 93a3bce..7156731 100644 --- a/.claude/skills/wiring-up-koin/SKILL.md +++ b/.claude/skills/wiring-up-koin/SKILL.md @@ -5,52 +5,62 @@ description: This skill is to instruct you how to go about setting up the wiring # How to wire up Koin -## Module ownership rules +## Composition-root model -- `:lib` and `:feature` modules each have `:api`, `:impl`, and a **parent proxy** module. -- `:api` owns a Koin module for its own definitions (use case bindings, interfaces, etc.). -- `:impl` owns a Koin module for its concrete implementations and platform-specific registrations. -- The **parent** module owns an **aggregated Koin module** that `includes(...)` both `:api` and `:impl` modules. This is the only Koin module that consumers (e.g. `:libs:di`) ever reference. -- `:libs:di` collects all parent aggregated modules and passes them to `startKoin { modules(...) }`. -- Consumers must **never** depend on `:api` or `:impl` directly — always depend on the parent. +`:libs:di` is the single composition root for Dependency Injection. It: +- Assembles all Koin modules in `AppModules.kt` (commonMain) and `KoinInitializer.kt` (androidMain) +- Is the **only** module outside of a feature tree allowed to reference `:android` / `:impl` directly (enforced by the `yaseyo.kmp.library` / `yaseyo.android.library` convention plugin linter) +- No aggregator/umbrella Koin module per feature. `:libs:di` references the common and platform-specific Koin modules directly. -## Scope rules - -- Register definitions in the **root scope** (`module { factory { ... } }`) unless there is a specific reason to scope them. -- Do **not** wrap definitions in `activityRetainedScope { }` unless the definition must be tied to an activity's retained lifecycle. Scoped definitions are invisible to root-scope lookups like `getKoin().getAll()`. - -## Multi-binding with `getAll` +## Module ownership per module type -When multiple modules contribute implementations of the same interface (e.g. `FeatureNavigation`), each registers its own `factory`. Call `getKoin().getAll()` to collect all of them at the call site. - -## Structure per module type - -### `:feature::api` (commonMain) +### `:feature::api` (KMP, commonMain) ```kotlin -val ApiModule = module { - // interfaces, use case factories, etc. +val Module = module { + factoryOf(::SomeUseCase) + // platform-agnostic bindings only } ``` -### `:feature::impl` (androidMain or commonMain) +### `:feature::impl` (KMP, commonMain) — optional +If an `:impl` exists, it hosts bindings for implementations that should not leak via `:api`. Define a separate `val ImplModule = module { ... }` if needed. + +### `:feature::android` (pure Android library) ```kotlin val FeatureAndroidModule = module { factory { FeatureNavigation() } - // other impl-specific bindings + // Android-specific bindings: ViewModels, platform services, etc. } ``` -### `:feature:` parent (androidMain) +### `:libs:di` +`AppModules.kt` (commonMain): ```kotlin -val Module = module { - includes(ApiModule, FeatureAndroidModule) -} +internal val appModules = listOf( + // ... + Module, // from :api +) ``` -### `:libs:di` `KoinInitializer` +`KoinInitializer.kt` (androidMain): ```kotlin startKoin { androidContext(context) - modules(appModules + navigationAndroidModule + Module + ...) + modules(appModules + navigationAndroidModule + FeatureAndroidModule) } ``` + +## Scope rules + +- Register definitions in the **root scope** (`module { factory { ... } }`) unless there is a specific reason to scope them. +- Do **not** wrap definitions in `activityRetainedScope { }` unless the definition must be tied to an activity's retained lifecycle. Scoped definitions are invisible to root-scope lookups like `getKoin().getAll()`. + +## Multi-binding with `getAll` + +When multiple `:android` modules contribute implementations of the same interface (e.g. `FeatureNavigation`), each registers its own `factory`. Call `getKoin().getAll()` at the call site to collect all of them. This is how `App.kt` aggregates nav entries across features. + +## What NOT to do + +- **Don't create a per-feature aggregator Koin module** in the parent. The parent has no source. Aggregation happens at the `:libs:di` composition root. +- **Don't have the parent depend on `:android`** — it would create a project cycle (`:android` depends on `:api`/`:impl`, which live under parent). +- **Don't include `:api`'s Koin module from inside `:android`'s module**. `:libs:di` registers both separately. Using `includes(...)` double-registers. diff --git a/.claude/skills/working-with-gradle/SKILL.md b/.claude/skills/working-with-gradle/SKILL.md index f73af82..9bf4e1f 100644 --- a/.claude/skills/working-with-gradle/SKILL.md +++ b/.claude/skills/working-with-gradle/SKILL.md @@ -3,39 +3,64 @@ name: working-with-gradle description: This skill is to instruct you how to go about configuring a new gradle module or altering the gradle configuration of this project by adding/removing/updating dependencies. --- -# How to teach me or explain to me +# Working with Gradle -## Instructions +## General rules -- When adding a new KMP module, use the convention plugin/s defined in the build-logic module -- When adding a plugin or configuring a block in build.gradle.kts, consider adding it into either an - existing convention plugin or suggesting the creation of a new one. -- When adding a plugin, don't forget to add the plugin to the project level build.gradle.kts too! +- When adding a new module, pick a convention plugin from `build-logic` — do not hand-roll `android { }` / `kotlin { }` config. +- When adding a new plugin to the catalog, also add it to the project-level `build.gradle.kts` if it needs to be on the classpath. +- When you find yourself repeating config across modules, promote it into an existing convention plugin (or suggest creating a new one) rather than duplicating. -## Parent proxy module pattern +## Convention plugins -Every `:feature:` and `:lib:` group uses a parent proxy module. The parent: -- Has its own `build.gradle.kts` with **no namespace** (it is not an Android library itself, just a KMP aggregator) -- Declares `api(projects.feature..api)` so consumers get the public API transitively -- Declares `implementation(projects.feature..impl)` to pull in the concrete implementations -- Hosts the aggregated Koin module (see `wiring-up-koin` skill) +Two convention plugins cover all library modules: -Consumers (e.g. `:libs:di`, `:composeApp`) depend **only** on the parent — never on `:api` or `:impl` directly. This keeps the dependency graph clean and prevents leaking implementation details. +### `yaseyo.kmp.library` +KMP modules. Applies: +- `org.jetbrains.kotlin.multiplatform` +- `com.android.kotlin.multiplatform.library` +- `com.android.lint` +- `org.jetbrains.kotlin.plugin.serialization` +- iOS targets (`iosArm64`, `iosSimulatorArm64`) -Example parent `build.gradle.kts`: +Exposes a `kmpLibrary { }` DSL for iOS framework production (`iosFrameworkName`, `enableSkie`). Framework production is restricted to `:libs:di` only — attempting to set `iosFrameworkName` in any other module fails the build. Reason: iOS can load at most one K/N framework per process. + +### `yaseyo.android.library` +Pure Android libraries (typically `:feature:*:android`). Applies: +- `com.android.library` (includes lint) +- `org.jetbrains.kotlin.android` + +Sets min/compile SDK from the version catalog, Java 11 source/target compatibility, and Kotlin JVM 11 target to match the rest of the project. + +## Module structure patterns + +### `:feature:` +See `create-new-feature` skill. Structure: +- Parent (`yaseyo.kmp.library`) — no source, just `api(:api)` in commonMain +- `:api` (`yaseyo.kmp.library`) — required, commonMain public surface +- `:impl` (`yaseyo.kmp.library`) — optional +- `:android` (`yaseyo.android.library`) — pure Android, depends on `:impl` if exists else `:api` + +### `:lib:` +Parent (`yaseyo.kmp.library`) with commonMain aggregating `api(:api)` + `implementation(:impl)`. See `libs/auth` and `libs/user` for the pattern. + +## Internal module boundary linter + +`ModuleBoundaryEnforcement.kt` (invoked automatically by both convention plugins) enforces that `:impl` and `:android` modules are internal: + +- **Allowed** to depend on `:feature:onboarding:impl` / `:feature:onboarding:android`: + - Any module within the same feature tree (e.g. `:feature:onboarding` or `:feature:onboarding:android`) + - `:libs:di` (DI composition root, whitelisted) +- **Not allowed**: any other module. The build fails with a "DESIGN BREACH" message showing the exact line to change. + +To extend the rule (e.g. add another internal suffix, or whitelist another module), edit: +`build-logic/convention/src/main/kotlin/dev/yaseyo/ModuleBoundaryEnforcement.kt` + +## iOS framework export (for Swift consumption) + +In `libs/di/build.gradle.kts`, inside the iOS target block: ```kotlin -plugins { - id("yaseyo.kmp.library") -} - -kotlin { - sourceSets { - commonMain.dependencies { - api(projects.feature..api) - implementation(projects.feature..impl) - } - } -} +export(projects.feature.) // always the parent, never :api/:impl/:android ``` -## Examples \ No newline at end of file +And `api(projects.feature.)` in commonMain dependencies so that the exported types are visible at compile time.