diff --git a/.gitignore b/.gitignore
index f64d396..6cb6d79 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,4 +19,5 @@ captures
!*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings
node_modules/
-CLAUDE.md
\ No newline at end of file
+CLAUDE.md
+.hotswan/
diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index 7fc4d3e..125226c 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -82,6 +82,12 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
+ sourceSets {
+ getByName("debug") {
+ manifest.srcFile("src/androidDebug/AndroidManifest.xml")
+ res.srcDirs("src/androidDebug/res")
+ }
+ }
}
dependencies {
diff --git a/composeApp/src/androidDebug/AndroidManifest.xml b/composeApp/src/androidDebug/AndroidManifest.xml
new file mode 100644
index 0000000..06ac322
--- /dev/null
+++ b/composeApp/src/androidDebug/AndroidManifest.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/composeApp/src/androidDebug/res/xml/network_security_config.xml b/composeApp/src/androidDebug/res/xml/network_security_config.xml
new file mode 100644
index 0000000..6b2347b
--- /dev/null
+++ b/composeApp/src/androidDebug/res/xml/network_security_config.xml
@@ -0,0 +1,6 @@
+
+
+
+ 10.0.2.2
+
+
diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt
index a8c0c93..d81c458 100644
--- a/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt
+++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt
@@ -37,6 +37,7 @@ fun App(navigator: Navigator) =
appRouter.events.collect { event ->
when (event) {
is NavigationEvent.GoTo -> navigator.goTo(event.route)
+ is NavigationEvent.ClearBackStackAndGoTo -> navigator.clearAndGoTo(event.route)
is NavigationEvent.GoBack -> navigator.goBack()
}
}
diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt
index 6e3b7f8..44f6a21 100644
--- a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt
@@ -11,6 +11,7 @@ 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.FinishHandler
import dev.yaseyo.navigation.Navigator
import org.koin.android.scope.AndroidScopeComponent
import org.koin.androidx.scope.activityRetainedScope
@@ -19,7 +20,8 @@ import org.koin.core.scope.Scope
class MainActivity :
ComponentActivity(),
- AndroidScopeComponent {
+ AndroidScopeComponent,
+ FinishHandler {
override val scope: Scope by activityRetainedScope()
private val viewModel: MainViewModel by viewModel()
@@ -30,6 +32,8 @@ class MainActivity :
enableEdgeToEdge()
super.onCreate(savedInstanceState)
+ scope.declare(this as FinishHandler, allowOverride = true)
+
splashScreen.setKeepOnScreenCondition {
viewModel.startDestination.value == null
}
diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts
new file mode 100644
index 0000000..0e9c093
--- /dev/null
+++ b/feature/home/build.gradle.kts
@@ -0,0 +1,74 @@
+plugins {
+ id("yaseyo.kmp.library")
+ alias(libs.plugins.composeMultiplatform)
+ alias(libs.plugins.composeCompiler)
+}
+
+kotlin {
+ android {
+ namespace = "dev.yaseyo.home"
+
+ withHostTestBuilder {
+ sourceSetTreeName = "test"
+ }
+
+ withDeviceTestBuilder {
+ }.configure {
+ instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+ }
+
+ sourceSets {
+ commonMain {
+ dependencies {
+ implementation(libs.kotlin.stdlib)
+ implementation(libs.koin.core)
+ implementation(libs.koin.core.viewmodel)
+ implementation(libs.koin.compose)
+ implementation(libs.koin.compose.viewmodel)
+ api(libs.kotlinx.coroutines.core)
+
+ implementation(projects.libs.coroutines)
+ api(projects.libs.navigation)
+ implementation(projects.libs.design)
+
+ implementation(libs.androidx.lifecycle.viewmodel)
+ implementation(libs.compose.runtime)
+ implementation(libs.compose.foundation)
+ implementation(libs.compose.material3)
+ implementation(libs.compose.uiToolingPreview)
+ implementation(libs.compose.material.icons.extended)
+ implementation(libs.compose.navigationevent)
+ }
+ }
+
+ commonTest {
+ dependencies {
+ implementation(libs.kotlin.test)
+ implementation(libs.turbine)
+ implementation(projects.libs.coroutines.testing)
+ }
+ }
+
+ androidMain {
+ dependencies {
+ implementation(libs.koin.android)
+ implementation(libs.koin.compose.nav3)
+ implementation(libs.androidx.ui.tooling)
+ }
+ }
+
+ getByName("androidDeviceTest") {
+ dependencies {
+ implementation(libs.androidx.runner)
+ implementation(libs.androidx.core)
+ implementation(libs.androidx.testExt.junit)
+ }
+ }
+
+ iosMain {
+ dependencies {
+ }
+ }
+ }
+}
diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts
index 08d597e..e50bc1b 100644
--- a/feature/onboarding/build.gradle.kts
+++ b/feature/onboarding/build.gradle.kts
@@ -9,10 +9,10 @@ kotlin {
namespace = "dev.yaseyo.onboarding"
withHostTestBuilder {
+ sourceSetTreeName = "test"
}
withDeviceTestBuilder {
- sourceSetTreeName = "test"
}.configure {
instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -31,8 +31,10 @@ kotlin {
implementation(projects.libs.auth)
implementation(projects.libs.user)
+ implementation(projects.libs.coroutines)
api(projects.libs.navigation)
implementation(projects.libs.design)
+ implementation(projects.libs.datastore)
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.compose.runtime)
@@ -47,6 +49,8 @@ kotlin {
commonTest {
dependencies {
implementation(libs.kotlin.test)
+ implementation(libs.turbine)
+ implementation(projects.libs.coroutines.testing)
}
}
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/datasource/OnboardingLocalDataSource.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/datasource/OnboardingLocalDataSource.kt
new file mode 100644
index 0000000..e96ce41
--- /dev/null
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/datasource/OnboardingLocalDataSource.kt
@@ -0,0 +1,12 @@
+package dev.yaseyo.onboarding.datasource
+
+import dev.yaseyo.onboarding.model.OnboardingDraft
+import kotlinx.coroutines.flow.Flow
+
+internal interface OnboardingLocalDataSource {
+ val draft: Flow
+
+ suspend fun saveDraft(draft: OnboardingDraft)
+
+ suspend fun clearDraft()
+}
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/datasource/OnboardingLocalDataSourceImpl.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/datasource/OnboardingLocalDataSourceImpl.kt
new file mode 100644
index 0000000..5a5e447
--- /dev/null
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/datasource/OnboardingLocalDataSourceImpl.kt
@@ -0,0 +1,92 @@
+package dev.yaseyo.onboarding.datasource
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import dev.yaseyo.onboarding.model.OnboardingDraft
+import dev.yaseyo.user.api.ActivityLevel
+import dev.yaseyo.user.api.Sex
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+internal class OnboardingLocalDataSourceImpl(
+ private val dataStore: DataStore,
+) : OnboardingLocalDataSource {
+ override val draft: Flow = dataStore.data.map { prefs ->
+ OnboardingDraft(
+ currentStepIndex = prefs[Keys.CURRENT_STEP_INDEX] ?: 0,
+ name = prefs[Keys.NAME] ?: "",
+ sex = prefs[Keys.SEX]?.toSex(),
+ dobDay = prefs[Keys.DOB_DAY] ?: 1,
+ dobMonth = prefs[Keys.DOB_MONTH] ?: 1,
+ dobYear = prefs[Keys.DOB_YEAR] ?: 1990,
+ heightCm = prefs[Keys.HEIGHT_CM] ?: 170,
+ currentWeightKg = prefs[Keys.CURRENT_WEIGHT_KG] ?: 70,
+ activityLevel = prefs[Keys.ACTIVITY_LEVEL]?.toActivityLevel(),
+ targetWeightKg = prefs[Keys.TARGET_WEIGHT_KG] ?: 70,
+ dailyDeficitKcal = prefs[Keys.DAILY_DEFICIT_KCAL] ?: 500,
+ )
+ }
+
+ override suspend fun saveDraft(draft: OnboardingDraft) {
+ dataStore.edit { prefs ->
+ prefs[Keys.CURRENT_STEP_INDEX] = draft.currentStepIndex
+ prefs[Keys.NAME] = draft.name
+ draft.sex?.let { prefs[Keys.SEX] = it.toStorageValue() } ?: prefs.remove(Keys.SEX)
+ prefs[Keys.DOB_DAY] = draft.dobDay
+ prefs[Keys.DOB_MONTH] = draft.dobMonth
+ prefs[Keys.DOB_YEAR] = draft.dobYear
+ prefs[Keys.HEIGHT_CM] = draft.heightCm
+ prefs[Keys.CURRENT_WEIGHT_KG] = draft.currentWeightKg
+ draft.activityLevel?.let { prefs[Keys.ACTIVITY_LEVEL] = it.toStorageValue() } ?: prefs.remove(Keys.ACTIVITY_LEVEL)
+ prefs[Keys.TARGET_WEIGHT_KG] = draft.targetWeightKg
+ prefs[Keys.DAILY_DEFICIT_KCAL] = draft.dailyDeficitKcal
+ }
+ }
+
+ override suspend fun clearDraft() {
+ dataStore.edit { it.clear() }
+ }
+
+ private fun String.toSex(): Sex? =
+ when (this) {
+ "MALE" -> Sex.Male
+ "FEMALE" -> Sex.Female
+ else -> null
+ }
+
+ private fun String.toActivityLevel(): ActivityLevel? =
+ when (this) {
+ "SEDENTARY" -> ActivityLevel.Sedentary
+ "LIGHTLY_ACTIVE" -> ActivityLevel.LightlyActive
+ else -> null
+ }
+
+ private fun Sex.toStorageValue() =
+ when (this) {
+ Sex.Male -> "MALE"
+ Sex.Female -> "FEMALE"
+ }
+
+ private fun ActivityLevel.toStorageValue() =
+ when (this) {
+ ActivityLevel.Sedentary -> "SEDENTARY"
+ ActivityLevel.LightlyActive -> "LIGHTLY_ACTIVE"
+ }
+
+ private object Keys {
+ val CURRENT_STEP_INDEX = intPreferencesKey("current_step_index")
+ val NAME = stringPreferencesKey("name")
+ val SEX = stringPreferencesKey("sex")
+ val DOB_DAY = intPreferencesKey("dob_day")
+ val DOB_MONTH = intPreferencesKey("dob_month")
+ val DOB_YEAR = intPreferencesKey("dob_year")
+ val HEIGHT_CM = intPreferencesKey("height_cm")
+ val CURRENT_WEIGHT_KG = intPreferencesKey("current_weight_kg")
+ val ACTIVITY_LEVEL = stringPreferencesKey("activity_level")
+ val TARGET_WEIGHT_KG = intPreferencesKey("target_weight_kg")
+ val DAILY_DEFICIT_KCAL = intPreferencesKey("daily_deficit_kcal")
+ }
+}
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/di/OnboardingModule.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/di/OnboardingModule.kt
index 5802678..e4b6630 100644
--- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/di/OnboardingModule.kt
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/di/OnboardingModule.kt
@@ -1,12 +1,17 @@
package dev.yaseyo.onboarding.di
import dev.yaseyo.onboarding.ResolveStartDestinationUseCase
+import dev.yaseyo.onboarding.datasource.OnboardingLocalDataSource
+import dev.yaseyo.onboarding.datasource.OnboardingLocalDataSourceImpl
import dev.yaseyo.onboarding.network.OnboardingApi
import dev.yaseyo.onboarding.network.OnboardingApiImpl
+import dev.yaseyo.onboarding.repository.OnboardingRepository
+import dev.yaseyo.onboarding.repository.OnboardingRepositoryImpl
import dev.yaseyo.onboarding.ui.auth.AuthViewModel
import dev.yaseyo.onboarding.ui.profilesetup.ProfileSetupViewModel
import org.koin.core.module.dsl.factoryOf
import org.koin.core.module.dsl.viewModelOf
+import org.koin.dsl.bind
import org.koin.dsl.module
val onboardingModule = module {
@@ -14,5 +19,7 @@ val onboardingModule = module {
viewModelOf(::AuthViewModel)
single { OnboardingApiImpl(client = get()) }
+ single { OnboardingLocalDataSourceImpl(dataStore = get()) } bind OnboardingLocalDataSource::class
+ single { OnboardingRepositoryImpl(localDataSource = get(), api = get()) } bind OnboardingRepository::class
viewModelOf(::ProfileSetupViewModel)
}
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/ActivityLevelOption.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/ActivityLevelOption.kt
new file mode 100644
index 0000000..dd7f9bf
--- /dev/null
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/ActivityLevelOption.kt
@@ -0,0 +1,9 @@
+package dev.yaseyo.onboarding.model
+
+import dev.yaseyo.user.api.ActivityLevel
+
+internal data class ActivityLevelOption(
+ val level: ActivityLevel,
+ val label: String,
+ val description: String,
+)
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/GoalSuggestion.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/GoalSuggestion.kt
new file mode 100644
index 0000000..15c5e82
--- /dev/null
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/GoalSuggestion.kt
@@ -0,0 +1,8 @@
+package dev.yaseyo.onboarding.model
+
+internal data class GoalSuggestion(
+ val currentBmi: Double,
+ val currentBodyFatPct: Double?,
+ val currentBodyFatCategory: String?,
+ val suggestedTargetKg: Int,
+)
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/OnboardingDraft.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/OnboardingDraft.kt
new file mode 100644
index 0000000..c3d62d5
--- /dev/null
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/OnboardingDraft.kt
@@ -0,0 +1,18 @@
+package dev.yaseyo.onboarding.model
+
+import dev.yaseyo.user.api.ActivityLevel
+import dev.yaseyo.user.api.Sex
+
+internal data class OnboardingDraft(
+ val currentStepIndex: Int = 0,
+ val name: String = "",
+ val sex: Sex? = null,
+ val dobDay: Int = 1,
+ val dobMonth: Int = 1,
+ val dobYear: Int = 1990,
+ val heightCm: Int = 170,
+ val currentWeightKg: Int = 70,
+ val activityLevel: ActivityLevel? = null,
+ val targetWeightKg: Int = 70,
+ val dailyDeficitKcal: Int = 500,
+)
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/OnboardingSummary.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/OnboardingSummary.kt
new file mode 100644
index 0000000..d312a2f
--- /dev/null
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/OnboardingSummary.kt
@@ -0,0 +1,10 @@
+package dev.yaseyo.onboarding.model
+
+internal data class OnboardingSummary(
+ val tdee: Int,
+ val dailyCalorieTarget: Int,
+ val weeklyLossKg: Double,
+ val proteinG: Int,
+ val carbsG: Int,
+ val fatG: Int,
+)
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/navigation/OnboardingRoutes.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/navigation/OnboardingRoutes.kt
index 8b092b4..9a9a486 100644
--- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/navigation/OnboardingRoutes.kt
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/navigation/OnboardingRoutes.kt
@@ -1,11 +1,12 @@
package dev.yaseyo.onboarding.navigation
+import dev.yaseyo.navigation.AppRootRoute
import dev.yaseyo.navigation.AppRoute
import dev.yaseyo.navigation.ModalRoute
sealed interface OnboardingRoutes : AppRoute {
data object Auth : OnboardingRoutes {
- data object Landing : OnboardingRoutes
+ data object Landing : OnboardingRoutes, AppRootRoute
data object SignIn : OnboardingRoutes, ModalRoute
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/ActivityLevelNet.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/ActivityLevelNet.kt
new file mode 100644
index 0000000..ba98bbc
--- /dev/null
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/ActivityLevelNet.kt
@@ -0,0 +1,11 @@
+package dev.yaseyo.onboarding.network
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class ActivityLevelNet(
+ val level: String,
+ val label: String,
+ val description: String,
+ val multiplier: Double,
+)
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/GoalSuggestionNet.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/GoalSuggestionNet.kt
new file mode 100644
index 0000000..924450d
--- /dev/null
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/GoalSuggestionNet.kt
@@ -0,0 +1,22 @@
+package dev.yaseyo.onboarding.network
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class GoalSuggestionNet(
+ val suggestedTargetKg: Double,
+ val current: GoalStatsNet,
+)
+
+@Serializable
+internal data class GoalStatsNet(
+ val weightKg: Double,
+ val bmi: Double,
+ val bodyFat: BodyFatNet? = null,
+)
+
+@Serializable
+internal data class BodyFatNet(
+ val estimatedPct: Double,
+ val category: String,
+)
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/GoalSuggestionRequestNet.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/GoalSuggestionRequestNet.kt
new file mode 100644
index 0000000..b63ffb2
--- /dev/null
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/GoalSuggestionRequestNet.kt
@@ -0,0 +1,11 @@
+package dev.yaseyo.onboarding.network
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class GoalSuggestionRequestNet(
+ val sex: String,
+ val height: Double,
+ val dateOfBirth: String,
+ val currentWeightKg: Double,
+)
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingApi.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingApi.kt
index 02e3340..7dcbd79 100644
--- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingApi.kt
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingApi.kt
@@ -1,5 +1,31 @@
package dev.yaseyo.onboarding.network
+import dev.yaseyo.onboarding.model.ActivityLevelOption
+import dev.yaseyo.onboarding.model.GoalSuggestion
+import dev.yaseyo.onboarding.model.OnboardingStep
+import dev.yaseyo.onboarding.model.OnboardingSummary
+import dev.yaseyo.user.api.ActivityLevel
+import dev.yaseyo.user.api.Sex
+import kotlinx.datetime.LocalDate
+
internal interface OnboardingApi {
- suspend fun getSteps(): List
+ suspend fun getSteps(): List
+
+ suspend fun getActivityLevels(): List
+
+ suspend fun getGoalSuggestion(
+ sex: Sex,
+ heightCm: Int,
+ dateOfBirth: LocalDate,
+ currentWeightKg: Int,
+ ): GoalSuggestion
+
+ suspend fun getSummary(
+ sex: Sex,
+ heightCm: Int,
+ dateOfBirth: LocalDate,
+ currentWeightKg: Int,
+ activityLevel: ActivityLevel,
+ dailyDeficitKcal: Int,
+ ): OnboardingSummary
}
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingApiImpl.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingApiImpl.kt
index 83fcae2..d67a887 100644
--- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingApiImpl.kt
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingApiImpl.kt
@@ -1,11 +1,109 @@
package dev.yaseyo.onboarding.network
+import dev.yaseyo.onboarding.model.ActivityLevelOption
+import dev.yaseyo.onboarding.model.GoalSuggestion
+import dev.yaseyo.onboarding.model.OnboardingStep
+import dev.yaseyo.onboarding.model.OnboardingSummary
+import dev.yaseyo.user.api.ActivityLevel
+import dev.yaseyo.user.api.Sex
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.contentType
+import kotlinx.datetime.LocalDate
internal class OnboardingApiImpl(
private val client: HttpClient,
) : OnboardingApi {
- override suspend fun getSteps(): List = client.get("/onboarding/steps").body()
+ override suspend fun getSteps(): List =
+ client
+ .get("/onboarding/steps")
+ .body>()
+ .map { OnboardingStep(key = it.key, title = it.title, subtitle = it.subtitle) }
+
+ override suspend fun getActivityLevels(): List =
+ client
+ .get("/user/activity-levels")
+ .body>()
+ .mapNotNull { net ->
+ val level = when (net.level) {
+ "SEDENTARY" -> ActivityLevel.Sedentary
+ "LIGHTLY_ACTIVE" -> ActivityLevel.LightlyActive
+ else -> return@mapNotNull null
+ }
+ ActivityLevelOption(level = level, label = net.label, description = net.description)
+ }
+
+ override suspend fun getGoalSuggestion(
+ sex: Sex,
+ heightCm: Int,
+ dateOfBirth: LocalDate,
+ currentWeightKg: Int,
+ ): GoalSuggestion {
+ val net = client
+ .post("/onboarding/goal-suggestion") {
+ contentType(ContentType.Application.Json)
+ setBody(
+ GoalSuggestionRequestNet(
+ sex = sex.toApiValue(),
+ height = heightCm.toDouble(),
+ dateOfBirth = dateOfBirth.toString(),
+ currentWeightKg = currentWeightKg.toDouble(),
+ ),
+ )
+ }.body()
+ return GoalSuggestion(
+ currentBmi = net.current.bmi,
+ currentBodyFatPct = net.current.bodyFat?.estimatedPct,
+ currentBodyFatCategory = net.current.bodyFat?.category,
+ suggestedTargetKg = net.suggestedTargetKg.toInt(),
+ )
+ }
+
+ override suspend fun getSummary(
+ sex: Sex,
+ heightCm: Int,
+ dateOfBirth: LocalDate,
+ currentWeightKg: Int,
+ activityLevel: ActivityLevel,
+ dailyDeficitKcal: Int,
+ ): OnboardingSummary {
+ val net = client
+ .post("/onboarding/summary") {
+ contentType(ContentType.Application.Json)
+ setBody(
+ OnboardingSummaryRequestNet(
+ sex = sex.toApiValue(),
+ height = heightCm.toDouble(),
+ dateOfBirth = dateOfBirth.toString(),
+ currentWeightKg = currentWeightKg.toDouble(),
+ activityLevel = activityLevel.toApiValue(),
+ dailyDeficitKcal = dailyDeficitKcal,
+ ),
+ )
+ }.body()
+ return OnboardingSummary(
+ tdee = net.tdee.toInt(),
+ dailyCalorieTarget = net.dailyCalorieTarget.toInt(),
+ weeklyLossKg = net.weeklyLossKg,
+ proteinG = net.suggestedMacros.protein.toInt(),
+ carbsG = net.suggestedMacros.carbs.toInt(),
+ fatG = net.suggestedMacros.fat.toInt(),
+ )
+ }
+
+ private fun Sex.toApiValue(): String =
+ when (this) {
+ Sex.Male -> "MALE"
+ Sex.Female -> "FEMALE"
+ }
+
+ private fun ActivityLevel.toApiValue(): String =
+ when (this) {
+ ActivityLevel.Sedentary -> "SEDENTARY"
+ ActivityLevel.LightlyActive -> "LIGHTLY_ACTIVE"
+ }
}
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingSummaryNet.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingSummaryNet.kt
new file mode 100644
index 0000000..1e1c302
--- /dev/null
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingSummaryNet.kt
@@ -0,0 +1,28 @@
+package dev.yaseyo.onboarding.network
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class OnboardingSummaryRequestNet(
+ val sex: String,
+ val height: Double,
+ val dateOfBirth: String,
+ val currentWeightKg: Double,
+ val activityLevel: String,
+ val dailyDeficitKcal: Int,
+)
+
+@Serializable
+internal data class OnboardingSummaryNet(
+ val tdee: Double,
+ val dailyCalorieTarget: Double,
+ val weeklyLossKg: Double,
+ val suggestedMacros: SuggestedMacrosNet,
+)
+
+@Serializable
+internal data class SuggestedMacrosNet(
+ val protein: Double,
+ val carbs: Double,
+ val fat: Double,
+)
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/repository/OnboardingRepository.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/repository/OnboardingRepository.kt
new file mode 100644
index 0000000..2a4d0af
--- /dev/null
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/repository/OnboardingRepository.kt
@@ -0,0 +1,39 @@
+package dev.yaseyo.onboarding.repository
+
+import dev.yaseyo.onboarding.model.ActivityLevelOption
+import dev.yaseyo.onboarding.model.GoalSuggestion
+import dev.yaseyo.onboarding.model.OnboardingDraft
+import dev.yaseyo.onboarding.model.OnboardingStep
+import dev.yaseyo.onboarding.model.OnboardingSummary
+import dev.yaseyo.user.api.ActivityLevel
+import dev.yaseyo.user.api.Sex
+import kotlinx.coroutines.flow.Flow
+import kotlinx.datetime.LocalDate
+
+internal interface OnboardingRepository {
+ val draft: Flow
+
+ suspend fun saveDraft(draft: OnboardingDraft)
+
+ suspend fun clearDraft()
+
+ suspend fun getSteps(): Result>
+
+ suspend fun getActivityLevels(): Result>
+
+ suspend fun getGoalSuggestion(
+ sex: Sex,
+ heightCm: Int,
+ dateOfBirth: LocalDate,
+ currentWeightKg: Int,
+ ): Result
+
+ suspend fun getSummary(
+ sex: Sex,
+ heightCm: Int,
+ dateOfBirth: LocalDate,
+ currentWeightKg: Int,
+ activityLevel: ActivityLevel,
+ dailyDeficitKcal: Int,
+ ): Result
+}
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/repository/OnboardingRepositoryImpl.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/repository/OnboardingRepositoryImpl.kt
new file mode 100644
index 0000000..c8262a2
--- /dev/null
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/repository/OnboardingRepositoryImpl.kt
@@ -0,0 +1,45 @@
+package dev.yaseyo.onboarding.repository
+
+import dev.yaseyo.onboarding.datasource.OnboardingLocalDataSource
+import dev.yaseyo.onboarding.model.ActivityLevelOption
+import dev.yaseyo.onboarding.model.GoalSuggestion
+import dev.yaseyo.onboarding.model.OnboardingDraft
+import dev.yaseyo.onboarding.model.OnboardingStep
+import dev.yaseyo.onboarding.model.OnboardingSummary
+import dev.yaseyo.onboarding.network.OnboardingApi
+import dev.yaseyo.user.api.ActivityLevel
+import dev.yaseyo.user.api.Sex
+import kotlinx.coroutines.flow.Flow
+import kotlinx.datetime.LocalDate
+
+internal class OnboardingRepositoryImpl(
+ private val localDataSource: OnboardingLocalDataSource,
+ private val api: OnboardingApi,
+) : OnboardingRepository {
+ override val draft: Flow = localDataSource.draft
+
+ override suspend fun saveDraft(draft: OnboardingDraft) = localDataSource.saveDraft(draft)
+
+ override suspend fun clearDraft() = localDataSource.clearDraft()
+
+ override suspend fun getSteps(): Result> = runCatching { api.getSteps() }
+
+ override suspend fun getActivityLevels(): Result> = runCatching { api.getActivityLevels() }
+
+ override suspend fun getGoalSuggestion(
+ sex: Sex,
+ heightCm: Int,
+ dateOfBirth: LocalDate,
+ currentWeightKg: Int,
+ ): Result = runCatching { api.getGoalSuggestion(sex, heightCm, dateOfBirth, currentWeightKg) }
+
+ override suspend fun getSummary(
+ sex: Sex,
+ heightCm: Int,
+ dateOfBirth: LocalDate,
+ currentWeightKg: Int,
+ activityLevel: ActivityLevel,
+ dailyDeficitKcal: Int,
+ ): Result =
+ runCatching { api.getSummary(sex, heightCm, dateOfBirth, currentWeightKg, activityLevel, dailyDeficitKcal) }
+}
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthLandingScreen.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthLandingScreen.kt
index daab7fb..6c45921 100644
--- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthLandingScreen.kt
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthLandingScreen.kt
@@ -221,7 +221,9 @@ private fun AuthField(
color = YaseyoTheme.colors.contentSecondary,
)
TextField(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(MaterialTheme.shapes.large),
state = state,
placeholder = { Text(text = placeholder, color = YaseyoTheme.colors.contentTertiary) },
lineLimits = TextFieldLineLimits.SingleLine,
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthViewModel.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthViewModel.kt
index da3fa64..e2a7a78 100644
--- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthViewModel.kt
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthViewModel.kt
@@ -50,7 +50,7 @@ internal class AuthViewModel(
signInResult.fold(
onSuccess = {
- router.navigate(OnboardingRoutes.ProfileSetup)
+ router.navigateAndClearBackStack(OnboardingRoutes.ProfileSetup)
},
onFailure = {
_action.send("Something went wrong")
@@ -79,7 +79,7 @@ internal class AuthViewModel(
signInResult.fold(
onSuccess = {
- router.navigate(OnboardingRoutes.ProfileSetup)
+ router.navigateAndClearBackStack(OnboardingRoutes.ProfileSetup)
},
onFailure = {
_action.send("Something went wrong")
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheetScreen.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheetScreen.kt
index 29ba7ea..21a6c82 100644
--- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheetScreen.kt
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheetScreen.kt
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -35,6 +36,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
@@ -112,7 +114,9 @@ internal fun SignInSheetContent(
)
},
enabled = !state.isLoading,
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
shape = MaterialTheme.shapes.large,
colors = ButtonDefaults.buttonColors(
containerColor = YaseyoTheme.colors.accentDefault,
@@ -157,7 +161,8 @@ private fun SheetEmailField(
TextField(
modifier = Modifier
.fillMaxWidth()
- .focusRequester(focusRequester),
+ .focusRequester(focusRequester)
+ .clip(MaterialTheme.shapes.large),
state = state,
placeholder = {
Text(text = "yamadataro@yaseyo.com", color = YaseyoTheme.colors.contentTertiary)
@@ -198,7 +203,9 @@ private fun SheetPasswordField(
color = YaseyoTheme.colors.contentSecondary,
)
TextField(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(MaterialTheme.shapes.large),
state = state,
placeholder = {
Text(text = "••••••••", color = YaseyoTheme.colors.contentTertiary)
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignUpSheetScreen.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignUpSheetScreen.kt
index f5cd9d4..19116ca 100644
--- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignUpSheetScreen.kt
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignUpSheetScreen.kt
@@ -3,6 +3,7 @@ package dev.yaseyo.onboarding.ui.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -32,6 +33,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
@@ -98,7 +100,8 @@ internal fun SignUpSheetContent(
enabled = !state.isLoading,
modifier = Modifier
.fillMaxWidth()
- .padding(top = 8.dp),
+ .padding(top = 8.dp)
+ .height(56.dp),
shape = MaterialTheme.shapes.large,
colors = ButtonDefaults.buttonColors(
containerColor = YaseyoTheme.colors.accentDefault,
@@ -143,7 +146,8 @@ private fun SheetEmailField(
TextField(
modifier = Modifier
.fillMaxWidth()
- .focusRequester(focusRequester),
+ .focusRequester(focusRequester)
+ .clip(MaterialTheme.shapes.large),
state = state,
placeholder = {
Text(text = "yamadataro@yaseyo.com", color = YaseyoTheme.colors.contentTertiary)
@@ -184,7 +188,9 @@ private fun SheetPasswordField(
color = YaseyoTheme.colors.contentSecondary,
)
TextField(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(MaterialTheme.shapes.large),
state = state,
placeholder = {
Text(text = "••••••••", color = YaseyoTheme.colors.contentTertiary)
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupEventHandler.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupEventHandler.kt
index 60bf42d..40b32d8 100644
--- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupEventHandler.kt
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupEventHandler.kt
@@ -1,5 +1,6 @@
package dev.yaseyo.onboarding.ui.profilesetup
+import dev.yaseyo.user.api.ActivityLevel
import dev.yaseyo.user.api.Sex
internal interface ProfileSetupEventHandler {
@@ -15,6 +16,21 @@ internal interface ProfileSetupEventHandler {
fun onDobYearChanged(year: Int)
+ // Step 3
+ fun onHeightChanged(heightCm: Int)
+
+ // Step 4
+ fun onCurrentWeightChanged(weightKg: Int)
+
+ // Step 5
+ fun onActivityLevelSelected(level: ActivityLevel)
+
+ // Step 6
+ fun onTargetWeightChanged(weightKg: Int)
+
+ // Step 7
+ fun onDeficitChanged(kcal: Int)
+
fun onContinue()
fun onBack()
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupScreen.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupScreen.kt
index 8a47f2b..a6dd262 100644
--- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupScreen.kt
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupScreen.kt
@@ -20,6 +20,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -31,17 +32,27 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigationevent.NavigationEventInfo
import androidx.navigationevent.compose.NavigationBackHandler
import androidx.navigationevent.compose.rememberNavigationEventState
+import dev.yaseyo.design.LocalSnackBarHostState
+import dev.yaseyo.design.YaseyoPreview
import dev.yaseyo.design.YaseyoTheme
import dev.yaseyo.onboarding.model.OnboardingStep
-import dev.yaseyo.onboarding.ui.profilesetup.widgets.AboutYouStep
+import dev.yaseyo.onboarding.ui.profilesetup.widgets.ActivityLevelStep
+import dev.yaseyo.onboarding.ui.profilesetup.widgets.CurrentWeightStep
+import dev.yaseyo.onboarding.ui.profilesetup.widgets.DateOfBirthStep
+import dev.yaseyo.onboarding.ui.profilesetup.widgets.DeficitStep
+import dev.yaseyo.onboarding.ui.profilesetup.widgets.GoalWeightStep
+import dev.yaseyo.onboarding.ui.profilesetup.widgets.HeightStep
import dev.yaseyo.onboarding.ui.profilesetup.widgets.NameStep
import dev.yaseyo.onboarding.ui.profilesetup.widgets.OnboardingProgressBar
+import dev.yaseyo.onboarding.ui.profilesetup.widgets.SexStep
+import dev.yaseyo.onboarding.ui.profilesetup.widgets.SummaryStep
import org.koin.compose.viewmodel.koinViewModel
@Composable
internal fun ProfileSetupScreen(viewModel: ProfileSetupViewModel = koinViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val navState = rememberNavigationEventState(NavigationEventInfo.None)
+ val snackBarHostState = LocalSnackBarHostState.current
NavigationBackHandler(
state = navState,
@@ -51,6 +62,10 @@ internal fun ProfileSetupScreen(viewModel: ProfileSetupViewModel = koinViewModel
}
ProfileSetupContent(state = state, eventHandler = viewModel)
+
+ LaunchedEffect(viewModel) {
+ viewModel.action.collect { snackBarHostState.showSnackbar(it) }
+ }
}
@Composable
@@ -73,12 +88,16 @@ private fun ProfileSetupContent(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
- IconButton(onClick = eventHandler::onBack) {
- Icon(
- imageVector = Icons.AutoMirrored.Filled.ArrowBack,
- contentDescription = "Back",
- tint = colors.contentPrimary,
- )
+ if (state.currentStepIndex > 0) {
+ IconButton(onClick = eventHandler::onBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back",
+ tint = colors.contentPrimary,
+ )
+ }
+ } else {
+ Spacer(Modifier.size(48.dp))
}
Text(
text = "Yaseyo",
@@ -86,7 +105,6 @@ private fun ProfileSetupContent(
fontWeight = FontWeight.SemiBold,
color = colors.accentDefault,
)
- // Balance the row so title stays centered
Spacer(Modifier.size(48.dp))
}
@@ -101,25 +119,69 @@ private fun ProfileSetupContent(
.weight(1f)
.padding(horizontal = 24.dp, vertical = 32.dp),
) {
- when (state.currentStepIndex) {
- 0 -> NameStep(
+ when (state.currentStep?.key) {
+ "name" -> NameStep(
name = state.name,
title = state.currentStep?.title ?: "What should we call you?",
subtitle = state.currentStep?.subtitle,
onNameChanged = eventHandler::onNameChanged,
)
- 1 -> AboutYouStep(
+ "sex" -> SexStep(
sex = state.sex,
+ title = state.currentStep?.title ?: "Biological sex",
+ subtitle = state.currentStep?.subtitle,
+ onSexChanged = eventHandler::onSexChanged,
+ )
+ "dateOfBirth" -> DateOfBirthStep(
dobMonth = state.dobMonth,
dobDay = state.dobDay,
dobYear = state.dobYear,
- title = state.currentStep?.title ?: "Tell us about yourself",
+ title = state.currentStep?.title ?: "When were you born?",
subtitle = state.currentStep?.subtitle,
- onSexChanged = eventHandler::onSexChanged,
onMonthChanged = eventHandler::onDobMonthChanged,
onDayChanged = eventHandler::onDobDayChanged,
onYearChanged = eventHandler::onDobYearChanged,
)
+ "height" -> HeightStep(
+ heightCm = state.heightCm,
+ title = state.currentStep?.title ?: "How tall are you?",
+ subtitle = state.currentStep?.subtitle,
+ onHeightChanged = eventHandler::onHeightChanged,
+ )
+ "currentWeightKg" -> CurrentWeightStep(
+ weightKg = state.currentWeightKg,
+ title = state.currentStep?.title ?: "What's your current weight?",
+ subtitle = state.currentStep?.subtitle,
+ onWeightChanged = eventHandler::onCurrentWeightChanged,
+ )
+ "activityLevel" -> ActivityLevelStep(
+ options = state.activityLevels,
+ selected = state.selectedActivityLevel,
+ title = state.currentStep?.title ?: "How active is your daily life?",
+ subtitle = state.currentStep?.subtitle,
+ onSelected = eventHandler::onActivityLevelSelected,
+ )
+ "targetWeightKg" -> GoalWeightStep(
+ targetWeightKg = state.targetWeightKg,
+ currentWeightKg = state.currentWeightKg,
+ suggestion = state.goalSuggestion,
+ isLoading = state.isLoadingGoalSuggestion,
+ title = state.currentStep?.title ?: "What's your goal weight?",
+ subtitle = state.currentStep?.subtitle,
+ onTargetWeightChanged = eventHandler::onTargetWeightChanged,
+ )
+ "dailyDeficitKcal" -> DeficitStep(
+ dailyDeficitKcal = state.dailyDeficitKcal,
+ title = state.currentStep?.title ?: "How fast do you want to lose weight?",
+ subtitle = state.currentStep?.subtitle,
+ onDeficitChanged = eventHandler::onDeficitChanged,
+ )
+ "summary" -> SummaryStep(
+ summary = state.onboardingSummary,
+ isLoading = state.isLoadingSummary,
+ title = state.currentStep?.title ?: "Your plan",
+ subtitle = state.currentStep?.subtitle,
+ )
}
}
@@ -134,11 +196,14 @@ private fun ProfileSetupContent(
containerColor = colors.accentDefault,
disabledContainerColor = colors.accentSubtle,
),
- enabled = when (state.currentStepIndex) {
- 0 -> state.name.isNotBlank()
- 1 -> state.sex != null
- else -> true
- },
+ enabled = !state.isSubmitting &&
+ when (state.currentStep?.key) {
+ "name" -> state.name.isNotBlank()
+ "sex" -> state.sex != null
+ "activityLevel" -> state.selectedActivityLevel != null
+ "summary" -> state.onboardingSummary != null
+ else -> true
+ },
) {
Text(
text = "CONTINUE →",
@@ -153,12 +218,8 @@ private fun ProfileSetupContent(
}
private val previewSteps = listOf(
- OnboardingStep(key = "name", title = "What should we call you?", subtitle = "We'll use this to personalize your experience."),
- OnboardingStep(
- key = "about_you",
- title = "Tell us about yourself",
- subtitle = "This helps us calculate your calorie needs accurately.",
- ),
+ OnboardingStep(key = "name", title = "What should we call you?", subtitle = "We'll use this to personalise your experience"),
+ OnboardingStep(key = "sex", title = "Biological sex", subtitle = "Used for accurate calorie calculations"),
)
private val previewState = ProfileSetupUiState(
@@ -179,6 +240,16 @@ private val previewHandler = object : ProfileSetupEventHandler {
override fun onDobYearChanged(year: Int) {}
+ override fun onHeightChanged(heightCm: Int) {}
+
+ override fun onCurrentWeightChanged(weightKg: Int) {}
+
+ override fun onActivityLevelSelected(level: dev.yaseyo.user.api.ActivityLevel) {}
+
+ override fun onTargetWeightChanged(weightKg: Int) {}
+
+ override fun onDeficitChanged(kcal: Int) {}
+
override fun onContinue() {}
override fun onBack() {}
@@ -187,7 +258,7 @@ private val previewHandler = object : ProfileSetupEventHandler {
@Preview(showBackground = true)
@Composable
private fun ProfileSetupNameLightPreview() {
- YaseyoTheme(darkTheme = false) {
+ YaseyoPreview(darkTheme = false) {
ProfileSetupContent(state = previewState, eventHandler = previewHandler)
}
}
@@ -195,7 +266,7 @@ private fun ProfileSetupNameLightPreview() {
@Preview(showBackground = true)
@Composable
private fun ProfileSetupNameDarkPreview() {
- YaseyoTheme(darkTheme = true) {
+ YaseyoPreview(darkTheme = true) {
ProfileSetupContent(state = previewState, eventHandler = previewHandler)
}
}
@@ -203,7 +274,7 @@ private fun ProfileSetupNameDarkPreview() {
@Preview(showBackground = true)
@Composable
private fun ProfileSetupNameFilledPreview() {
- YaseyoTheme(darkTheme = false) {
- ProfileSetupContent(state = previewState.copy(name = "Taki"), eventHandler = previewHandler)
+ YaseyoPreview(darkTheme = false) {
+ ProfileSetupContent(state = previewState.copy(name = "Alex"), eventHandler = previewHandler)
}
}
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupUiState.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupUiState.kt
index 0767674..530191f 100644
--- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupUiState.kt
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupUiState.kt
@@ -1,20 +1,33 @@
package dev.yaseyo.onboarding.ui.profilesetup
+import dev.yaseyo.onboarding.model.ActivityLevelOption
+import dev.yaseyo.onboarding.model.GoalSuggestion
import dev.yaseyo.onboarding.model.OnboardingStep
+import dev.yaseyo.onboarding.model.OnboardingSummary
+import dev.yaseyo.user.api.ActivityLevel
import dev.yaseyo.user.api.Sex
import kotlinx.datetime.LocalDate
internal data class ProfileSetupUiState(
val steps: List = emptyList(),
val isLoadingSteps: Boolean = true,
+ val isSubmitting: Boolean = false,
val currentStepIndex: Int = 0,
- // Step 1 — name
val name: String = "",
- // Step 2 — about you
val sex: Sex? = null,
val dobMonth: Int = 1,
val dobDay: Int = 1,
val dobYear: Int = 1990,
+ val heightCm: Int = 170,
+ val currentWeightKg: Int = 70,
+ val activityLevels: List = emptyList(),
+ val selectedActivityLevel: ActivityLevel? = null,
+ val goalSuggestion: GoalSuggestion? = null,
+ val isLoadingGoalSuggestion: Boolean = false,
+ val targetWeightKg: Int = 70,
+ val dailyDeficitKcal: Int = 500,
+ val onboardingSummary: OnboardingSummary? = null,
+ val isLoadingSummary: Boolean = false,
)
internal fun daysInMonth(
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupViewModel.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupViewModel.kt
index d70abbf..9d8ae81 100644
--- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupViewModel.kt
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupViewModel.kt
@@ -2,74 +2,210 @@ package dev.yaseyo.onboarding.ui.profilesetup
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import dev.yaseyo.coroutines.DispatcherProvider
import dev.yaseyo.navigation.AppRouter
-import dev.yaseyo.onboarding.model.OnboardingStep
-import dev.yaseyo.onboarding.network.OnboardingApi
+import dev.yaseyo.navigation.Home
+import dev.yaseyo.onboarding.model.OnboardingDraft
+import dev.yaseyo.onboarding.repository.OnboardingRepository
+import dev.yaseyo.user.api.ActivityLevel
import dev.yaseyo.user.api.Sex
+import dev.yaseyo.user.api.UserRepository
+import kotlinx.coroutines.async
+import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
internal class ProfileSetupViewModel(
- private val onboardingApi: OnboardingApi,
+ private val repo: OnboardingRepository,
+ private val userRepository: UserRepository,
+ private val dispatchers: DispatcherProvider,
private val router: AppRouter,
) : ViewModel(),
ProfileSetupEventHandler {
+ private val _action = Channel(capacity = Channel.BUFFERED)
+ val action = _action.receiveAsFlow()
+ private val draftState: StateFlow = repo.draft
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.Eagerly,
+ initialValue = OnboardingDraft(),
+ )
+
private val _uiState = MutableStateFlow(ProfileSetupUiState())
- val uiState: StateFlow = _uiState.asStateFlow()
- init {
- fetchSteps()
- }
+ val uiState: StateFlow = combine(draftState, _uiState) { draft, state ->
+ state.copy(
+ currentStepIndex = draft.currentStepIndex,
+ name = draft.name,
+ sex = draft.sex,
+ dobDay = draft.dobDay,
+ dobMonth = draft.dobMonth,
+ dobYear = draft.dobYear,
+ heightCm = draft.heightCm,
+ currentWeightKg = draft.currentWeightKg,
+ selectedActivityLevel = draft.activityLevel,
+ targetWeightKg = draft.targetWeightKg,
+ dailyDeficitKcal = draft.dailyDeficitKcal,
+ )
+ }.onStart { loadInitialData() }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = ProfileSetupUiState(),
+ )
+
+ private fun loadInitialData() {
+ viewModelScope.launch(dispatchers.io) {
+ val stepsDeferred = async { repo.getSteps() }
+ val levelsDeferred = async { repo.getActivityLevels() }
- private fun fetchSteps() {
- viewModelScope.launch {
- runCatching { onboardingApi.getSteps() }
- .onSuccess { nets ->
- _uiState.update { state ->
- state.copy(
- steps = nets.map { OnboardingStep(key = it.key, title = it.title, subtitle = it.subtitle) },
+ stepsDeferred
+ .await()
+ .onSuccess { steps ->
+ _uiState.update {
+ it.copy(
+ steps = steps,
isLoadingSteps = false,
)
}
}.onFailure {
_uiState.update { it.copy(isLoadingSteps = false) }
}
+
+ levelsDeferred
+ .await()
+ .onSuccess { levels -> _uiState.update { it.copy(activityLevels = levels) } }
}
}
- override fun onNameChanged(name: String) = _uiState.update { it.copy(name = name) }
+ override fun onNameChanged(name: String) = save { it.copy(name = name) }
+
+ override fun onSexChanged(sex: Sex) = save { it.copy(sex = sex) }
+
+ override fun onDobMonthChanged(month: Int) = save { it.copy(dobMonth = month) }
- override fun onSexChanged(sex: Sex) = _uiState.update { it.copy(sex = sex) }
+ override fun onDobDayChanged(day: Int) = save { it.copy(dobDay = day) }
- override fun onDobMonthChanged(month: Int) = _uiState.update { it.copy(dobMonth = month) }
+ override fun onDobYearChanged(year: Int) = save { it.copy(dobYear = year) }
- override fun onDobDayChanged(day: Int) = _uiState.update { it.copy(dobDay = day) }
+ override fun onHeightChanged(heightCm: Int) = save { it.copy(heightCm = heightCm) }
- override fun onDobYearChanged(year: Int) = _uiState.update { it.copy(dobYear = year) }
+ override fun onCurrentWeightChanged(weightKg: Int) = save { it.copy(currentWeightKg = weightKg) }
+
+ override fun onActivityLevelSelected(level: ActivityLevel) = save { it.copy(activityLevel = level) }
+
+ override fun onTargetWeightChanged(weightKg: Int) = save { it.copy(targetWeightKg = weightKg) }
+
+ override fun onDeficitChanged(kcal: Int) {
+ save { it.copy(dailyDeficitKcal = kcal) }
+ fetchSummary(overrideDeficitKcal = kcal)
+ }
override fun onContinue() {
- val state = _uiState.value
+ val state = uiState.value
val nextIndex = state.currentStepIndex + 1
+ if (state.currentStep?.key == "activityLevel") fetchGoalSuggestion()
if (nextIndex < state.totalSteps) {
- _uiState.update { it.copy(currentStepIndex = nextIndex) }
+ save { it.copy(currentStepIndex = nextIndex) }
+ val nextKey = state.steps.getOrNull(nextIndex)?.key
+ if (nextKey == "summary") fetchSummary()
} else {
submitProfile()
}
}
override fun onBack() {
- val currentIndex = _uiState.value.currentStepIndex
+ val currentIndex = draftState.value.currentStepIndex
if (currentIndex == 0) {
router.goBack()
} else {
- _uiState.update { it.copy(currentStepIndex = currentIndex - 1) }
+ save { it.copy(currentStepIndex = currentIndex - 1) }
+ }
+ }
+
+ private fun fetchGoalSuggestion() {
+ val state = uiState.value
+ val sex = state.sex ?: return
+ _uiState.update { it.copy(isLoadingGoalSuggestion = true) }
+ viewModelScope.launch(dispatchers.io) {
+ repo
+ .getGoalSuggestion(
+ sex = sex,
+ heightCm = state.heightCm,
+ dateOfBirth = state.dateOfBirth(),
+ currentWeightKg = state.currentWeightKg,
+ ).onSuccess { suggestion ->
+ _uiState.update { it.copy(goalSuggestion = suggestion, isLoadingGoalSuggestion = false) }
+ save { it.copy(targetWeightKg = suggestion.suggestedTargetKg) }
+ }.onFailure {
+ _uiState.update { it.copy(isLoadingGoalSuggestion = false) }
+ }
+ }
+ }
+
+ private fun fetchSummary(overrideDeficitKcal: Int? = null) {
+ val state = uiState.value
+ val sex = state.sex ?: return
+ val activityLevel = state.selectedActivityLevel ?: return
+ _uiState.update { it.copy(isLoadingSummary = true) }
+ viewModelScope.launch(dispatchers.io) {
+ repo
+ .getSummary(
+ sex = sex,
+ heightCm = state.heightCm,
+ dateOfBirth = state.dateOfBirth(),
+ currentWeightKg = state.currentWeightKg,
+ activityLevel = activityLevel,
+ dailyDeficitKcal = overrideDeficitKcal ?: state.dailyDeficitKcal,
+ ).onSuccess { summary ->
+ _uiState.update { it.copy(onboardingSummary = summary, isLoadingSummary = false) }
+ }.onFailure {
+ _uiState.update { it.copy(isLoadingSummary = false) }
+ }
}
}
+ private fun save(transform: (OnboardingDraft) -> OnboardingDraft) {
+ viewModelScope.launch(dispatchers.io) { repo.saveDraft(transform(draftState.value)) }
+ }
+
private fun submitProfile() {
- // TODO: POST /user + POST /weight — wired up on the final step
+ val state = uiState.value
+ val sex = state.sex ?: return
+ val activityLevel = state.selectedActivityLevel ?: return
+ val summary = state.onboardingSummary ?: return
+
+ _uiState.update { it.copy(isSubmitting = true) }
+ viewModelScope.launch(dispatchers.io) {
+ userRepository
+ .createUser(
+ name = state.name,
+ sex = sex,
+ height = state.heightCm.toDouble(),
+ dateOfBirth = state.dateOfBirth(),
+ activityLevel = activityLevel,
+ targetWeightKg = state.targetWeightKg.toDouble(),
+ dailyDeficitKcal = state.dailyDeficitKcal.toDouble(),
+ targetProtein = summary.proteinG.toDouble(),
+ targetCarbs = summary.carbsG.toDouble(),
+ targetFat = summary.fatG.toDouble(),
+ ).fold(
+ onSuccess = {
+ repo.clearDraft()
+ router.navigateAndClearBackStack(Home)
+ },
+ onFailure = {
+ _uiState.update { it.copy(isSubmitting = false) }
+ _action.send("Something went wrong. Please try again.")
+ },
+ )
+ }
}
}
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/AboutYouStep.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/AboutYouStep.kt
deleted file mode 100644
index a9d1947..0000000
--- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/AboutYouStep.kt
+++ /dev/null
@@ -1,145 +0,0 @@
-package dev.yaseyo.onboarding.ui.profilesetup.widgets
-
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.height
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import dev.yaseyo.design.YaseyoTheme
-import dev.yaseyo.user.api.Sex
-
-@Composable
-internal fun AboutYouStep(
- sex: Sex?,
- dobMonth: Int,
- dobDay: Int,
- dobYear: Int,
- title: String,
- subtitle: String?,
- onSexChanged: (Sex) -> Unit,
- onMonthChanged: (Int) -> Unit,
- onDayChanged: (Int) -> Unit,
- onYearChanged: (Int) -> Unit,
- modifier: Modifier = Modifier,
-) {
- val colors = YaseyoTheme.colors
-
- Column(modifier = modifier) {
- Text(
- text = title,
- fontSize = 22.sp,
- fontWeight = FontWeight.Bold,
- color = colors.contentPrimary,
- )
-
- if (subtitle != null) {
- Spacer(Modifier.height(6.dp))
- Text(
- text = subtitle,
- fontSize = 15.sp,
- color = colors.contentSecondary,
- )
- }
-
- Spacer(Modifier.height(24.dp))
-
- SectionLabel(text = "SELECT BIOLOGICAL SEX")
-
- Spacer(Modifier.height(12.dp))
-
- SexSelectionRow(selected = sex, onSelected = onSexChanged)
-
- Spacer(Modifier.height(28.dp))
-
- SectionLabel(text = "DATE OF BIRTH")
-
- Spacer(Modifier.height(12.dp))
-
- WheelDatePicker(
- month = dobMonth,
- day = dobDay,
- year = dobYear,
- onMonthChanged = onMonthChanged,
- onDayChanged = onDayChanged,
- onYearChanged = onYearChanged,
- )
- }
-}
-
-@Composable
-private fun SectionLabel(
- text: String,
- modifier: Modifier = Modifier,
-) {
- val colors = YaseyoTheme.colors
- Text(
- text = text,
- fontSize = 11.sp,
- fontWeight = FontWeight.Bold,
- letterSpacing = 1.sp,
- color = colors.contentTertiary,
- modifier = modifier,
- )
-}
-
-@Preview(showBackground = true)
-@Composable
-private fun AboutYouStepNoneSelectedPreview() {
- YaseyoTheme(darkTheme = false) {
- AboutYouStep(
- sex = null,
- dobMonth = 2,
- dobDay = 14,
- dobYear = 1994,
- title = "Tell us about yourself",
- subtitle = "This helps us calculate your calorie needs accurately.",
- onSexChanged = {},
- onMonthChanged = {},
- onDayChanged = {},
- onYearChanged = {},
- )
- }
-}
-
-@Preview(showBackground = true)
-@Composable
-private fun AboutYouStepFemalePreview() {
- YaseyoTheme(darkTheme = false) {
- AboutYouStep(
- sex = Sex.FEMALE,
- dobMonth = 2,
- dobDay = 14,
- dobYear = 1994,
- title = "Tell us about yourself",
- subtitle = "This helps us calculate your calorie needs accurately.",
- onSexChanged = {},
- onMonthChanged = {},
- onDayChanged = {},
- onYearChanged = {},
- )
- }
-}
-
-@Preview(showBackground = true)
-@Composable
-private fun AboutYouStepDarkPreview() {
- YaseyoTheme(darkTheme = true) {
- AboutYouStep(
- sex = Sex.MALE,
- dobMonth = 6,
- dobDay = 1,
- dobYear = 1990,
- title = "Tell us about yourself",
- subtitle = "This helps us calculate your calorie needs accurately.",
- onSexChanged = {},
- onMonthChanged = {},
- onDayChanged = {},
- onYearChanged = {},
- )
- }
-}
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/ActivityLevelStep.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/ActivityLevelStep.kt
new file mode 100644
index 0000000..b62eaeb
--- /dev/null
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/ActivityLevelStep.kt
@@ -0,0 +1,161 @@
+package dev.yaseyo.onboarding.ui.profilesetup.widgets
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import dev.yaseyo.design.YaseyoPreview
+import dev.yaseyo.design.YaseyoTheme
+import dev.yaseyo.onboarding.model.ActivityLevelOption
+import dev.yaseyo.user.api.ActivityLevel
+
+@Composable
+internal fun ActivityLevelStep(
+ options: List,
+ selected: ActivityLevel?,
+ title: String,
+ subtitle: String?,
+ onSelected: (ActivityLevel) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colors = YaseyoTheme.colors
+
+ Column(modifier = modifier) {
+ Text(
+ text = title,
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold,
+ color = colors.contentPrimary,
+ )
+
+ if (subtitle != null) {
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = subtitle,
+ fontSize = 15.sp,
+ color = colors.contentSecondary,
+ )
+ }
+
+ Spacer(Modifier.height(24.dp))
+
+ Column(
+ verticalArrangement = androidx.compose.foundation.layout.Arrangement
+ .spacedBy(12.dp),
+ ) {
+ options.forEach { option ->
+ ActivityLevelCard(
+ option = option,
+ selected = selected == option.level,
+ onClick = { onSelected(option.level) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ActivityLevelCard(
+ option: ActivityLevelOption,
+ selected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colors = YaseyoTheme.colors
+ val borderColor = if (selected) colors.accentDefault else Color.Transparent
+ val cardBg = if (selected) colors.accentSubtle else colors.backgroundElevated
+
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(cardBg)
+ .border(width = 1.5.dp, color = borderColor, shape = RoundedCornerShape(16.dp))
+ .clickable(onClick = onClick)
+ .padding(horizontal = 20.dp, vertical = 18.dp),
+ ) {
+ Column {
+ Text(
+ text = option.label,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = colors.contentPrimary,
+ )
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = option.description,
+ fontSize = 13.sp,
+ color = colors.contentSecondary,
+ )
+ }
+ }
+}
+
+private val previewOptions = listOf(
+ ActivityLevelOption(
+ level = ActivityLevel.Sedentary,
+ label = "Sedentary",
+ description = "Desk job or mostly sitting throughout the day.",
+ ),
+ ActivityLevelOption(
+ level = ActivityLevel.LightlyActive,
+ label = "Lightly Active",
+ description = "On your feet for most of the day — retail, teaching, etc.",
+ ),
+)
+
+@Preview(showBackground = true)
+@Composable
+private fun ActivityLevelStepNonePreview() {
+ YaseyoPreview(darkTheme = false) {
+ ActivityLevelStep(
+ options = previewOptions,
+ selected = null,
+ title = "How active is your daily life?",
+ subtitle = "This is about your lifestyle, not how often you train.",
+ onSelected = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ActivityLevelStepSelectedPreview() {
+ YaseyoPreview(darkTheme = false) {
+ ActivityLevelStep(
+ options = previewOptions,
+ selected = ActivityLevel.Sedentary,
+ title = "How active is your daily life?",
+ subtitle = "This is about your lifestyle, not how often you train.",
+ onSelected = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ActivityLevelStepDarkPreview() =
+ YaseyoPreview(darkTheme = true) {
+ ActivityLevelStep(
+ options = previewOptions,
+ selected = ActivityLevel.LightlyActive,
+ title = "How active is your daily life?",
+ subtitle = "This is about your lifestyle, not how often you train.",
+ onSelected = {},
+ )
+ }
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/CurrentWeightStep.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/CurrentWeightStep.kt
new file mode 100644
index 0000000..03804fa
--- /dev/null
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/CurrentWeightStep.kt
@@ -0,0 +1,104 @@
+package dev.yaseyo.onboarding.ui.profilesetup.widgets
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import dev.yaseyo.design.YaseyoPreview
+import dev.yaseyo.design.YaseyoTheme
+
+private val weightRange = (30..300).map { it.toString() }
+
+@Composable
+internal fun CurrentWeightStep(
+ weightKg: Int,
+ title: String,
+ subtitle: String?,
+ onWeightChanged: (Int) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colors = YaseyoTheme.colors
+
+ Column(modifier = modifier) {
+ Text(
+ text = title,
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold,
+ color = colors.contentPrimary,
+ )
+
+ if (subtitle != null) {
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = subtitle,
+ fontSize = 15.sp,
+ color = colors.contentSecondary,
+ )
+ }
+
+ Spacer(Modifier.height(32.dp))
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(colors.backgroundSubtle)
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ WheelPicker(
+ items = weightRange,
+ selectedIndex = (weightKg - 30).coerceIn(weightRange.indices),
+ onIndexChanged = { onWeightChanged(it + 30) },
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ text = "kg",
+ fontSize = 18.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = colors.contentSecondary,
+ modifier = Modifier.padding(end = 32.dp),
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CurrentWeightStepLightPreview() {
+ YaseyoPreview(darkTheme = false) {
+ CurrentWeightStep(
+ weightKg = 75,
+ title = "What's your current weight?",
+ subtitle = "We'll use this to track your progress.",
+ onWeightChanged = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CurrentWeightStepDarkPreview() =
+ YaseyoPreview(darkTheme = true) {
+ CurrentWeightStep(
+ weightKg = 75,
+ title = "What's your current weight?",
+ subtitle = "We'll use this to track your progress.",
+ onWeightChanged = {},
+ )
+ }
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/DateOfBirthStep.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/DateOfBirthStep.kt
new file mode 100644
index 0000000..32a5d7f
--- /dev/null
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/DateOfBirthStep.kt
@@ -0,0 +1,62 @@
+package dev.yaseyo.onboarding.ui.profilesetup.widgets
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import dev.yaseyo.design.YaseyoPreview
+import dev.yaseyo.design.YaseyoTheme
+
+@Composable
+internal fun DateOfBirthStep(
+ dobMonth: Int,
+ dobDay: Int,
+ dobYear: Int,
+ title: String,
+ subtitle: String?,
+ onMonthChanged: (Int) -> Unit,
+ onDayChanged: (Int) -> Unit,
+ onYearChanged: (Int) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colors = YaseyoTheme.colors
+ Column(modifier = modifier) {
+ Text(text = title, fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.contentPrimary)
+ if (subtitle != null) {
+ Spacer(Modifier.height(6.dp))
+ Text(text = subtitle, fontSize = 15.sp, color = colors.contentSecondary)
+ }
+ Spacer(Modifier.height(24.dp))
+ WheelDatePicker(
+ month = dobMonth,
+ day = dobDay,
+ year = dobYear,
+ onMonthChanged = onMonthChanged,
+ onDayChanged = onDayChanged,
+ onYearChanged = onYearChanged,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun DateOfBirthStepPreview() {
+ YaseyoPreview(darkTheme = false) {
+ DateOfBirthStep(
+ dobMonth = 6,
+ dobDay = 15,
+ dobYear = 1990,
+ title = "When were you born?",
+ subtitle = "Age affects your metabolic rate",
+ onMonthChanged = {},
+ onDayChanged = {},
+ onYearChanged = {},
+ )
+ }
+}
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/FormatUtils.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/FormatUtils.kt
new file mode 100644
index 0000000..f344cff
--- /dev/null
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/FormatUtils.kt
@@ -0,0 +1,6 @@
+package dev.yaseyo.onboarding.ui.profilesetup.widgets
+
+internal fun Double.format1dp(): String {
+ val scaled = (this * 10).toLong()
+ return "${scaled / 10}.${kotlin.math.abs(scaled % 10)}"
+}
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/GoalWeightStep.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/GoalWeightStep.kt
new file mode 100644
index 0000000..e474a82
--- /dev/null
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/GoalWeightStep.kt
@@ -0,0 +1,211 @@
+package dev.yaseyo.onboarding.ui.profilesetup.widgets
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import dev.yaseyo.design.YaseyoPreview
+import dev.yaseyo.design.YaseyoTheme
+import dev.yaseyo.onboarding.model.GoalSuggestion
+
+@Composable
+internal fun GoalWeightStep(
+ targetWeightKg: Int,
+ currentWeightKg: Int,
+ suggestion: GoalSuggestion?,
+ isLoading: Boolean,
+ title: String,
+ subtitle: String?,
+ onTargetWeightChanged: (Int) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colors = YaseyoTheme.colors
+ val weightRange = (30..currentWeightKg).map { it.toString() }
+
+ Column(modifier = modifier) {
+ Text(
+ text = title,
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold,
+ color = colors.contentPrimary,
+ )
+
+ if (subtitle != null) {
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = subtitle,
+ fontSize = 15.sp,
+ color = colors.contentSecondary,
+ )
+ }
+
+ Spacer(Modifier.height(24.dp))
+
+ if (isLoading) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(colors.backgroundSubtle)
+ .padding(vertical = 24.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator(color = colors.accentDefault)
+ }
+ } else if (suggestion != null) {
+ CurrentStatsCard(suggestion = suggestion)
+ }
+
+ Spacer(Modifier.height(24.dp))
+
+ Text(
+ text = "TARGET WEIGHT",
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 1.sp,
+ color = colors.contentTertiary,
+ )
+
+ Spacer(Modifier.height(12.dp))
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(colors.backgroundSubtle)
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ WheelPicker(
+ items = weightRange,
+ selectedIndex = (targetWeightKg - 30).coerceIn(weightRange.indices),
+ onIndexChanged = { onTargetWeightChanged(it + 30) },
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ text = "kg",
+ fontSize = 18.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = colors.contentSecondary,
+ modifier = Modifier.padding(end = 32.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun CurrentStatsCard(
+ suggestion: GoalSuggestion,
+ modifier: Modifier = Modifier,
+) {
+ val colors = YaseyoTheme.colors
+
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(colors.backgroundSubtle)
+ .padding(horizontal = 20.dp, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Text(
+ text = "YOUR CURRENT STATS",
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 1.sp,
+ color = colors.contentTertiary,
+ )
+ StatRow(label = "BMI", value = suggestion.currentBmi.format1dp())
+ if (suggestion.currentBodyFatPct != null) {
+ val category = suggestion.currentBodyFatCategory?.let { " · $it" } ?: ""
+ StatRow(label = "Est. Body Fat", value = "${suggestion.currentBodyFatPct.format1dp()}%$category")
+ }
+ }
+}
+
+@Composable
+private fun StatRow(
+ label: String,
+ value: String,
+ modifier: Modifier = Modifier,
+) {
+ val colors = YaseyoTheme.colors
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(text = label, fontSize = 14.sp, color = colors.contentSecondary)
+ Text(text = value, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.contentPrimary)
+ }
+}
+
+private val previewSuggestion = GoalSuggestion(
+ currentBmi = 27.4,
+ currentBodyFatPct = 22.1,
+ currentBodyFatCategory = "Overweight",
+ suggestedTargetKg = 72,
+)
+
+@Preview(showBackground = true)
+@Composable
+private fun GoalWeightStepLoadingPreview() {
+ YaseyoPreview(darkTheme = false) {
+ GoalWeightStep(
+ targetWeightKg = 75,
+ currentWeightKg = 85,
+ suggestion = null,
+ isLoading = true,
+ title = "What's your goal weight?",
+ subtitle = null,
+ onTargetWeightChanged = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun GoalWeightStepLoadedPreview() {
+ YaseyoPreview(darkTheme = false) {
+ GoalWeightStep(
+ targetWeightKg = 72,
+ currentWeightKg = 85,
+ suggestion = previewSuggestion,
+ isLoading = false,
+ title = "What's your goal weight?",
+ subtitle = null,
+ onTargetWeightChanged = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun GoalWeightStepDarkPreview() =
+ YaseyoPreview(darkTheme = true) {
+ GoalWeightStep(
+ targetWeightKg = 72,
+ currentWeightKg = 85,
+ suggestion = previewSuggestion,
+ isLoading = false,
+ title = "What's your goal weight?",
+ subtitle = null,
+ onTargetWeightChanged = {},
+ )
+ }
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/HeightStep.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/HeightStep.kt
new file mode 100644
index 0000000..41ff5e7
--- /dev/null
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/HeightStep.kt
@@ -0,0 +1,104 @@
+package dev.yaseyo.onboarding.ui.profilesetup.widgets
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import dev.yaseyo.design.YaseyoPreview
+import dev.yaseyo.design.YaseyoTheme
+
+private val heightRange = (140..220).map { it.toString() }
+
+@Composable
+internal fun HeightStep(
+ heightCm: Int,
+ title: String,
+ subtitle: String?,
+ onHeightChanged: (Int) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colors = YaseyoTheme.colors
+
+ Column(modifier = modifier) {
+ Text(
+ text = title,
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold,
+ color = colors.contentPrimary,
+ )
+
+ if (subtitle != null) {
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = subtitle,
+ fontSize = 15.sp,
+ color = colors.contentSecondary,
+ )
+ }
+
+ Spacer(Modifier.height(32.dp))
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(colors.backgroundSubtle)
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ WheelPicker(
+ items = heightRange,
+ selectedIndex = (heightCm - 140).coerceIn(heightRange.indices),
+ onIndexChanged = { onHeightChanged(it + 140) },
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ text = "cm",
+ fontSize = 18.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = colors.contentSecondary,
+ modifier = Modifier.padding(end = 32.dp),
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun HeightStepLightPreview() {
+ YaseyoPreview(darkTheme = false) {
+ HeightStep(
+ heightCm = 175,
+ title = "How tall are you?",
+ subtitle = "Used to calculate your calorie needs.",
+ onHeightChanged = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun HeightStepDarkPreview() =
+ YaseyoPreview(darkTheme = true) {
+ HeightStep(
+ heightCm = 175,
+ title = "How tall are you?",
+ subtitle = "Used to calculate your calorie needs.",
+ onHeightChanged = {},
+ )
+ }
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/NameStep.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/NameStep.kt
index 58f41b0..8a5babb 100644
--- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/NameStep.kt
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/NameStep.kt
@@ -6,6 +6,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.InputTransformation
+import androidx.compose.foundation.text.input.TextFieldLineLimits
+import androidx.compose.foundation.text.input.byValue
+import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
@@ -17,6 +21,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import dev.yaseyo.design.YaseyoPreview
import dev.yaseyo.design.YaseyoTheme
@Composable
@@ -28,6 +33,7 @@ internal fun NameStep(
modifier: Modifier = Modifier,
) {
val colors = YaseyoTheme.colors
+ val localNameState = rememberTextFieldState(initialText = name)
Column(modifier = modifier) {
Text(
@@ -50,9 +56,12 @@ internal fun NameStep(
Spacer(Modifier.height(32.dp))
OutlinedTextField(
- value = name,
- onValueChange = onNameChanged,
+ state = localNameState,
modifier = Modifier.fillMaxWidth(),
+ inputTransformation = InputTransformation.byValue { _, proposed ->
+ onNameChanged(proposed.toString())
+ proposed
+ },
placeholder = {
Text(
text = "Your name",
@@ -71,7 +80,7 @@ internal fun NameStep(
unfocusedTextColor = colors.contentPrimary,
focusedTextColor = colors.contentPrimary,
),
- singleLine = true,
+ lineLimits = TextFieldLineLimits.SingleLine,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
)
}
@@ -80,7 +89,7 @@ internal fun NameStep(
@Preview(showBackground = true)
@Composable
private fun NameStepEmptyLightPreview() {
- YaseyoTheme(darkTheme = false) {
+ YaseyoPreview(darkTheme = false) {
NameStep(
name = "",
title = "What should we call you?",
@@ -93,9 +102,9 @@ private fun NameStepEmptyLightPreview() {
@Preview(showBackground = true)
@Composable
private fun NameStepFilledPreview() {
- YaseyoTheme(darkTheme = false) {
+ YaseyoPreview(darkTheme = false) {
NameStep(
- name = "Taki",
+ name = "Alex",
title = "What should we call you?",
subtitle = "We'll use this to personalize your experience.",
onNameChanged = {},
@@ -106,7 +115,7 @@ private fun NameStepFilledPreview() {
@Preview(showBackground = true)
@Composable
private fun NameStepDarkPreview() {
- YaseyoTheme(darkTheme = true) {
+ YaseyoPreview(darkTheme = true) {
NameStep(
name = "",
title = "What should we call you?",
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/OnboardingProgressBar.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/OnboardingProgressBar.kt
index 1cb6329..bae84dd 100644
--- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/OnboardingProgressBar.kt
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/OnboardingProgressBar.kt
@@ -1,20 +1,18 @@
package dev.yaseyo.onboarding.ui.profilesetup.widgets
-import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.material3.LinearProgressIndicator
-import androidx.compose.material3.Text
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.StrokeCap
-import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
+import dev.yaseyo.design.YaseyoPreview
import dev.yaseyo.design.YaseyoTheme
@Composable
@@ -24,59 +22,49 @@ internal fun OnboardingProgressBar(
modifier: Modifier = Modifier,
) {
val colors = YaseyoTheme.colors
- val percentage = ((currentStep.toFloat() / totalSteps) * 100).toInt()
- Column(modifier = modifier.fillMaxWidth()) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(
- text = "Step $currentStep of $totalSteps",
- color = colors.accentDefault,
- fontWeight = FontWeight.SemiBold,
- fontSize = 13.sp,
- modifier = Modifier.weight(1f),
- )
- Text(
- text = "$percentage%",
- color = colors.contentSecondary,
- fontSize = 13.sp,
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ repeat(totalSteps) { index ->
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .height(4.dp)
+ .clip(CircleShape)
+ .background(
+ if (index < currentStep) {
+ colors.accentDefault
+ } else {
+ colors.backgroundSubtle
+ },
+ ),
)
}
- Spacer(Modifier.height(6.dp))
- LinearProgressIndicator(
- progress = { currentStep.toFloat() / totalSteps },
- modifier = Modifier
- .fillMaxWidth()
- .height(4.dp),
- color = colors.accentDefault,
- trackColor = colors.backgroundSubtle,
- strokeCap = StrokeCap.Round,
- )
}
}
@Preview(showBackground = true)
@Composable
private fun OnboardingProgressBarStep1Preview() {
- YaseyoTheme(darkTheme = false) {
- OnboardingProgressBar(currentStep = 1, totalSteps = 6)
+ YaseyoPreview(darkTheme = false) {
+ OnboardingProgressBar(currentStep = 1, totalSteps = 7)
}
}
@Preview(showBackground = true)
@Composable
private fun OnboardingProgressBarStep4Preview() {
- YaseyoTheme(darkTheme = false) {
- OnboardingProgressBar(currentStep = 4, totalSteps = 6)
+ YaseyoPreview(darkTheme = false) {
+ OnboardingProgressBar(currentStep = 4, totalSteps = 7)
}
}
@Preview(showBackground = true)
@Composable
private fun OnboardingProgressBarDarkPreview() {
- YaseyoTheme(darkTheme = true) {
- OnboardingProgressBar(currentStep = 5, totalSteps = 6)
+ YaseyoPreview(darkTheme = true) {
+ OnboardingProgressBar(currentStep = 5, totalSteps = 7)
}
}
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/SexCard.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/SexCard.kt
index 90e7a14..a67a016 100644
--- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/SexCard.kt
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/SexCard.kt
@@ -24,6 +24,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import dev.yaseyo.design.YaseyoPreview
import dev.yaseyo.design.YaseyoTheme
import dev.yaseyo.user.api.Sex
@@ -40,15 +41,15 @@ internal fun SexSelectionRow(
SexCard(
label = "Male",
symbol = "♂",
- selected = selected == Sex.MALE,
- onClick = { onSelected(Sex.MALE) },
+ selected = selected == Sex.Male,
+ onClick = { onSelected(Sex.Male) },
modifier = Modifier.weight(1f),
)
SexCard(
label = "Female",
symbol = "♀",
- selected = selected == Sex.FEMALE,
- onClick = { onSelected(Sex.FEMALE) },
+ selected = selected == Sex.Female,
+ onClick = { onSelected(Sex.Female) },
modifier = Modifier.weight(1f),
)
}
@@ -65,7 +66,6 @@ private fun SexCard(
val colors = YaseyoTheme.colors
val iconBg = if (selected) colors.accentDefault else colors.backgroundSubtle
val iconTint = if (selected) colors.contentOnAccent else colors.contentSecondary
- val labelColor = if (selected) colors.accentDefault else colors.contentPrimary
val borderColor = if (selected) colors.accentDefault else Color.Transparent
val cardBg = if (selected) colors.accentSubtle else colors.backgroundElevated
@@ -92,7 +92,7 @@ private fun SexCard(
text = label,
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp,
- color = labelColor,
+ color = colors.contentPrimary,
)
}
}
@@ -100,7 +100,7 @@ private fun SexCard(
@Preview(showBackground = true)
@Composable
private fun SexSelectionNonePreview() {
- YaseyoTheme(darkTheme = false) {
+ YaseyoPreview(darkTheme = false) {
SexSelectionRow(selected = null, onSelected = {})
}
}
@@ -108,15 +108,15 @@ private fun SexSelectionNonePreview() {
@Preview(showBackground = true)
@Composable
private fun SexSelectionFemalePreview() {
- YaseyoTheme(darkTheme = false) {
- SexSelectionRow(selected = Sex.FEMALE, onSelected = {})
+ YaseyoPreview(darkTheme = false) {
+ SexSelectionRow(selected = Sex.Female, onSelected = {})
}
}
@Preview(showBackground = true)
@Composable
private fun SexSelectionDarkPreview() {
- YaseyoTheme(darkTheme = true) {
- SexSelectionRow(selected = Sex.MALE, onSelected = {})
+ YaseyoPreview(darkTheme = true) {
+ SexSelectionRow(selected = Sex.Male, onSelected = {})
}
}
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/SexStep.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/SexStep.kt
new file mode 100644
index 0000000..b2fafb9
--- /dev/null
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/SexStep.kt
@@ -0,0 +1,51 @@
+package dev.yaseyo.onboarding.ui.profilesetup.widgets
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import dev.yaseyo.design.YaseyoPreview
+import dev.yaseyo.design.YaseyoTheme
+import dev.yaseyo.user.api.Sex
+
+@Composable
+internal fun SexStep(
+ sex: Sex?,
+ title: String,
+ subtitle: String?,
+ onSexChanged: (Sex) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colors = YaseyoTheme.colors
+ Column(modifier = modifier) {
+ Text(text = title, fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.contentPrimary)
+ if (subtitle != null) {
+ Spacer(Modifier.height(6.dp))
+ Text(text = subtitle, fontSize = 15.sp, color = colors.contentSecondary)
+ }
+ Spacer(Modifier.height(24.dp))
+ SexSelectionRow(selected = sex, onSelected = onSexChanged)
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun SexStepNonePreview() {
+ YaseyoPreview(darkTheme = false) {
+ SexStep(sex = null, title = "Biological sex", subtitle = "Used for accurate calorie calculations", onSexChanged = {})
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun SexStepSelectedPreview() {
+ YaseyoPreview(darkTheme = true) {
+ SexStep(sex = Sex.Male, title = "Biological sex", subtitle = "Used for accurate calorie calculations", onSexChanged = {})
+ }
+}
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/SummaryStep.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/SummaryStep.kt
new file mode 100644
index 0000000..81bb5b5
--- /dev/null
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/SummaryStep.kt
@@ -0,0 +1,255 @@
+package dev.yaseyo.onboarding.ui.profilesetup.widgets
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import dev.yaseyo.design.YaseyoPreview
+import dev.yaseyo.design.YaseyoTheme
+import dev.yaseyo.onboarding.model.OnboardingSummary
+
+private val deficitOptions = listOf(
+ 250 to "Mild",
+ 500 to "Moderate",
+ 750 to "Aggressive",
+)
+
+@Composable
+internal fun DeficitStep(
+ dailyDeficitKcal: Int,
+ title: String,
+ subtitle: String?,
+ onDeficitChanged: (Int) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colors = YaseyoTheme.colors
+ Column(modifier = modifier) {
+ Text(text = title, fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.contentPrimary)
+ if (subtitle != null) {
+ Spacer(Modifier.height(6.dp))
+ Text(text = subtitle, fontSize = 15.sp, color = colors.contentSecondary)
+ }
+ Spacer(Modifier.height(24.dp))
+ Text(
+ text = "DAILY DEFICIT",
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 1.sp,
+ color = colors.contentTertiary,
+ )
+ Spacer(Modifier.height(10.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ deficitOptions.forEach { (kcal, label) ->
+ DeficitChip(
+ label = label,
+ kcal = kcal,
+ selected = dailyDeficitKcal == kcal,
+ onClick = { onDeficitChanged(kcal) },
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+internal fun SummaryStep(
+ summary: OnboardingSummary?,
+ isLoading: Boolean,
+ title: String,
+ subtitle: String?,
+ modifier: Modifier = Modifier,
+) {
+ val colors = YaseyoTheme.colors
+ Column(modifier = modifier) {
+ Text(text = title, fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.contentPrimary)
+ if (subtitle != null) {
+ Spacer(Modifier.height(6.dp))
+ Text(text = subtitle, fontSize = 15.sp, color = colors.contentSecondary)
+ }
+ Spacer(Modifier.height(24.dp))
+ if (isLoading) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(colors.backgroundSubtle)
+ .padding(vertical = 32.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator(color = colors.accentDefault)
+ }
+ } else if (summary != null) {
+ SummaryCard(summary = summary)
+ }
+ }
+}
+
+@Composable
+private fun DeficitChip(
+ label: String,
+ kcal: Int,
+ selected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colors = YaseyoTheme.colors
+ val bg = if (selected) colors.accentSubtle else colors.backgroundElevated
+ val border = if (selected) colors.accentDefault else Color.Transparent
+
+ Column(
+ modifier = modifier
+ .clip(RoundedCornerShape(12.dp))
+ .background(bg)
+ .border(1.5.dp, border, RoundedCornerShape(12.dp))
+ .clickable(onClick = onClick)
+ .padding(vertical = 12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(text = label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.contentPrimary)
+ Text(text = "$kcal kcal", fontSize = 11.sp, color = colors.contentSecondary)
+ }
+}
+
+@Composable
+private fun SummaryCard(
+ summary: OnboardingSummary,
+ modifier: Modifier = Modifier,
+) {
+ val colors = YaseyoTheme.colors
+
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(colors.backgroundSubtle)
+ .padding(horizontal = 20.dp, vertical = 16.dp),
+ ) {
+ Text(
+ text = "YOUR PLAN",
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 1.sp,
+ color = colors.contentTertiary,
+ )
+ Spacer(Modifier.height(12.dp))
+ SummaryRow(label = "Daily calories", value = "${summary.dailyCalorieTarget} kcal", highlight = true)
+ SummaryRow(label = "Weekly loss", value = "${summary.weeklyLossKg.format1dp()} kg/week")
+ SummaryRow(label = "TDEE", value = "${summary.tdee} kcal")
+ HorizontalDivider(modifier = Modifier.padding(vertical = 10.dp), color = colors.borderSubtle)
+ Text(
+ text = "SUGGESTED MACROS",
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 1.sp,
+ color = colors.contentTertiary,
+ )
+ Spacer(Modifier.height(10.dp))
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
+ MacroColumn(label = "Protein", value = "${summary.proteinG}g")
+ MacroColumn(label = "Carbs", value = "${summary.carbsG}g")
+ MacroColumn(label = "Fat", value = "${summary.fatG}g")
+ }
+ }
+}
+
+@Composable
+private fun SummaryRow(
+ label: String,
+ value: String,
+ highlight: Boolean = false,
+ modifier: Modifier = Modifier,
+) {
+ val colors = YaseyoTheme.colors
+ Row(
+ modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(text = label, fontSize = 14.sp, color = colors.contentSecondary)
+ Text(
+ text = value,
+ fontSize = 14.sp,
+ fontWeight = if (highlight) FontWeight.Bold else FontWeight.SemiBold,
+ color = if (highlight) colors.accentDefault else colors.contentPrimary,
+ )
+ }
+}
+
+@Composable
+private fun MacroColumn(
+ label: String,
+ value: String,
+ modifier: Modifier = Modifier,
+) {
+ val colors = YaseyoTheme.colors
+ Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(text = value, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = colors.contentPrimary)
+ Text(text = label, fontSize = 12.sp, color = colors.contentSecondary)
+ }
+}
+
+private val previewSummary = OnboardingSummary(
+ tdee = 2200,
+ dailyCalorieTarget = 1700,
+ weeklyLossKg = 0.7,
+ proteinG = 150,
+ carbsG = 170,
+ fatG = 55,
+)
+
+@Preview(showBackground = true)
+@Composable
+private fun DeficitStepPreview() {
+ YaseyoPreview(darkTheme = false) {
+ DeficitStep(
+ dailyDeficitKcal = 500,
+ title = "How fast do you want to lose weight?",
+ subtitle = "You can change this any time",
+ onDeficitChanged = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun SummaryStepLoadingPreview() {
+ YaseyoPreview(darkTheme = false) {
+ SummaryStep(summary = null, isLoading = true, title = "Your plan", subtitle = "Based on your profile")
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun SummaryStepLoadedPreview() {
+ YaseyoPreview(darkTheme = false) {
+ SummaryStep(summary = previewSummary, isLoading = false, title = "Your plan", subtitle = "Based on your profile")
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun SummaryStepDarkPreview() {
+ YaseyoPreview(darkTheme = true) {
+ SummaryStep(summary = previewSummary, isLoading = false, title = "Your plan", subtitle = "Based on your profile")
+ }
+}
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/WheelDatePicker.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/WheelDatePicker.kt
index 74c526b..1e6a2b3 100644
--- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/WheelDatePicker.kt
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/WheelDatePicker.kt
@@ -1,8 +1,12 @@
package dev.yaseyo.onboarding.ui.profilesetup.widgets
import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -10,6 +14,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import dev.yaseyo.design.YaseyoPreview
import dev.yaseyo.design.YaseyoTheme
import dev.yaseyo.onboarding.ui.profilesetup.daysInMonth
@@ -48,37 +53,50 @@ internal fun WheelDatePicker(
(1..daysInMonth(month, year)).map { it.toString() }
}
- Row(
+ Box(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(colors.backgroundSubtle),
) {
- WheelPicker(
- items = months,
- selectedIndex = (month - 1).coerceIn(months.indices),
- onIndexChanged = { onMonthChanged(it + 1) },
- modifier = Modifier.weight(2f), // months are wider
- )
- WheelPicker(
- items = days,
- selectedIndex = (day - 1).coerceIn(days.indices),
- onIndexChanged = { onDayChanged(it + 1) },
- modifier = Modifier.weight(1f),
- )
- WheelPicker(
- items = years,
- selectedIndex = (year - 1926).coerceIn(years.indices),
- onIndexChanged = { onYearChanged(it + 1926) },
- modifier = Modifier.weight(1f),
+ // Continuous selection window spanning all three columns
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .offset(y = ItemHeight)
+ .height(ItemHeight)
+ .clip(RoundedCornerShape(8.dp))
+ .background(colors.backgroundBase)
+ .border(1.dp, colors.borderDefault, RoundedCornerShape(8.dp)),
)
+
+ Row(modifier = Modifier.fillMaxWidth()) {
+ WheelPicker(
+ items = months,
+ selectedIndex = (month - 1).coerceIn(months.indices),
+ onIndexChanged = { onMonthChanged(it + 1) },
+ modifier = Modifier.weight(2f), // months are wider
+ )
+ WheelPicker(
+ items = days,
+ selectedIndex = (day - 1).coerceIn(days.indices),
+ onIndexChanged = { onDayChanged(it + 1) },
+ modifier = Modifier.weight(1f),
+ )
+ WheelPicker(
+ items = years,
+ selectedIndex = (year - 1926).coerceIn(years.indices),
+ onIndexChanged = { onYearChanged(it + 1926) },
+ modifier = Modifier.weight(1f),
+ )
+ }
}
}
@Preview(showBackground = true)
@Composable
private fun WheelDatePickerPreview() {
- YaseyoTheme(darkTheme = false) {
+ YaseyoPreview(darkTheme = false) {
WheelDatePicker(
month = 2,
day = 14,
@@ -93,7 +111,7 @@ private fun WheelDatePickerPreview() {
@Preview(showBackground = true)
@Composable
private fun WheelDatePickerDarkPreview() {
- YaseyoTheme(darkTheme = true) {
+ YaseyoPreview(darkTheme = true) {
WheelDatePicker(
month = 6,
day = 1,
diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/WheelPicker.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/WheelPicker.kt
index 64a8469..87215d8 100644
--- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/WheelPicker.kt
+++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/WheelPicker.kt
@@ -4,11 +4,9 @@ import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -16,7 +14,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -24,7 +21,7 @@ import dev.yaseyo.design.YaseyoTheme
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
-private val ItemHeight: Dp = 44.dp
+internal val ItemHeight: Dp = 44.dp
/**
* Drum-style scroll picker that snaps to items.
@@ -67,8 +64,7 @@ internal fun WheelPicker(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
- itemsIndexed(paddedItems) { index, item ->
- val isSelected = index == listState.firstVisibleItemIndex + 1
+ itemsIndexed(paddedItems) { _, item ->
Box(
modifier = Modifier
.fillMaxWidth()
@@ -77,26 +73,11 @@ internal fun WheelPicker(
) {
Text(
text = item,
- fontSize = if (isSelected) 18.sp else 14.sp,
- fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
- color = if (isSelected) colors.accentDefault else colors.contentTertiary,
+ fontSize = 16.sp,
+ color = colors.contentPrimary,
)
}
}
}
-
- // Thin lines framing the selected center slot
- HorizontalDivider(
- modifier = Modifier
- .fillMaxWidth()
- .offset(y = ItemHeight),
- color = colors.borderDefault,
- )
- HorizontalDivider(
- modifier = Modifier
- .fillMaxWidth()
- .offset(y = ItemHeight * 2),
- color = colors.borderDefault,
- )
}
}
diff --git a/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeOnboardingApi.kt b/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeOnboardingApi.kt
new file mode 100644
index 0000000..15f08c0
--- /dev/null
+++ b/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeOnboardingApi.kt
@@ -0,0 +1,72 @@
+package dev.yaseyo.onboarding.fake
+
+import dev.yaseyo.onboarding.model.ActivityLevelOption
+import dev.yaseyo.onboarding.model.GoalSuggestion
+import dev.yaseyo.onboarding.model.OnboardingStep
+import dev.yaseyo.onboarding.model.OnboardingSummary
+import dev.yaseyo.onboarding.network.OnboardingApi
+import dev.yaseyo.user.api.ActivityLevel
+import dev.yaseyo.user.api.Sex
+import kotlinx.datetime.LocalDate
+
+internal class FakeOnboardingApi(
+ private val error: Throwable? = null,
+ val steps: List = DEFAULT_STEPS,
+ val activityLevels: List = DEFAULT_ACTIVITY_LEVELS,
+ val goalSuggestion: GoalSuggestion = DEFAULT_GOAL_SUGGESTION,
+ val summary: OnboardingSummary = DEFAULT_SUMMARY,
+) : OnboardingApi {
+ private fun resolve(value: T): T = error?.let { throw it } ?: value
+
+ override suspend fun getSteps() = resolve(steps)
+
+ override suspend fun getActivityLevels() = resolve(activityLevels)
+
+ override suspend fun getGoalSuggestion(
+ sex: Sex,
+ heightCm: Int,
+ dateOfBirth: LocalDate,
+ currentWeightKg: Int,
+ ) = resolve(goalSuggestion)
+
+ override suspend fun getSummary(
+ sex: Sex,
+ heightCm: Int,
+ dateOfBirth: LocalDate,
+ currentWeightKg: Int,
+ activityLevel: ActivityLevel,
+ dailyDeficitKcal: Int,
+ ) = resolve(summary)
+
+ companion object {
+ val DEFAULT_STEPS = listOf(
+ OnboardingStep(key = "name", title = "Name", subtitle = null),
+ OnboardingStep(key = "sex", title = "Biological sex", subtitle = null),
+ OnboardingStep(key = "dateOfBirth", title = "Date of birth", subtitle = null),
+ OnboardingStep(key = "height", title = "Height", subtitle = null),
+ OnboardingStep(key = "currentWeightKg", title = "Current weight", subtitle = null),
+ OnboardingStep(key = "activityLevel", title = "Activity level", subtitle = null),
+ OnboardingStep(key = "targetWeightKg", title = "Goal weight", subtitle = null),
+ OnboardingStep(key = "dailyDeficitKcal", title = "Deficit", subtitle = null),
+ OnboardingStep(key = "summary", title = "Your plan", subtitle = null),
+ )
+ val DEFAULT_ACTIVITY_LEVELS = listOf(
+ ActivityLevelOption(level = ActivityLevel.Sedentary, label = "Sedentary", description = "Mostly sitting"),
+ ActivityLevelOption(level = ActivityLevel.LightlyActive, label = "Lightly Active", description = "Light movement"),
+ )
+ val DEFAULT_GOAL_SUGGESTION = GoalSuggestion(
+ currentBmi = 24.5,
+ currentBodyFatPct = null,
+ currentBodyFatCategory = null,
+ suggestedTargetKg = 70,
+ )
+ val DEFAULT_SUMMARY = OnboardingSummary(
+ tdee = 2200,
+ dailyCalorieTarget = 1700,
+ weeklyLossKg = 0.5,
+ proteinG = 150,
+ carbsG = 180,
+ fatG = 60,
+ )
+ }
+}
diff --git a/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeOnboardingLocalDataSource.kt b/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeOnboardingLocalDataSource.kt
new file mode 100644
index 0000000..d4e200e
--- /dev/null
+++ b/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeOnboardingLocalDataSource.kt
@@ -0,0 +1,21 @@
+package dev.yaseyo.onboarding.fake
+
+import dev.yaseyo.onboarding.datasource.OnboardingLocalDataSource
+import dev.yaseyo.onboarding.model.OnboardingDraft
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+
+internal class FakeOnboardingLocalDataSource(
+ initialDraft: OnboardingDraft = OnboardingDraft(),
+) : OnboardingLocalDataSource {
+ private val _draft = MutableStateFlow(initialDraft)
+ override val draft: Flow = _draft
+
+ override suspend fun saveDraft(draft: OnboardingDraft) {
+ _draft.value = draft
+ }
+
+ override suspend fun clearDraft() {
+ _draft.value = OnboardingDraft()
+ }
+}
diff --git a/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeOnboardingRepository.kt b/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeOnboardingRepository.kt
new file mode 100644
index 0000000..045c1cf
--- /dev/null
+++ b/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeOnboardingRepository.kt
@@ -0,0 +1,55 @@
+package dev.yaseyo.onboarding.fake
+
+import dev.yaseyo.onboarding.model.ActivityLevelOption
+import dev.yaseyo.onboarding.model.GoalSuggestion
+import dev.yaseyo.onboarding.model.OnboardingDraft
+import dev.yaseyo.onboarding.model.OnboardingStep
+import dev.yaseyo.onboarding.model.OnboardingSummary
+import dev.yaseyo.onboarding.repository.OnboardingRepository
+import dev.yaseyo.user.api.ActivityLevel
+import dev.yaseyo.user.api.Sex
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.datetime.LocalDate
+
+internal class FakeOnboardingRepository(
+ initialDraft: OnboardingDraft = OnboardingDraft(),
+ var stepsResult: Result> = Result.success(FakeOnboardingApi.DEFAULT_STEPS),
+ var activityLevelsResult: Result> = Result.success(FakeOnboardingApi.DEFAULT_ACTIVITY_LEVELS),
+ var goalSuggestionResult: Result = Result.success(FakeOnboardingApi.DEFAULT_GOAL_SUGGESTION),
+ var summaryResult: Result = Result.success(FakeOnboardingApi.DEFAULT_SUMMARY),
+) : OnboardingRepository {
+ private val _draft = MutableStateFlow(initialDraft)
+ override val draft: Flow = _draft
+
+ val savedDrafts = mutableListOf()
+
+ override suspend fun saveDraft(draft: OnboardingDraft) {
+ savedDrafts += draft
+ _draft.value = draft
+ }
+
+ override suspend fun clearDraft() {
+ _draft.value = OnboardingDraft()
+ }
+
+ override suspend fun getSteps() = stepsResult
+
+ override suspend fun getActivityLevels() = activityLevelsResult
+
+ override suspend fun getGoalSuggestion(
+ sex: Sex,
+ heightCm: Int,
+ dateOfBirth: LocalDate,
+ currentWeightKg: Int,
+ ) = goalSuggestionResult
+
+ override suspend fun getSummary(
+ sex: Sex,
+ heightCm: Int,
+ dateOfBirth: LocalDate,
+ currentWeightKg: Int,
+ activityLevel: ActivityLevel,
+ dailyDeficitKcal: Int,
+ ) = summaryResult
+}
diff --git a/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeUserRepository.kt b/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeUserRepository.kt
new file mode 100644
index 0000000..e53e913
--- /dev/null
+++ b/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeUserRepository.kt
@@ -0,0 +1,58 @@
+package dev.yaseyo.onboarding.fake
+
+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.LocalDate
+import kotlin.time.Instant
+
+internal class FakeUserRepository(
+ private val createUserResult: Result = Result.success(DEFAULT_USER),
+) : UserRepository {
+ override suspend fun getCurrentUser(): Result = Result.success(DEFAULT_USER)
+
+ 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 = createUserResult
+
+ 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 = Result.success(DEFAULT_USER)
+
+ companion object {
+ val DEFAULT_USER = User(
+ id = "usr_test",
+ name = "Alex",
+ sex = Sex.Male,
+ height = 178.0,
+ dateOfBirth = LocalDate(1990, 6, 15),
+ activityLevel = ActivityLevel.LightlyActive,
+ targetWeightKg = 75.0,
+ dailyDeficitKcal = 500.0,
+ targetProtein = 150.0,
+ targetCarbs = 180.0,
+ targetFat = 60.0,
+ createdAt = Instant.parse("2024-01-10T08:00:00Z"),
+ updatedAt = Instant.parse("2024-01-10T08:00:00Z"),
+ )
+ }
+}
diff --git a/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/repository/OnboardingRepositoryImplTest.kt b/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/repository/OnboardingRepositoryImplTest.kt
new file mode 100644
index 0000000..c1a310f
--- /dev/null
+++ b/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/repository/OnboardingRepositoryImplTest.kt
@@ -0,0 +1,147 @@
+package dev.yaseyo.onboarding.repository
+
+import dev.yaseyo.onboarding.fake.FakeOnboardingApi
+import dev.yaseyo.onboarding.fake.FakeOnboardingLocalDataSource
+import dev.yaseyo.onboarding.model.OnboardingDraft
+import dev.yaseyo.user.api.ActivityLevel
+import dev.yaseyo.user.api.Sex
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import kotlinx.datetime.LocalDate
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class OnboardingRepositoryImplTest {
+ private fun repo(
+ localDataSource: FakeOnboardingLocalDataSource = FakeOnboardingLocalDataSource(),
+ api: FakeOnboardingApi = FakeOnboardingApi(),
+ ) = OnboardingRepositoryImpl(localDataSource = localDataSource, api = api)
+
+ // --- draft ---
+
+ @Test
+ fun `draft reflects local data source`() =
+ runTest {
+ val initial = OnboardingDraft(name = "Alex", heightCm = 180)
+ val repo = repo(localDataSource = FakeOnboardingLocalDataSource(initial))
+ assertEquals(initial, repo.draft.first())
+ }
+
+ // --- saveDraft / clearDraft ---
+
+ @Test
+ fun `saveDraft persists to local data source`() =
+ runTest {
+ val source = FakeOnboardingLocalDataSource()
+ val updated = OnboardingDraft(name = "Alex", sex = Sex.Male)
+ repo(localDataSource = source).saveDraft(updated)
+ assertEquals(updated, source.draft.first())
+ }
+
+ @Test
+ fun `clearDraft resets to defaults`() =
+ runTest {
+ val source = FakeOnboardingLocalDataSource(OnboardingDraft(name = "Alex"))
+ repo(localDataSource = source).clearDraft()
+ assertEquals(OnboardingDraft(), source.draft.first())
+ }
+
+ // --- getSteps ---
+
+ @Test
+ fun `getSteps returns success from api`() =
+ runTest {
+ val result = repo().getSteps()
+ assertTrue(result.isSuccess)
+ assertEquals(FakeOnboardingApi.DEFAULT_STEPS, result.getOrThrow())
+ }
+
+ @Test
+ fun `getSteps returns failure when api throws`() =
+ runTest {
+ val error = RuntimeException("network error")
+ val result = repo(api = FakeOnboardingApi(error = error)).getSteps()
+ assertTrue(result.isFailure)
+ assertEquals(error, result.exceptionOrNull())
+ }
+
+ // --- getActivityLevels ---
+
+ @Test
+ fun `getActivityLevels returns success from api`() =
+ runTest {
+ val result = repo().getActivityLevels()
+ assertTrue(result.isSuccess)
+ assertEquals(FakeOnboardingApi.DEFAULT_ACTIVITY_LEVELS, result.getOrThrow())
+ }
+
+ @Test
+ fun `getActivityLevels returns failure when api throws`() =
+ runTest {
+ val error = RuntimeException("network error")
+ val result = repo(api = FakeOnboardingApi(error = error)).getActivityLevels()
+ assertTrue(result.isFailure)
+ }
+
+ // --- getGoalSuggestion ---
+
+ @Test
+ fun `getGoalSuggestion returns success from api`() =
+ runTest {
+ val result = repo().getGoalSuggestion(
+ sex = Sex.Male,
+ heightCm = 178,
+ dateOfBirth = LocalDate(1990, 6, 15),
+ currentWeightKg = 80,
+ )
+ assertTrue(result.isSuccess)
+ assertEquals(FakeOnboardingApi.DEFAULT_GOAL_SUGGESTION, result.getOrThrow())
+ }
+
+ @Test
+ fun `getGoalSuggestion returns failure when api throws`() =
+ runTest {
+ val error = RuntimeException("network error")
+ val result = repo(api = FakeOnboardingApi(error = error)).getGoalSuggestion(
+ sex = Sex.Male,
+ heightCm = 178,
+ dateOfBirth = LocalDate(1990, 6, 15),
+ currentWeightKg = 80,
+ )
+ assertTrue(result.isFailure)
+ assertEquals(error, result.exceptionOrNull())
+ }
+
+ // --- getSummary ---
+
+ @Test
+ fun `getSummary returns success from api`() =
+ runTest {
+ val result = repo().getSummary(
+ sex = Sex.Female,
+ heightCm = 165,
+ dateOfBirth = LocalDate(1995, 3, 20),
+ currentWeightKg = 65,
+ activityLevel = ActivityLevel.LightlyActive,
+ dailyDeficitKcal = 500,
+ )
+ assertTrue(result.isSuccess)
+ assertEquals(FakeOnboardingApi.DEFAULT_SUMMARY, result.getOrThrow())
+ }
+
+ @Test
+ fun `getSummary returns failure when api throws`() =
+ runTest {
+ val error = RuntimeException("network error")
+ val result = repo(api = FakeOnboardingApi(error = error)).getSummary(
+ sex = Sex.Female,
+ heightCm = 165,
+ dateOfBirth = LocalDate(1995, 3, 20),
+ currentWeightKg = 65,
+ activityLevel = ActivityLevel.LightlyActive,
+ dailyDeficitKcal = 500,
+ )
+ assertTrue(result.isFailure)
+ }
+}
diff --git a/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupViewModelTest.kt b/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupViewModelTest.kt
new file mode 100644
index 0000000..25eea08
--- /dev/null
+++ b/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupViewModelTest.kt
@@ -0,0 +1,292 @@
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package dev.yaseyo.onboarding.ui.profilesetup
+
+import app.cash.turbine.test
+import dev.yaseyo.coroutines.testing.TestDispatcherProvider
+import dev.yaseyo.navigation.AppRouter
+import dev.yaseyo.onboarding.fake.FakeOnboardingApi
+import dev.yaseyo.onboarding.fake.FakeOnboardingRepository
+import dev.yaseyo.onboarding.fake.FakeUserRepository
+import dev.yaseyo.onboarding.model.OnboardingDraft
+import dev.yaseyo.user.api.ActivityLevel
+import dev.yaseyo.user.api.Sex
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class ProfileSetupViewModelTest {
+ private val testDispatcher = StandardTestDispatcher()
+ private val dispatchers = TestDispatcherProvider(testDispatcher)
+
+ @BeforeTest
+ fun setup() {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun viewModel(
+ repo: FakeOnboardingRepository = FakeOnboardingRepository(),
+ userRepository: FakeUserRepository = FakeUserRepository(),
+ ) = ProfileSetupViewModel(repo = repo, dispatchers = dispatchers, router = AppRouter(), userRepository = userRepository)
+
+ // --- draft restoration ---
+
+ @Test
+ fun `uiState reflects persisted draft on start`() =
+ runTest {
+ val draft = OnboardingDraft(name = "Alex", heightCm = 182, sex = Sex.Female)
+ val vm = viewModel(repo = FakeOnboardingRepository(initialDraft = draft))
+ vm.uiState.test {
+ awaitItem()
+ advanceUntilIdle()
+ val state = expectMostRecentItem()
+ assertEquals("Alex", state.name)
+ assertEquals(182, state.heightCm)
+ assertEquals(Sex.Female, state.sex)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ // --- initial data loading ---
+
+ @Test
+ fun `loadInitialData populates steps and clears loading flag`() =
+ runTest {
+ val vm = viewModel()
+ vm.uiState.test {
+ awaitItem()
+ advanceUntilIdle()
+ val state = expectMostRecentItem()
+ assertEquals(FakeOnboardingApi.DEFAULT_STEPS, state.steps)
+ assertFalse(state.isLoadingSteps)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `loadInitialData populates activity levels`() =
+ runTest {
+ val vm = viewModel()
+ vm.uiState.test {
+ awaitItem()
+ advanceUntilIdle()
+ assertEquals(FakeOnboardingApi.DEFAULT_ACTIVITY_LEVELS, expectMostRecentItem().activityLevels)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `loadInitialData clears loading flag on steps failure`() =
+ runTest {
+ val repo = FakeOnboardingRepository(stepsResult = Result.failure(RuntimeException("error")))
+ val vm = viewModel(repo = repo)
+ vm.uiState.test {
+ awaitItem()
+ advanceUntilIdle()
+ val state = expectMostRecentItem()
+ assertFalse(state.isLoadingSteps)
+ assertTrue(state.steps.isEmpty())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ // --- event handlers save to repo ---
+
+ @Test
+ fun `onNameChanged saves name to repo`() =
+ runTest {
+ val repo = FakeOnboardingRepository()
+ val vm = viewModel(repo = repo)
+ vm.onNameChanged("Alex")
+ advanceUntilIdle()
+ assertEquals("Alex", repo.savedDrafts.last().name)
+ }
+
+ @Test
+ fun `onSexChanged saves sex to repo`() =
+ runTest {
+ val repo = FakeOnboardingRepository()
+ val vm = viewModel(repo = repo)
+ vm.onSexChanged(Sex.Female)
+ advanceUntilIdle()
+ assertEquals(Sex.Female, repo.savedDrafts.last().sex)
+ }
+
+ @Test
+ fun `onHeightChanged saves height to repo`() =
+ runTest {
+ val repo = FakeOnboardingRepository()
+ val vm = viewModel(repo = repo)
+ vm.onHeightChanged(175)
+ advanceUntilIdle()
+ assertEquals(175, repo.savedDrafts.last().heightCm)
+ }
+
+ @Test
+ fun `onActivityLevelSelected saves activity level to repo`() =
+ runTest {
+ val repo = FakeOnboardingRepository()
+ val vm = viewModel(repo = repo)
+ vm.onActivityLevelSelected(ActivityLevel.LightlyActive)
+ advanceUntilIdle()
+ assertEquals(ActivityLevel.LightlyActive, repo.savedDrafts.last().activityLevel)
+ }
+
+ // --- uiState reflects saved draft ---
+
+ @Test
+ fun `uiState updates when draft is saved`() =
+ runTest {
+ val vm = viewModel()
+ vm.uiState.test {
+ awaitItem()
+ advanceUntilIdle()
+ expectMostRecentItem()
+ vm.onNameChanged("Sam")
+ advanceUntilIdle()
+ assertEquals("Sam", expectMostRecentItem().name)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ // --- navigation ---
+
+ @Test
+ fun `onContinue advances step index`() =
+ runTest {
+ val repo = FakeOnboardingRepository()
+ val vm = viewModel(repo = repo)
+ vm.onContinue()
+ advanceUntilIdle()
+ assertEquals(1, repo.savedDrafts.last().currentStepIndex)
+ }
+
+ @Test
+ fun `onBack decrements step index when not at first step`() =
+ runTest {
+ val repo = FakeOnboardingRepository(initialDraft = OnboardingDraft(currentStepIndex = 2))
+ val vm = viewModel(repo = repo)
+ advanceUntilIdle() // let draftState collect currentStepIndex = 2 from repo
+ vm.onBack()
+ advanceUntilIdle()
+ assertEquals(1, repo.savedDrafts.last().currentStepIndex)
+ }
+
+ @Test
+ fun `onBack does not save draft when already at first step`() =
+ runTest {
+ val repo = FakeOnboardingRepository(initialDraft = OnboardingDraft(currentStepIndex = 0))
+ val vm = viewModel(repo = repo)
+ vm.onBack()
+ advanceUntilIdle()
+ assertTrue(repo.savedDrafts.isEmpty())
+ }
+
+ // --- goal suggestion ---
+
+ @Test
+ fun `fetchGoalSuggestion updates uiState on success`() =
+ runTest {
+ val repo = FakeOnboardingRepository(
+ initialDraft = OnboardingDraft(
+ currentStepIndex = 5,
+ sex = Sex.Male,
+ heightCm = 178,
+ currentWeightKg = 80,
+ ),
+ )
+ val vm = viewModel(repo = repo)
+ vm.uiState.test {
+ awaitItem()
+ advanceUntilIdle()
+ expectMostRecentItem()
+ vm.onContinue()
+ advanceUntilIdle()
+ val state = expectMostRecentItem()
+ assertNotNull(state.goalSuggestion)
+ assertFalse(state.isLoadingGoalSuggestion)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `fetchGoalSuggestion clears loading on failure`() =
+ runTest {
+ val repo = FakeOnboardingRepository(
+ initialDraft = OnboardingDraft(currentStepIndex = 5, sex = Sex.Male),
+ goalSuggestionResult = Result.failure(RuntimeException("error")),
+ )
+ val vm = viewModel(repo = repo)
+ vm.uiState.test {
+ awaitItem()
+ advanceUntilIdle()
+ expectMostRecentItem()
+ vm.onContinue()
+ advanceUntilIdle()
+ val state = expectMostRecentItem()
+ assertNull(state.goalSuggestion)
+ assertFalse(state.isLoadingGoalSuggestion)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ // --- summary ---
+
+ @Test
+ fun `onDeficitChanged triggers summary fetch`() =
+ runTest {
+ val repo = FakeOnboardingRepository(
+ initialDraft = OnboardingDraft(
+ sex = Sex.Female,
+ heightCm = 165,
+ currentWeightKg = 65,
+ activityLevel = ActivityLevel.LightlyActive,
+ ),
+ )
+ val vm = viewModel(repo = repo)
+ vm.uiState.test {
+ awaitItem()
+ advanceUntilIdle()
+ expectMostRecentItem()
+ vm.onDeficitChanged(500)
+ advanceUntilIdle()
+ val state = expectMostRecentItem()
+ assertNotNull(state.onboardingSummary)
+ assertFalse(state.isLoadingSummary)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `fetchSummary is skipped when sex is missing`() =
+ runTest {
+ val repo = FakeOnboardingRepository(initialDraft = OnboardingDraft(sex = null))
+ val vm = viewModel(repo = repo)
+ vm.uiState.test {
+ awaitItem()
+ advanceUntilIdle()
+ assertNull(expectMostRecentItem().onboardingSummary)
+ vm.onDeficitChanged(500)
+ advanceUntilIdle()
+ expectNoEvents() // no summary fetch means no new emission
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+}
diff --git a/feature/onboarding/src/iosMain/kotlin/dev/yaseyo/onboarding/AuthViewControllers.kt b/feature/onboarding/src/iosMain/kotlin/dev/yaseyo/onboarding/AuthViewControllers.kt
index 77694ad..575dc91 100644
--- a/feature/onboarding/src/iosMain/kotlin/dev/yaseyo/onboarding/AuthViewControllers.kt
+++ b/feature/onboarding/src/iosMain/kotlin/dev/yaseyo/onboarding/AuthViewControllers.kt
@@ -4,9 +4,12 @@ import dev.yaseyo.design.createYaseyoUiViewController
import dev.yaseyo.onboarding.ui.auth.AuthLandingScreen
import dev.yaseyo.onboarding.ui.auth.SignInSheetScreen
import dev.yaseyo.onboarding.ui.auth.SignUpSheetScreen
+import dev.yaseyo.onboarding.ui.profilesetup.ProfileSetupScreen
fun authLandingScreenViewController() = createYaseyoUiViewController { AuthLandingScreen() }
fun signInScreenViewController() = createYaseyoUiViewController { SignInSheetScreen() }
fun signUpScreenViewController() = createYaseyoUiViewController { SignUpSheetScreen() }
+
+fun profileSetupScreenViewController() = createYaseyoUiViewController { ProfileSetupScreen() }
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 267aaf9..0c98074 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,5 +1,6 @@
[versions]
agp = "9.1.1"
+datastore = "1.2.1"
firebase-gitlive = "2.4.0"
firebase-android-bom = "34.12.0"
googleServices = "4.4.4"
@@ -35,6 +36,8 @@ ui-tooling = "1.11.1"
composeNavigationEvent = "1.0.1"
[libraries]
+androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
+androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { module = "junit:junit", version.ref = "junit" }
@@ -57,6 +60,8 @@ compose-material-icons-extended = { module = "org.jetbrains.compose.material:mat
firebase-auth = { module = "dev.gitlive:firebase-auth", version.ref = "firebase-gitlive" }
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" }
+kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
+turbine = { module = "app.cash.turbine:turbine", version = "1.2.0" }
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" }
diff --git a/iosApp/iosApp/AppCoordinator.swift b/iosApp/iosApp/AppCoordinator.swift
index b7c8bc5..d70e2d5 100644
--- a/iosApp/iosApp/AppCoordinator.swift
+++ b/iosApp/iosApp/AppCoordinator.swift
@@ -17,18 +17,26 @@ class AppCoordinator: ObservableObject {
for await navEvent in appRouter.events {
switch onEnum(of: navEvent) {
case .goBack:
- navigationController?.popViewController(animated: true)
+ if navigationController?.presentedViewController != nil {
+ navigationController?.dismiss(animated: true)
+ } else {
+ navigationController?.popViewController(animated: true)
+ }
case .goTo(let navigationEvent):
guard let vc = makeViewController(for: navigationEvent.route) else { return }
- if (navigationEvent.route is ModalRoute) {
+ if navigationEvent.route is ModalRoute {
vc.modalPresentationStyle = .pageSheet
if let sheet = vc.sheetPresentationController {
- sheet.prefersGrabberVisible = true
+ sheet.prefersGrabberVisible = true
}
navigationController?.present(vc, animated: true)
} else {
navigationController?.pushViewController(vc, animated: true)
}
+ case .clearBackStackAndGoTo(let navigationEvent):
+ guard let vc = makeViewController(for: navigationEvent.route) else { return }
+ navigationController?.dismiss(animated: false)
+ navigationController?.setViewControllers([vc], animated: true)
}
}
}
@@ -45,8 +53,8 @@ class AppCoordinator: ObservableObject {
if route is OnboardingRoutesAuth.Landing { return authLandingScreenViewController() }
if route is OnboardingRoutesAuth.SignIn { return signInScreenViewController() }
if route is OnboardingRoutesAuth.SignUp { return signUpScreenViewController() }
- if route is OnboardingRoutesProfileSetup { return nil } // TODO
- if route is Home { return nil } // TODO
+ if route is OnboardingRoutesProfileSetup { return profileSetupScreenViewController() }
+ if route is Home { return nil } // TODO: Home screen
return nil
}
}
diff --git a/libs/auth/api/build.gradle.kts b/libs/auth/api/build.gradle.kts
index 8364568..2bd561e 100644
--- a/libs/auth/api/build.gradle.kts
+++ b/libs/auth/api/build.gradle.kts
@@ -7,10 +7,10 @@ kotlin {
namespace = "dev.yaseyo.auth.api"
withHostTestBuilder {
+ sourceSetTreeName = "test"
}
withDeviceTestBuilder {
- sourceSetTreeName = "test"
}.configure {
instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/libs/auth/build.gradle.kts b/libs/auth/build.gradle.kts
index 6fc982c..48dd23f 100644
--- a/libs/auth/build.gradle.kts
+++ b/libs/auth/build.gradle.kts
@@ -3,14 +3,14 @@ plugins {
}
kotlin {
- androidLibrary {
+ android {
namespace = "dev.yaseyo.auth"
withHostTestBuilder {
+ sourceSetTreeName = "test"
}
withDeviceTestBuilder {
- sourceSetTreeName = "test"
}.configure {
instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -22,7 +22,6 @@ kotlin {
implementation(libs.koin.core)
implementation(libs.kotlin.stdlib)
api(projects.libs.auth.api)
- implementation(projects.libs.auth.impl)
}
}
diff --git a/libs/auth/impl/build.gradle.kts b/libs/auth/impl/build.gradle.kts
index ee039f6..3da7933 100644
--- a/libs/auth/impl/build.gradle.kts
+++ b/libs/auth/impl/build.gradle.kts
@@ -7,10 +7,10 @@ kotlin {
namespace = "dev.yaseyo.auth.impl"
withHostTestBuilder {
+ sourceSetTreeName = "test"
}
withDeviceTestBuilder {
- sourceSetTreeName = "test"
}.configure {
instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/libs/auth/src/commonMain/kotlin/dev/yaseyo/auth/di/AuthModule.kt b/libs/auth/src/commonMain/kotlin/dev/yaseyo/auth/di/AuthModule.kt
deleted file mode 100644
index 9493bf2..0000000
--- a/libs/auth/src/commonMain/kotlin/dev/yaseyo/auth/di/AuthModule.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package dev.yaseyo.auth.di
-
-import dev.yaseyo.auth.impl.authImplModule
-import org.koin.dsl.module
-
-val authModule = module {
- includes(authImplModule)
-}
diff --git a/libs/coroutines/testing/build.gradle.kts b/libs/coroutines/testing/build.gradle.kts
new file mode 100644
index 0000000..35f47ca
--- /dev/null
+++ b/libs/coroutines/testing/build.gradle.kts
@@ -0,0 +1,18 @@
+plugins {
+ id("yaseyo.kmp.library")
+}
+
+kotlin {
+ androidLibrary {
+ namespace = "dev.yaseyo.coroutines.testing"
+ }
+
+ sourceSets {
+ commonMain {
+ dependencies {
+ api(projects.libs.coroutines)
+ api(libs.kotlinx.coroutines.test)
+ }
+ }
+ }
+}
diff --git a/libs/coroutines/testing/src/commonMain/kotlin/dev/yaseyo/coroutines/testing/TestDispatcherProvider.kt b/libs/coroutines/testing/src/commonMain/kotlin/dev/yaseyo/coroutines/testing/TestDispatcherProvider.kt
new file mode 100644
index 0000000..26892ad
--- /dev/null
+++ b/libs/coroutines/testing/src/commonMain/kotlin/dev/yaseyo/coroutines/testing/TestDispatcherProvider.kt
@@ -0,0 +1,14 @@
+package dev.yaseyo.coroutines.testing
+
+import dev.yaseyo.coroutines.DispatcherProvider
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.test.StandardTestDispatcher
+
+class TestDispatcherProvider(
+ dispatcher: CoroutineDispatcher = StandardTestDispatcher(),
+) : DispatcherProvider {
+ override val io = dispatcher
+ override val main = dispatcher
+ override val default = dispatcher
+ override val immediate = dispatcher
+}
diff --git a/libs/datastore/build.gradle.kts b/libs/datastore/build.gradle.kts
new file mode 100644
index 0000000..659e929
--- /dev/null
+++ b/libs/datastore/build.gradle.kts
@@ -0,0 +1,22 @@
+plugins {
+ id("yaseyo.kmp.library")
+}
+
+kmpLibrary {}
+
+kotlin {
+ android {
+ namespace = "dev.yaseyo.datastore"
+ }
+
+ sourceSets {
+ commonMain {
+ dependencies {
+ implementation(libs.kotlinx.serialization.json)
+ api(libs.androidx.datastore)
+ api(libs.androidx.datastore.preferences)
+ implementation(libs.koin.core)
+ }
+ }
+ }
+}
diff --git a/libs/datastore/src/androidMain/kotlin/dev/yaseyo/datastore/DataStoreFactory.android.kt b/libs/datastore/src/androidMain/kotlin/dev/yaseyo/datastore/DataStoreFactory.android.kt
new file mode 100644
index 0000000..f1ec298
--- /dev/null
+++ b/libs/datastore/src/androidMain/kotlin/dev/yaseyo/datastore/DataStoreFactory.android.kt
@@ -0,0 +1,12 @@
+package dev.yaseyo.datastore
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import org.koin.mp.KoinPlatform
+
+actual fun createDataStore(): DataStore =
+ createDataStore {
+ val context = KoinPlatform.getKoin().get()
+ context.filesDir.resolve(DATA_STORE_FILE_NAME).absolutePath
+ }
diff --git a/libs/datastore/src/commonMain/kotlin/dev/yaseyo/datastore/DataStoreFactory.kt b/libs/datastore/src/commonMain/kotlin/dev/yaseyo/datastore/DataStoreFactory.kt
new file mode 100644
index 0000000..abd9881
--- /dev/null
+++ b/libs/datastore/src/commonMain/kotlin/dev/yaseyo/datastore/DataStoreFactory.kt
@@ -0,0 +1,17 @@
+package dev.yaseyo.datastore
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import okio.Path.Companion.toPath
+
+internal fun createDataStore(storePath: () -> String): DataStore =
+ PreferenceDataStoreFactory.createWithPath(
+ produceFile = {
+ storePath.invoke().toPath()
+ },
+ )
+
+expect fun createDataStore(): DataStore
+
+internal const val DATA_STORE_FILE_NAME = "yaseyo.preferences_pb"
diff --git a/libs/datastore/src/commonMain/kotlin/dev/yaseyo/datastore/DataStoreModule.kt b/libs/datastore/src/commonMain/kotlin/dev/yaseyo/datastore/DataStoreModule.kt
new file mode 100644
index 0000000..05324c5
--- /dev/null
+++ b/libs/datastore/src/commonMain/kotlin/dev/yaseyo/datastore/DataStoreModule.kt
@@ -0,0 +1,9 @@
+package dev.yaseyo.datastore
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import org.koin.dsl.module
+
+val dataStoreModule = module {
+ single> { createDataStore() }
+}
diff --git a/libs/datastore/src/iosMain/kotlin/dev/yaseyo/datastore/DataStoreFactory.ios.kt b/libs/datastore/src/iosMain/kotlin/dev/yaseyo/datastore/DataStoreFactory.ios.kt
new file mode 100644
index 0000000..36f3c9a
--- /dev/null
+++ b/libs/datastore/src/iosMain/kotlin/dev/yaseyo/datastore/DataStoreFactory.ios.kt
@@ -0,0 +1,22 @@
+package dev.yaseyo.datastore
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import kotlinx.cinterop.ExperimentalForeignApi
+import platform.Foundation.NSDocumentDirectory
+import platform.Foundation.NSFileManager
+import platform.Foundation.NSURL
+import platform.Foundation.NSUserDomainMask
+
+@OptIn(ExperimentalForeignApi::class)
+actual fun createDataStore(): DataStore =
+ createDataStore {
+ val documentDir: NSURL? = NSFileManager.defaultManager.URLForDirectory(
+ directory = NSDocumentDirectory,
+ inDomain = NSUserDomainMask,
+ appropriateForURL = null,
+ create = false,
+ error = null,
+ )
+ requireNotNull(documentDir).path + "/${DATA_STORE_FILE_NAME}"
+ }
diff --git a/libs/di/build.gradle.kts b/libs/di/build.gradle.kts
index 37491c6..5f75d5d 100644
--- a/libs/di/build.gradle.kts
+++ b/libs/di/build.gradle.kts
@@ -10,7 +10,7 @@ kmpLibrary {
}
kotlin {
- androidLibrary {
+ android {
namespace = "dev.yaseyo.di"
}
@@ -34,9 +34,13 @@ kotlin {
// Libs
implementation(projects.libs.coroutines)
- api(projects.libs.auth.api)
- implementation(projects.libs.auth)
+ implementation(projects.libs.auth.impl)
implementation(projects.libs.network)
+ implementation(projects.libs.datastore)
+
+ // Exported Libs
+ api(projects.libs.auth.api)
+ implementation(projects.libs.user.impl)
api(projects.libs.user)
api(projects.libs.navigation)
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 926f2e1..43d9911 100644
--- a/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt
+++ b/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt
@@ -12,7 +12,12 @@ class KoinInitializer : Initializer {
override fun create(context: Context): KoinApplication =
startKoin {
androidContext(context)
- modules(appModules + navigationAndroidModule + onboardingFeatureAndroidModule)
+
+ val modules = appModules + listOf(
+ navigationAndroidModule,
+ onboardingFeatureAndroidModule,
+ )
+ modules(modules)
}
override fun dependencies(): List>> = emptyList()
diff --git a/libs/di/src/androidMain/kotlin/dev/yaseyo/di/NetworkConfigProvider.android.kt b/libs/di/src/androidMain/kotlin/dev/yaseyo/di/NetworkConfigProvider.android.kt
index 744db62..2be5fb9 100644
--- a/libs/di/src/androidMain/kotlin/dev/yaseyo/di/NetworkConfigProvider.android.kt
+++ b/libs/di/src/androidMain/kotlin/dev/yaseyo/di/NetworkConfigProvider.android.kt
@@ -3,4 +3,4 @@ package dev.yaseyo.di
import dev.yaseyo.network.NetworkConfig
// 10.0.2.2 is the Android emulator's alias for localhost
-internal actual val networkConfig = NetworkConfig(baseUrl = "http://10.0.2.2:8080")
+internal actual val networkConfig = NetworkConfig(baseUrl = "http://10.0.2.2:3000")
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 b9dd6e6..a55351d 100644
--- a/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt
+++ b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt
@@ -1,19 +1,21 @@
package dev.yaseyo.di
-import dev.yaseyo.auth.di.authModule
+import dev.yaseyo.auth.impl.authImplModule
import dev.yaseyo.coroutines.coroutinesModule
+import dev.yaseyo.datastore.dataStoreModule
import dev.yaseyo.navigation.navigationModule
import dev.yaseyo.network.networkModule
import dev.yaseyo.onboarding.di.onboardingModule
-import dev.yaseyo.user.di.userModule
+import dev.yaseyo.user.impl.userImplModule
import org.koin.dsl.module
internal val appModules = listOf(
navigationModule,
coroutinesModule,
- authModule,
- userModule,
+ authImplModule,
+ userImplModule,
module { single { networkConfig } },
networkModule,
onboardingModule,
+ dataStoreModule,
)
diff --git a/libs/domain-models/build.gradle.kts b/libs/domain-models/build.gradle.kts
index 0a1caba..51f0766 100644
--- a/libs/domain-models/build.gradle.kts
+++ b/libs/domain-models/build.gradle.kts
@@ -7,10 +7,10 @@ kotlin {
namespace = "dev.yaseyo.domain.models"
withHostTestBuilder {
+ sourceSetTreeName = "test"
}
withDeviceTestBuilder {
- sourceSetTreeName = "test"
}.configure {
instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/BottomSheetSceneStrategy.kt b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/BottomSheetSceneStrategy.kt
index 96d5e2d..c1f8b6c 100644
--- a/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/BottomSheetSceneStrategy.kt
+++ b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/BottomSheetSceneStrategy.kt
@@ -1,10 +1,6 @@
package dev.yaseyo.navigation
-import androidx.compose.foundation.layout.WindowInsets
-import androidx.compose.foundation.layout.exclude
-import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.statusBarsPadding
-import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalBottomSheetProperties
@@ -40,20 +36,15 @@ internal data class BottomSheetScene(
override val content: @Composable () -> Unit = {
val lifecycleOwner = rememberLifecycleOwner()
- YaseyoTheme {
- ModalBottomSheet(
- modifier = Modifier.statusBarsPadding(),
- sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
- onDismissRequest = onBack,
- properties = modalBottomSheetProperties,
- containerColor = YaseyoTheme.colors.backgroundElevated,
- contentWindowInsets = {
- BottomSheetDefaults.windowInsets.exclude(WindowInsets.ime)
- },
- ) {
- CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) {
- entry.Content()
- }
+ ModalBottomSheet(
+ modifier = Modifier.statusBarsPadding(),
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ onDismissRequest = onBack,
+ properties = modalBottomSheetProperties,
+ containerColor = YaseyoTheme.colors.backgroundElevated,
+ ) {
+ CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) {
+ entry.Content()
}
}
}
diff --git a/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/FinishHandler.kt b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/FinishHandler.kt
new file mode 100644
index 0000000..519d92a
--- /dev/null
+++ b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/FinishHandler.kt
@@ -0,0 +1,5 @@
+package dev.yaseyo.navigation
+
+interface FinishHandler {
+ fun finish()
+}
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 d6f2530..171e22f 100644
--- a/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/Navigator.kt
+++ b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/Navigator.kt
@@ -2,14 +2,25 @@ package dev.yaseyo.navigation
import androidx.compose.runtime.mutableStateListOf
-class Navigator {
+class Navigator(
+ private val finishHandler: FinishHandler,
+) {
val backStack = mutableStateListOf()
fun goTo(destination: AppRoute) {
backStack.add(destination)
}
+ fun clearAndGoTo(destination: AppRoute) {
+ backStack.add(destination)
+ backStack.removeRange(0, backStack.size - 1)
+ }
+
fun goBack() {
- backStack.removeLastOrNull()
+ if (backStack.size <= 1) {
+ finishHandler.finish()
+ } else {
+ backStack.removeLastOrNull()
+ }
}
}
diff --git a/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRouter.kt b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRouter.kt
index 968c9aa..4c8341a 100644
--- a/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRouter.kt
+++ b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRouter.kt
@@ -12,6 +12,10 @@ class AppRouter {
_events.tryEmit(NavigationEvent.GoTo(route))
}
+ fun navigateAndClearBackStack(route: AppRoute) {
+ _events.tryEmit(NavigationEvent.ClearBackStackAndGoTo(route))
+ }
+
fun goBack() {
_events.tryEmit(NavigationEvent.GoBack)
}
diff --git a/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/NavigationEvent.kt b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/NavigationEvent.kt
index b0bbdf8..c52cda1 100644
--- a/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/NavigationEvent.kt
+++ b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/NavigationEvent.kt
@@ -5,5 +5,9 @@ sealed interface NavigationEvent {
val route: AppRoute,
) : NavigationEvent
+ data class ClearBackStackAndGoTo(
+ val route: AppRoute,
+ ) : NavigationEvent
+
data object GoBack : NavigationEvent
}
diff --git a/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/ModalRoute.kt b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/RouteMarkers.kt
similarity index 68%
rename from libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/ModalRoute.kt
rename to libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/RouteMarkers.kt
index 35e49cd..37a91da 100644
--- a/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/ModalRoute.kt
+++ b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/RouteMarkers.kt
@@ -1,3 +1,5 @@
package dev.yaseyo.navigation
interface ModalRoute
+
+interface AppRootRoute
diff --git a/libs/network/src/commonMain/kotlin/dev/yaseyo/network/NetworkModule.kt b/libs/network/src/commonMain/kotlin/dev/yaseyo/network/NetworkModule.kt
index 637045b..b66568a 100644
--- a/libs/network/src/commonMain/kotlin/dev/yaseyo/network/NetworkModule.kt
+++ b/libs/network/src/commonMain/kotlin/dev/yaseyo/network/NetworkModule.kt
@@ -2,6 +2,8 @@ package dev.yaseyo.network
import dev.yaseyo.auth.api.AuthRepository
import io.ktor.client.HttpClient
+import io.ktor.client.plugins.HttpResponseValidator
+import io.ktor.client.plugins.ResponseException
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
@@ -11,6 +13,7 @@ import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.http.ContentType
import io.ktor.http.contentType
+import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import org.koin.dsl.module
@@ -38,6 +41,13 @@ val networkModule =
},
)
}
+ HttpResponseValidator {
+ validateResponse { response ->
+ if (!response.status.isSuccess()) {
+ throw ResponseException(response, "HTTP ${response.status.value}")
+ }
+ }
+ }
install(Logging) {
level = LogLevel.ALL
}
diff --git a/libs/user/api/build.gradle.kts b/libs/user/api/build.gradle.kts
index 6b160cb..3d54601 100644
--- a/libs/user/api/build.gradle.kts
+++ b/libs/user/api/build.gradle.kts
@@ -7,10 +7,10 @@ kotlin {
namespace = "dev.yaseyo.user.api"
withHostTestBuilder {
+ sourceSetTreeName = "test"
}
withDeviceTestBuilder {
- sourceSetTreeName = "test"
}.configure {
instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/libs/user/api/src/commonMain/kotlin/dev/yaseyo/user/api/Sex.kt b/libs/user/api/src/commonMain/kotlin/dev/yaseyo/user/api/Sex.kt
index bee7b5d..8a218e2 100644
--- a/libs/user/api/src/commonMain/kotlin/dev/yaseyo/user/api/Sex.kt
+++ b/libs/user/api/src/commonMain/kotlin/dev/yaseyo/user/api/Sex.kt
@@ -1,6 +1,6 @@
package dev.yaseyo.user.api
enum class Sex {
- MALE,
- FEMALE,
+ Male,
+ Female,
}
diff --git a/libs/user/build.gradle.kts b/libs/user/build.gradle.kts
index f7edded..d4d9784 100644
--- a/libs/user/build.gradle.kts
+++ b/libs/user/build.gradle.kts
@@ -7,10 +7,10 @@ kotlin {
namespace = "dev.yaseyo.user"
withHostTestBuilder {
+ sourceSetTreeName = "test"
}
withDeviceTestBuilder {
- sourceSetTreeName = "test"
}.configure {
instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -20,9 +20,7 @@ kotlin {
commonMain {
dependencies {
implementation(libs.kotlin.stdlib)
- implementation(libs.koin.core)
api(projects.libs.user.api)
- implementation(projects.libs.user.impl)
}
}
diff --git a/libs/user/impl/build.gradle.kts b/libs/user/impl/build.gradle.kts
index 7785d9d..e822d57 100644
--- a/libs/user/impl/build.gradle.kts
+++ b/libs/user/impl/build.gradle.kts
@@ -9,10 +9,10 @@ kotlin {
namespace = "dev.yaseyo.user.impl"
withHostTestBuilder {
+ sourceSetTreeName = "test"
}
withDeviceTestBuilder {
- sourceSetTreeName = "test"
}.configure {
instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/libs/user/impl/src/commonMain/kotlin/dev/yaseyo/user/impl/network/UserApiImpl.kt b/libs/user/impl/src/commonMain/kotlin/dev/yaseyo/user/impl/network/UserApiImpl.kt
index ac83316..fa9987d 100644
--- a/libs/user/impl/src/commonMain/kotlin/dev/yaseyo/user/impl/network/UserApiImpl.kt
+++ b/libs/user/impl/src/commonMain/kotlin/dev/yaseyo/user/impl/network/UserApiImpl.kt
@@ -41,7 +41,7 @@ internal class UserApiImpl(
setBody(
CreateUserRequestNet(
name = name,
- sex = sex.name,
+ sex = sex.name.uppercase(),
height = height,
dateOfBirth = dateOfBirth.toString(),
activityLevel = activityLevel.toApiString(),
@@ -71,7 +71,7 @@ internal class UserApiImpl(
setBody(
buildJsonObject {
name?.let { put("name", it) }
- sex?.let { put("sex", it.name) }
+ sex?.let { put("sex", it.name.uppercase()) }
height?.let { put("height", it) }
dateOfBirth?.let { put("dateOfBirth", it.toString()) }
activityLevel?.let { put("activityLevel", it.toApiString()) }
diff --git a/libs/user/impl/src/commonMain/kotlin/dev/yaseyo/user/impl/network/UserNet.kt b/libs/user/impl/src/commonMain/kotlin/dev/yaseyo/user/impl/network/UserNet.kt
index b886f29..4a9e8bc 100644
--- a/libs/user/impl/src/commonMain/kotlin/dev/yaseyo/user/impl/network/UserNet.kt
+++ b/libs/user/impl/src/commonMain/kotlin/dev/yaseyo/user/impl/network/UserNet.kt
@@ -27,9 +27,9 @@ internal fun UserNet.toDomain() =
User(
id = id,
name = name,
- sex = Sex.valueOf(sex),
+ sex = Sex.valueOf(sex.lowercase().replaceFirstChar { it.uppercase() }),
height = height,
- dateOfBirth = LocalDate.parse(dateOfBirth),
+ dateOfBirth = LocalDate.parse(dateOfBirth.take(10)),
activityLevel = activityLevel.toActivityLevel(),
targetWeightKg = targetWeightKg,
dailyDeficitKcal = dailyDeficitKcal,
diff --git a/libs/user/impl/src/commonTest/kotlin/dev/yaseyo/user/impl/UserRepositoryImplTest.kt b/libs/user/impl/src/commonTest/kotlin/dev/yaseyo/user/impl/UserRepositoryImplTest.kt
index 0a82c88..ed1b62d 100644
--- a/libs/user/impl/src/commonTest/kotlin/dev/yaseyo/user/impl/UserRepositoryImplTest.kt
+++ b/libs/user/impl/src/commonTest/kotlin/dev/yaseyo/user/impl/UserRepositoryImplTest.kt
@@ -24,7 +24,7 @@ class UserRepositoryImplTest {
assertEquals("usr_abc123", user.id)
assertEquals("Alex Smith", user.name)
assertIs(user.sex)
- assertEquals(Sex.MALE, user.sex)
+ assertEquals(Sex.Male, user.sex)
assertEquals(ActivityLevel.LightlyActive, user.activityLevel)
assertEquals(LocalDate(1990, 6, 15), user.dateOfBirth)
assertIs(user.createdAt)
@@ -48,7 +48,7 @@ class UserRepositoryImplTest {
runBlocking {
val result = repo().createUser(
name = "Alex Smith",
- sex = Sex.MALE,
+ sex = Sex.Male,
height = 178.0,
dateOfBirth = LocalDate(1990, 6, 15),
activityLevel = ActivityLevel.LightlyActive,
@@ -68,7 +68,7 @@ class UserRepositoryImplTest {
val error = RuntimeException("network error")
val result = repo(FakeUserApi(error = error)).createUser(
name = "Alex Smith",
- sex = Sex.MALE,
+ sex = Sex.Male,
height = 178.0,
dateOfBirth = LocalDate(1990, 6, 15),
activityLevel = ActivityLevel.LightlyActive,
diff --git a/libs/user/impl/src/commonTest/kotlin/dev/yaseyo/user/impl/network/UserApiImplTest.kt b/libs/user/impl/src/commonTest/kotlin/dev/yaseyo/user/impl/network/UserApiImplTest.kt
index 2802176..772fd38 100644
--- a/libs/user/impl/src/commonTest/kotlin/dev/yaseyo/user/impl/network/UserApiImplTest.kt
+++ b/libs/user/impl/src/commonTest/kotlin/dev/yaseyo/user/impl/network/UserApiImplTest.kt
@@ -86,7 +86,7 @@ class UserApiImplTest {
runBlocking {
api.createUser(
name = "Alex Smith",
- sex = Sex.MALE,
+ sex = Sex.Male,
height = 178.0,
dateOfBirth = LocalDate(
year = 1990,
@@ -110,7 +110,7 @@ class UserApiImplTest {
runBlocking {
api.createUser(
name = "Alex Smith",
- sex = Sex.MALE,
+ sex = Sex.Male,
height = 178.0,
dateOfBirth = LocalDate(
year = 1990,
@@ -136,7 +136,7 @@ class UserApiImplTest {
runBlocking {
val result = api.createUser(
name = "Alex Smith",
- sex = Sex.MALE,
+ sex = Sex.Male,
height = 178.0,
dateOfBirth = LocalDate(
year = 1990,
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
deleted file mode 100644
index e040b3a..0000000
--- a/libs/user/src/commonMain/kotlin/dev/yaseyo/user/di/UserModule.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package dev.yaseyo.user.di
-
-import dev.yaseyo.user.impl.userImplModule
-import org.koin.dsl.module
-
-val userModule = module {
- includes(userImplModule)
-}
diff --git a/libs/user/src/iosMain/kotlin/dev/yaseyo/user/UserDependencies.kt b/libs/user/src/iosMain/kotlin/dev/yaseyo/user/UserDependencies.kt
deleted file mode 100644
index b850894..0000000
--- a/libs/user/src/iosMain/kotlin/dev/yaseyo/user/UserDependencies.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package dev.yaseyo.user
-
-import dev.yaseyo.user.api.UserRepository
-import org.koin.mp.KoinPlatform
-
-fun getUserRepository(): UserRepository = KoinPlatform.getKoin().get()
diff --git a/settings.gradle.kts b/settings.gradle.kts
index aaca923..bd4595b 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -40,8 +40,11 @@ include(":libs:user:impl")
include(":libs:auth:api")
include(":libs:auth:impl")
include(":libs:coroutines")
+include(":libs:coroutines:testing")
include(":libs:navigation")
include(":libs:design")
include(":libs:network")
include(":libs:di")
+include(":libs:datastore")
include(":feature:onboarding")
+include(":feature:home")