From ceb852d69bccd2928671ba7d6c77d00717842985 Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 15 May 2026 19:39:50 +0900 Subject: [PATCH 01/27] Implement height selection step in profile setup onboarding --- .../profilesetup/ProfileSetupEventHandler.kt | 3 + .../ui/profilesetup/ProfileSetupScreen.kt | 9 ++ .../ui/profilesetup/ProfileSetupUiState.kt | 3 +- .../ui/profilesetup/ProfileSetupViewModel.kt | 2 + .../ui/profilesetup/widgets/HeightStep.kt | 104 ++++++++++++++++++ 5 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/HeightStep.kt 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..fcb94dd 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 @@ -15,6 +15,9 @@ internal interface ProfileSetupEventHandler { fun onDobYearChanged(year: Int) + // Step 3 + fun onHeightChanged(heightCm: 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..52c802f 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 @@ -34,6 +34,7 @@ import androidx.navigationevent.compose.rememberNavigationEventState 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.HeightStep import dev.yaseyo.onboarding.ui.profilesetup.widgets.NameStep import dev.yaseyo.onboarding.ui.profilesetup.widgets.OnboardingProgressBar import org.koin.compose.viewmodel.koinViewModel @@ -120,6 +121,12 @@ private fun ProfileSetupContent( onDayChanged = eventHandler::onDobDayChanged, onYearChanged = eventHandler::onDobYearChanged, ) + 2 -> HeightStep( + heightCm = state.heightCm, + title = state.currentStep?.title ?: "How tall are you?", + subtitle = state.currentStep?.subtitle, + onHeightChanged = eventHandler::onHeightChanged, + ) } } @@ -179,6 +186,8 @@ private val previewHandler = object : ProfileSetupEventHandler { override fun onDobYearChanged(year: Int) {} + override fun onHeightChanged(heightCm: Int) {} + override fun onContinue() {} override fun onBack() {} 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..fce84bd 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 @@ -8,13 +8,12 @@ internal data class ProfileSetupUiState( val steps: List = emptyList(), val isLoadingSteps: Boolean = true, 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, ) 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..c27c5f1 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 @@ -50,6 +50,8 @@ internal class ProfileSetupViewModel( override fun onDobYearChanged(year: Int) = _uiState.update { it.copy(dobYear = year) } + override fun onHeightChanged(heightCm: Int) = _uiState.update { it.copy(heightCm = heightCm) } + override fun onContinue() { val state = _uiState.value val nextIndex = state.currentStepIndex + 1 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 = {}, + ) + } From e62d2fe6651ba295dcc0abe8a4b90a113bbf4592 Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 15 May 2026 19:43:44 +0900 Subject: [PATCH 02/27] Update Sex enum cases and migrate Compose previews to YaseyoPreview - Rename `Sex` enum members from `MALE`/`FEMALE` to `Male`/`Female` and update all references in code and tests. - Replace `YaseyoTheme` with `YaseyoPreview` in Compose preview functions across onboarding UI components. --- .../ui/profilesetup/ProfileSetupScreen.kt | 7 ++++--- .../ui/profilesetup/widgets/AboutYouStep.kt | 11 ++++++----- .../ui/profilesetup/widgets/NameStep.kt | 7 ++++--- .../widgets/OnboardingProgressBar.kt | 7 ++++--- .../ui/profilesetup/widgets/SexCard.kt | 19 ++++++++++--------- .../profilesetup/widgets/WheelDatePicker.kt | 5 +++-- .../kotlin/dev/yaseyo/user/api/Sex.kt | 4 ++-- .../user/impl/UserRepositoryImplTest.kt | 6 +++--- .../user/impl/network/UserApiImplTest.kt | 6 +++--- 9 files changed, 39 insertions(+), 33 deletions(-) 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 52c802f..b9d2320 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 @@ -31,6 +31,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState +import dev.yaseyo.design.YaseyoPreview import dev.yaseyo.design.YaseyoTheme import dev.yaseyo.onboarding.model.OnboardingStep import dev.yaseyo.onboarding.ui.profilesetup.widgets.AboutYouStep @@ -196,7 +197,7 @@ private val previewHandler = object : ProfileSetupEventHandler { @Preview(showBackground = true) @Composable private fun ProfileSetupNameLightPreview() { - YaseyoTheme(darkTheme = false) { + YaseyoPreview(darkTheme = false) { ProfileSetupContent(state = previewState, eventHandler = previewHandler) } } @@ -204,7 +205,7 @@ private fun ProfileSetupNameLightPreview() { @Preview(showBackground = true) @Composable private fun ProfileSetupNameDarkPreview() { - YaseyoTheme(darkTheme = true) { + YaseyoPreview(darkTheme = true) { ProfileSetupContent(state = previewState, eventHandler = previewHandler) } } @@ -212,7 +213,7 @@ private fun ProfileSetupNameDarkPreview() { @Preview(showBackground = true) @Composable private fun ProfileSetupNameFilledPreview() { - YaseyoTheme(darkTheme = false) { + YaseyoPreview(darkTheme = false) { ProfileSetupContent(state = previewState.copy(name = "Taki"), eventHandler = previewHandler) } } 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 index a9d1947..7870391 100644 --- 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 @@ -10,6 +10,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 @@ -90,7 +91,7 @@ private fun SectionLabel( @Preview(showBackground = true) @Composable private fun AboutYouStepNoneSelectedPreview() { - YaseyoTheme(darkTheme = false) { + YaseyoPreview(darkTheme = false) { AboutYouStep( sex = null, dobMonth = 2, @@ -109,9 +110,9 @@ private fun AboutYouStepNoneSelectedPreview() { @Preview(showBackground = true) @Composable private fun AboutYouStepFemalePreview() { - YaseyoTheme(darkTheme = false) { + YaseyoPreview(darkTheme = false) { AboutYouStep( - sex = Sex.FEMALE, + sex = Sex.Female, dobMonth = 2, dobDay = 14, dobYear = 1994, @@ -128,9 +129,9 @@ private fun AboutYouStepFemalePreview() { @Preview(showBackground = true) @Composable private fun AboutYouStepDarkPreview() { - YaseyoTheme(darkTheme = true) { + YaseyoPreview(darkTheme = true) { AboutYouStep( - sex = Sex.MALE, + sex = Sex.Male, dobMonth = 6, dobDay = 1, dobYear = 1990, 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..313b022 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 @@ -17,6 +17,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 @@ -80,7 +81,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,7 +94,7 @@ private fun NameStepEmptyLightPreview() { @Preview(showBackground = true) @Composable private fun NameStepFilledPreview() { - YaseyoTheme(darkTheme = false) { + YaseyoPreview(darkTheme = false) { NameStep( name = "Taki", title = "What should we call you?", @@ -106,7 +107,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..bd2bef1 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 @@ -15,6 +15,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 @Composable @@ -60,7 +61,7 @@ internal fun OnboardingProgressBar( @Preview(showBackground = true) @Composable private fun OnboardingProgressBarStep1Preview() { - YaseyoTheme(darkTheme = false) { + YaseyoPreview(darkTheme = false) { OnboardingProgressBar(currentStep = 1, totalSteps = 6) } } @@ -68,7 +69,7 @@ private fun OnboardingProgressBarStep1Preview() { @Preview(showBackground = true) @Composable private fun OnboardingProgressBarStep4Preview() { - YaseyoTheme(darkTheme = false) { + YaseyoPreview(darkTheme = false) { OnboardingProgressBar(currentStep = 4, totalSteps = 6) } } @@ -76,7 +77,7 @@ private fun OnboardingProgressBarStep4Preview() { @Preview(showBackground = true) @Composable private fun OnboardingProgressBarDarkPreview() { - YaseyoTheme(darkTheme = true) { + YaseyoPreview(darkTheme = true) { OnboardingProgressBar(currentStep = 5, totalSteps = 6) } } 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..c172ad5 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), ) } @@ -100,7 +101,7 @@ private fun SexCard( @Preview(showBackground = true) @Composable private fun SexSelectionNonePreview() { - YaseyoTheme(darkTheme = false) { + YaseyoPreview(darkTheme = false) { SexSelectionRow(selected = null, onSelected = {}) } } @@ -108,15 +109,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/WheelDatePicker.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/WheelDatePicker.kt index 74c526b..e739cb2 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 @@ -10,6 +10,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 @@ -78,7 +79,7 @@ internal fun WheelDatePicker( @Preview(showBackground = true) @Composable private fun WheelDatePickerPreview() { - YaseyoTheme(darkTheme = false) { + YaseyoPreview(darkTheme = false) { WheelDatePicker( month = 2, day = 14, @@ -93,7 +94,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/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/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, From 015a2a3e1a5d115120bbdf14052a0edc62879191 Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 15 May 2026 19:47:18 +0900 Subject: [PATCH 03/27] Update name in `NameStep` filled preview from "Taki" to "Alex" --- .../dev/yaseyo/onboarding/ui/profilesetup/widgets/NameStep.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 313b022..6c35931 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 @@ -96,7 +96,7 @@ private fun NameStepEmptyLightPreview() { private fun NameStepFilledPreview() { YaseyoPreview(darkTheme = false) { NameStep( - name = "Taki", + name = "Alex", title = "What should we call you?", subtitle = "We'll use this to personalize your experience.", onNameChanged = {}, From e4f93adbac49982a8e408547d3d56d69d9381f45 Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 15 May 2026 20:02:47 +0900 Subject: [PATCH 04/27] Add current weight and activity level steps to onboarding process --- .../onboarding/network/ActivityLevelNet.kt | 11 ++ .../onboarding/network/OnboardingApi.kt | 2 + .../onboarding/network/OnboardingApiImpl.kt | 2 + .../profilesetup/ProfileSetupEventHandler.kt | 7 + .../ui/profilesetup/ProfileSetupScreen.kt | 22 ++- .../ui/profilesetup/ProfileSetupUiState.kt | 11 ++ .../ui/profilesetup/ProfileSetupViewModel.kt | 57 ++++++- .../profilesetup/widgets/ActivityLevelStep.kt | 161 ++++++++++++++++++ .../profilesetup/widgets/CurrentWeightStep.kt | 104 +++++++++++ .../ui/profilesetup/widgets/SexCard.kt | 3 +- 10 files changed, 368 insertions(+), 12 deletions(-) create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/ActivityLevelNet.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/ActivityLevelStep.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/CurrentWeightStep.kt 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/OnboardingApi.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingApi.kt index 02e3340..8639b9c 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 @@ -2,4 +2,6 @@ package dev.yaseyo.onboarding.network internal interface OnboardingApi { suspend fun getSteps(): List + + suspend fun getActivityLevels(): List } 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..916c7d6 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 @@ -8,4 +8,6 @@ internal class OnboardingApiImpl( private val client: HttpClient, ) : OnboardingApi { override suspend fun getSteps(): List = client.get("/onboarding/steps").body() + + override suspend fun getActivityLevels(): List = client.get("/user/activity-levels").body() } 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 fcb94dd..4df8bed 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 { @@ -18,6 +19,12 @@ internal interface ProfileSetupEventHandler { // Step 3 fun onHeightChanged(heightCm: Int) + // Step 4 + fun onCurrentWeightChanged(weightKg: Int) + + // Step 5 + fun onActivityLevelSelected(level: ActivityLevel) + 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 b9d2320..608653f 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 @@ -35,6 +35,8 @@ 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.HeightStep import dev.yaseyo.onboarding.ui.profilesetup.widgets.NameStep import dev.yaseyo.onboarding.ui.profilesetup.widgets.OnboardingProgressBar @@ -128,6 +130,19 @@ private fun ProfileSetupContent( subtitle = state.currentStep?.subtitle, onHeightChanged = eventHandler::onHeightChanged, ) + 3 -> CurrentWeightStep( + weightKg = state.currentWeightKg, + title = state.currentStep?.title ?: "What's your current weight?", + subtitle = state.currentStep?.subtitle, + onWeightChanged = eventHandler::onCurrentWeightChanged, + ) + 4 -> ActivityLevelStep( + options = state.activityLevels, + selected = state.selectedActivityLevel, + title = state.currentStep?.title ?: "How active is your daily life?", + subtitle = state.currentStep?.subtitle, + onSelected = eventHandler::onActivityLevelSelected, + ) } } @@ -145,6 +160,7 @@ private fun ProfileSetupContent( enabled = when (state.currentStepIndex) { 0 -> state.name.isNotBlank() 1 -> state.sex != null + 4 -> state.selectedActivityLevel != null else -> true }, ) { @@ -189,6 +205,10 @@ private val previewHandler = object : ProfileSetupEventHandler { override fun onHeightChanged(heightCm: Int) {} + override fun onCurrentWeightChanged(weightKg: Int) {} + + override fun onActivityLevelSelected(level: dev.yaseyo.user.api.ActivityLevel) {} + override fun onContinue() {} override fun onBack() {} @@ -214,6 +234,6 @@ private fun ProfileSetupNameDarkPreview() { @Composable private fun ProfileSetupNameFilledPreview() { YaseyoPreview(darkTheme = false) { - ProfileSetupContent(state = previewState.copy(name = "Taki"), eventHandler = previewHandler) + 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 fce84bd..894778b 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,6 +1,7 @@ package dev.yaseyo.onboarding.ui.profilesetup import dev.yaseyo.onboarding.model.OnboardingStep +import dev.yaseyo.user.api.ActivityLevel import dev.yaseyo.user.api.Sex import kotlinx.datetime.LocalDate @@ -14,6 +15,16 @@ internal data class ProfileSetupUiState( val dobDay: Int = 1, val dobYear: Int = 1990, val heightCm: Int = 170, + val currentWeightKg: Int = 70, + // Step 5 — activity level + val activityLevels: List = emptyList(), + val selectedActivityLevel: ActivityLevel? = null, +) + +internal data class ActivityLevelOption( + val level: ActivityLevel, + val label: String, + val description: String, ) 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 c27c5f1..4fc423a 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 @@ -5,10 +5,14 @@ import androidx.lifecycle.viewModelScope import dev.yaseyo.navigation.AppRouter import dev.yaseyo.onboarding.model.OnboardingStep import dev.yaseyo.onboarding.network.OnboardingApi +import dev.yaseyo.user.api.ActivityLevel import dev.yaseyo.user.api.Sex +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -18,25 +22,56 @@ internal class ProfileSetupViewModel( ) : ViewModel(), ProfileSetupEventHandler { private val _uiState = MutableStateFlow(ProfileSetupUiState()) - val uiState: StateFlow = _uiState.asStateFlow() + val uiState: StateFlow = _uiState + .onStart { loadInitialData() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ProfileSetupUiState(), + ) - init { - fetchSteps() - } - - private fun fetchSteps() { + private fun loadInitialData() { viewModelScope.launch { - runCatching { onboardingApi.getSteps() } + val stepsDeferred = async { runCatching { onboardingApi.getSteps() } } + val levelsDeferred = async { runCatching { onboardingApi.getActivityLevels() } } + + stepsDeferred + .await() .onSuccess { nets -> _uiState.update { state -> state.copy( - steps = nets.map { OnboardingStep(key = it.key, title = it.title, subtitle = it.subtitle) }, + steps = nets.map { + OnboardingStep( + key = it.key, + title = it.title, + subtitle = it.subtitle, + ) + }, isLoadingSteps = false, ) } }.onFailure { _uiState.update { it.copy(isLoadingSteps = false) } } + + levelsDeferred.await().onSuccess { nets -> + _uiState.update { state -> + state.copy( + activityLevels = nets.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, + ) + }, + ) + } + } } } @@ -52,6 +87,10 @@ internal class ProfileSetupViewModel( override fun onHeightChanged(heightCm: Int) = _uiState.update { it.copy(heightCm = heightCm) } + override fun onCurrentWeightChanged(weightKg: Int) = _uiState.update { it.copy(currentWeightKg = weightKg) } + + override fun onActivityLevelSelected(level: ActivityLevel) = _uiState.update { it.copy(selectedActivityLevel = level) } + override fun onContinue() { val state = _uiState.value val nextIndex = state.currentStepIndex + 1 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..29a2f9e --- /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.ui.profilesetup.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/SexCard.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/SexCard.kt index c172ad5..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 @@ -66,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 @@ -93,7 +92,7 @@ private fun SexCard( text = label, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, - color = labelColor, + color = colors.contentPrimary, ) } } From 846b077f91717c9f04c115d7262e5cd1e3d680f5 Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 15 May 2026 20:12:20 +0900 Subject: [PATCH 05/27] Implement goal weight suggestion in onboarding flow - Add `getGoalSuggestion` to `OnboardingApi` and implementation - Define network request and response models for goal suggestions - Update `ProfileSetupUiState` to include goal suggestion and target weight fields - Implement `fetchGoalSuggestion` in `ProfileSetupViewModel` triggered during navigation - Add `GoalWeightStep` UI component to display BMI, body fat stats, and target weight picker - Integrate goal weight selection step into `ProfileSetupScreen` --- .../onboarding/network/GoalSuggestionNet.kt | 22 ++ .../network/GoalSuggestionRequestNet.kt | 11 + .../onboarding/network/OnboardingApi.kt | 2 + .../onboarding/network/OnboardingApiImpl.kt | 11 + .../profilesetup/ProfileSetupEventHandler.kt | 3 + .../ui/profilesetup/ProfileSetupScreen.kt | 12 + .../ui/profilesetup/ProfileSetupUiState.kt | 11 + .../ui/profilesetup/ProfileSetupViewModel.kt | 39 ++++ .../ui/profilesetup/widgets/GoalWeightStep.kt | 216 ++++++++++++++++++ 9 files changed, 327 insertions(+) create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/GoalSuggestionNet.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/GoalSuggestionRequestNet.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/GoalWeightStep.kt 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 8639b9c..d0e41b5 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 @@ -4,4 +4,6 @@ internal interface OnboardingApi { suspend fun getSteps(): List suspend fun getActivityLevels(): List + + suspend fun getGoalSuggestion(request: GoalSuggestionRequestNet): GoalSuggestionNet } 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 916c7d6..857eea9 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 @@ -3,6 +3,10 @@ package dev.yaseyo.onboarding.network 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 internal class OnboardingApiImpl( private val client: HttpClient, @@ -10,4 +14,11 @@ internal class OnboardingApiImpl( override suspend fun getSteps(): List = client.get("/onboarding/steps").body() override suspend fun getActivityLevels(): List = client.get("/user/activity-levels").body() + + override suspend fun getGoalSuggestion(request: GoalSuggestionRequestNet): GoalSuggestionNet = + client + .post("/onboarding/goal-suggestion") { + contentType(ContentType.Application.Json) + setBody(request) + }.body() } 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 4df8bed..19f11ee 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 @@ -25,6 +25,9 @@ internal interface ProfileSetupEventHandler { // Step 5 fun onActivityLevelSelected(level: ActivityLevel) + // Step 6 + fun onTargetWeightChanged(weightKg: 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 608653f..75d82af 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 @@ -37,6 +37,7 @@ 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.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 @@ -143,6 +144,15 @@ private fun ProfileSetupContent( subtitle = state.currentStep?.subtitle, onSelected = eventHandler::onActivityLevelSelected, ) + 5 -> 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, + ) } } @@ -209,6 +219,8 @@ private val previewHandler = object : ProfileSetupEventHandler { override fun onActivityLevelSelected(level: dev.yaseyo.user.api.ActivityLevel) {} + override fun onTargetWeightChanged(weightKg: Int) {} + override fun onContinue() {} override fun onBack() {} 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 894778b..3369756 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 @@ -19,6 +19,17 @@ internal data class ProfileSetupUiState( // Step 5 — activity level val activityLevels: List = emptyList(), val selectedActivityLevel: ActivityLevel? = null, + // Step 6 — goal weight + val goalSuggestion: GoalSuggestion? = null, + val isLoadingGoalSuggestion: Boolean = false, + val targetWeightKg: Int = 70, +) + +internal data class GoalSuggestion( + val currentBmi: Double, + val currentBodyFatPct: Double?, + val currentBodyFatCategory: String?, + val suggestedTargetKg: Int, ) internal data class ActivityLevelOption( 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 4fc423a..875da1d 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 @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.yaseyo.navigation.AppRouter import dev.yaseyo.onboarding.model.OnboardingStep +import dev.yaseyo.onboarding.network.GoalSuggestionRequestNet import dev.yaseyo.onboarding.network.OnboardingApi import dev.yaseyo.user.api.ActivityLevel import dev.yaseyo.user.api.Sex @@ -91,9 +92,14 @@ internal class ProfileSetupViewModel( override fun onActivityLevelSelected(level: ActivityLevel) = _uiState.update { it.copy(selectedActivityLevel = level) } + override fun onTargetWeightChanged(weightKg: Int) = _uiState.update { it.copy(targetWeightKg = weightKg) } + override fun onContinue() { val state = _uiState.value val nextIndex = state.currentStepIndex + 1 + if (state.currentStepIndex == 4) { + fetchGoalSuggestion(state) + } if (nextIndex < state.totalSteps) { _uiState.update { it.copy(currentStepIndex = nextIndex) } } else { @@ -101,6 +107,39 @@ internal class ProfileSetupViewModel( } } + private fun fetchGoalSuggestion(state: ProfileSetupUiState) { + val sex = state.sex ?: return + _uiState.update { it.copy(isLoadingGoalSuggestion = true) } + viewModelScope.launch { + runCatching { + onboardingApi.getGoalSuggestion( + GoalSuggestionRequestNet( + sex = sex.name.uppercase(), + height = state.heightCm.toDouble(), + dateOfBirth = state.dateOfBirth().toString(), + currentWeightKg = state.currentWeightKg.toDouble(), + ), + ) + }.onSuccess { net -> + val suggested = net.suggestedTargetKg.toInt() + _uiState.update { + it.copy( + goalSuggestion = GoalSuggestion( + currentBmi = net.current.bmi, + currentBodyFatPct = net.current.bodyFat?.estimatedPct, + currentBodyFatCategory = net.current.bodyFat?.category, + suggestedTargetKg = suggested, + ), + targetWeightKg = suggested, + isLoadingGoalSuggestion = false, + ) + } + }.onFailure { + _uiState.update { it.copy(isLoadingGoalSuggestion = false) } + } + } + } + override fun onBack() { val currentIndex = _uiState.value.currentStepIndex if (currentIndex == 0) { 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..7157e30 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/GoalWeightStep.kt @@ -0,0 +1,216 @@ +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.ui.profilesetup.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 fun Double.format1dp(): String { + val scaled = (this * 10).toLong() + return "${scaled / 10}.${kotlin.math.abs(scaled % 10)}" +} + +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 = {}, + ) + } From 5a0b0fd367db639ba0b9fd67c3983fa8e3d7582a Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 15 May 2026 20:22:49 +0900 Subject: [PATCH 06/27] Rename ModalRoute to RouteMarkers and add AppRootRoute interface --- .../dev/yaseyo/onboarding/navigation/OnboardingRoutes.kt | 3 ++- .../dev/yaseyo/navigation/{ModalRoute.kt => RouteMarkers.kt} | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) rename libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/{ModalRoute.kt => RouteMarkers.kt} (68%) 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/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 From a9fa2e975ef965c593d460d7c09152c206dfea4f Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 15 May 2026 20:30:13 +0900 Subject: [PATCH 07/27] Implement summary step in onboarding profile setup - Add `SummaryStep` UI widget and related components to display TDEE, calorie targets, and suggested macros. - Define `OnboardingSummary` domain model and its corresponding network request/response data classes. - Update `OnboardingApi` and `OnboardingApiImpl` to include the `getSummary` endpoint. - Integrate summary state and deficit adjustment logic into `ProfileSetupViewModel` and `ProfileSetupUiState`. - Add the summary step to `ProfileSetupScreen` with loading and data validation states. --- .../onboarding/network/OnboardingApi.kt | 2 + .../onboarding/network/OnboardingApiImpl.kt | 7 + .../network/OnboardingSummaryNet.kt | 28 ++ .../profilesetup/ProfileSetupEventHandler.kt | 3 + .../ui/profilesetup/ProfileSetupScreen.kt | 12 + .../ui/profilesetup/ProfileSetupUiState.kt | 13 + .../ui/profilesetup/ProfileSetupViewModel.kt | 47 ++- .../ui/profilesetup/widgets/SummaryStep.kt | 282 ++++++++++++++++++ 8 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingSummaryNet.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/SummaryStep.kt 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 d0e41b5..6444d5c 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 @@ -6,4 +6,6 @@ internal interface OnboardingApi { suspend fun getActivityLevels(): List suspend fun getGoalSuggestion(request: GoalSuggestionRequestNet): GoalSuggestionNet + + suspend fun getSummary(request: OnboardingSummaryRequestNet): OnboardingSummaryNet } 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 857eea9..a5622df 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 @@ -21,4 +21,11 @@ internal class OnboardingApiImpl( contentType(ContentType.Application.Json) setBody(request) }.body() + + override suspend fun getSummary(request: OnboardingSummaryRequestNet): OnboardingSummaryNet = + client + .post("/onboarding/summary") { + contentType(ContentType.Application.Json) + setBody(request) + }.body() } 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/ui/profilesetup/ProfileSetupEventHandler.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupEventHandler.kt index 19f11ee..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 @@ -28,6 +28,9 @@ internal interface ProfileSetupEventHandler { // 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 75d82af..16debfd 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 @@ -41,6 +41,7 @@ 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.SummaryStep import org.koin.compose.viewmodel.koinViewModel @Composable @@ -153,6 +154,14 @@ private fun ProfileSetupContent( subtitle = state.currentStep?.subtitle, onTargetWeightChanged = eventHandler::onTargetWeightChanged, ) + 6 -> SummaryStep( + summary = state.onboardingSummary, + isLoading = state.isLoadingSummary, + dailyDeficitKcal = state.dailyDeficitKcal, + title = state.currentStep?.title ?: "Here's your plan", + subtitle = state.currentStep?.subtitle, + onDeficitChanged = eventHandler::onDeficitChanged, + ) } } @@ -171,6 +180,7 @@ private fun ProfileSetupContent( 0 -> state.name.isNotBlank() 1 -> state.sex != null 4 -> state.selectedActivityLevel != null + 6 -> state.onboardingSummary != null else -> true }, ) { @@ -221,6 +231,8 @@ private val previewHandler = object : ProfileSetupEventHandler { override fun onTargetWeightChanged(weightKg: Int) {} + override fun onDeficitChanged(kcal: Int) {} + override fun onContinue() {} override fun onBack() {} 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 3369756..9a59e0d 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 @@ -23,6 +23,19 @@ internal data class ProfileSetupUiState( val goalSuggestion: GoalSuggestion? = null, val isLoadingGoalSuggestion: Boolean = false, val targetWeightKg: Int = 70, + // Step 7 — summary + val dailyDeficitKcal: Int = 500, + val onboardingSummary: OnboardingSummary? = null, + val isLoadingSummary: Boolean = false, +) + +internal data class OnboardingSummary( + val tdee: Int, + val dailyCalorieTarget: Int, + val weeklyLossKg: Double, + val proteinG: Int, + val carbsG: Int, + val fatG: Int, ) internal data class GoalSuggestion( 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 875da1d..b16b83a 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 @@ -6,6 +6,7 @@ import dev.yaseyo.navigation.AppRouter import dev.yaseyo.onboarding.model.OnboardingStep import dev.yaseyo.onboarding.network.GoalSuggestionRequestNet import dev.yaseyo.onboarding.network.OnboardingApi +import dev.yaseyo.onboarding.network.OnboardingSummaryRequestNet import dev.yaseyo.user.api.ActivityLevel import dev.yaseyo.user.api.Sex import kotlinx.coroutines.async @@ -94,14 +95,18 @@ internal class ProfileSetupViewModel( override fun onTargetWeightChanged(weightKg: Int) = _uiState.update { it.copy(targetWeightKg = weightKg) } + override fun onDeficitChanged(kcal: Int) { + _uiState.update { it.copy(dailyDeficitKcal = kcal) } + fetchSummary(_uiState.value) + } + override fun onContinue() { val state = _uiState.value val nextIndex = state.currentStepIndex + 1 - if (state.currentStepIndex == 4) { - fetchGoalSuggestion(state) - } + if (state.currentStepIndex == 4) fetchGoalSuggestion(state) if (nextIndex < state.totalSteps) { _uiState.update { it.copy(currentStepIndex = nextIndex) } + if (nextIndex == state.totalSteps - 1) fetchSummary(_uiState.value) } else { submitProfile() } @@ -149,6 +154,42 @@ internal class ProfileSetupViewModel( } } + private fun fetchSummary(state: ProfileSetupUiState) { + val sex = state.sex ?: return + val activityLevel = state.selectedActivityLevel ?: return + _uiState.update { it.copy(isLoadingSummary = true) } + viewModelScope.launch { + runCatching { + onboardingApi.getSummary( + OnboardingSummaryRequestNet( + sex = sex.name.uppercase(), + height = state.heightCm.toDouble(), + dateOfBirth = state.dateOfBirth().toString(), + currentWeightKg = state.currentWeightKg.toDouble(), + activityLevel = activityLevel.name.uppercase(), + dailyDeficitKcal = state.dailyDeficitKcal, + ), + ) + }.onSuccess { net -> + _uiState.update { + it.copy( + onboardingSummary = 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(), + ), + isLoadingSummary = false, + ) + } + }.onFailure { + _uiState.update { it.copy(isLoadingSummary = false) } + } + } + } + private fun submitProfile() { // TODO: POST /user + POST /weight — wired up on the final step } 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..88cd38f --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/SummaryStep.kt @@ -0,0 +1,282 @@ +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.ui.profilesetup.OnboardingSummary + +private val deficitOptions = listOf( + 250 to "Mild", + 500 to "Moderate", + 750 to "Aggressive", +) + +@Composable +internal fun SummaryStep( + summary: OnboardingSummary?, + isLoading: Boolean, + 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), + ) + } + } + + 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 fun Double.format1dp(): String { + val scaled = (this * 10).toLong() + return "${scaled / 10}.${kotlin.math.abs(scaled % 10)}" +} + +private val previewSummary = OnboardingSummary( + tdee = 2200, + dailyCalorieTarget = 1700, + weeklyLossKg = 0.7, + proteinG = 150, + carbsG = 170, + fatG = 55, +) + +@Preview(showBackground = true) +@Composable +private fun SummaryStepLoadingPreview() { + YaseyoPreview(darkTheme = false) { + SummaryStep( + summary = null, + isLoading = true, + dailyDeficitKcal = 500, + title = "Here's your plan", + subtitle = "Based on your profile and goal.", + onDeficitChanged = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun SummaryStepLoadedPreview() { + YaseyoPreview(darkTheme = false) { + SummaryStep( + summary = previewSummary, + isLoading = false, + dailyDeficitKcal = 500, + title = "Here's your plan", + subtitle = "Based on your profile and goal.", + onDeficitChanged = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun SummaryStepDarkPreview() = + YaseyoPreview(darkTheme = true) { + SummaryStep( + summary = previewSummary, + isLoading = false, + dailyDeficitKcal = 750, + title = "Here's your plan", + subtitle = "Based on your profile and goal.", + onDeficitChanged = {}, + ) + } From 9d9ec70ab821042333f44e4e1735698e1ac5e4c5 Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 15 May 2026 20:34:56 +0900 Subject: [PATCH 08/27] Add onboarding enum mappings and update ProfileSetupViewModel network calls --- .../yaseyo/onboarding/network/EnumMappings.kt | 16 ++++++++++++++++ .../ui/profilesetup/ProfileSetupViewModel.kt | 7 ++++--- 2 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/EnumMappings.kt diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/EnumMappings.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/EnumMappings.kt new file mode 100644 index 0000000..87e9bcd --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/EnumMappings.kt @@ -0,0 +1,16 @@ +package dev.yaseyo.onboarding.network + +import dev.yaseyo.user.api.ActivityLevel +import dev.yaseyo.user.api.Sex + +internal fun Sex.toApiValue(): String = + when (this) { + Sex.Male -> "MALE" + Sex.Female -> "FEMALE" + } + +internal fun ActivityLevel.toApiValue(): String = + when (this) { + ActivityLevel.Sedentary -> "SEDENTARY" + ActivityLevel.LightlyActive -> "LIGHTLY_ACTIVE" + } 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 b16b83a..7b65c98 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 @@ -7,6 +7,7 @@ import dev.yaseyo.onboarding.model.OnboardingStep import dev.yaseyo.onboarding.network.GoalSuggestionRequestNet import dev.yaseyo.onboarding.network.OnboardingApi import dev.yaseyo.onboarding.network.OnboardingSummaryRequestNet +import dev.yaseyo.onboarding.network.toApiValue import dev.yaseyo.user.api.ActivityLevel import dev.yaseyo.user.api.Sex import kotlinx.coroutines.async @@ -119,7 +120,7 @@ internal class ProfileSetupViewModel( runCatching { onboardingApi.getGoalSuggestion( GoalSuggestionRequestNet( - sex = sex.name.uppercase(), + sex = sex.toApiValue(), height = state.heightCm.toDouble(), dateOfBirth = state.dateOfBirth().toString(), currentWeightKg = state.currentWeightKg.toDouble(), @@ -162,11 +163,11 @@ internal class ProfileSetupViewModel( runCatching { onboardingApi.getSummary( OnboardingSummaryRequestNet( - sex = sex.name.uppercase(), + sex = sex.toApiValue(), height = state.heightCm.toDouble(), dateOfBirth = state.dateOfBirth().toString(), currentWeightKg = state.currentWeightKg.toDouble(), - activityLevel = activityLevel.name.uppercase(), + activityLevel = activityLevel.toApiValue(), dailyDeficitKcal = state.dailyDeficitKcal, ), ) From c312f562a1d213330fc88f16f64f4449e0b64660 Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 15 May 2026 21:02:23 +0900 Subject: [PATCH 09/27] Refactor onboarding data models and move mapping logic to the network layer - Relocate `OnboardingSummary`, `GoalSuggestion`, and `ActivityLevelOption` from UI state to a dedicated model package. - Update `OnboardingApi` to use domain models instead of network DTOs in its interface. - Move DTO-to-domain mapping and enum conversion logic into `OnboardingApiImpl`. - Clean up `ProfileSetupViewModel` by removing mapping logic and unused `AppRouter` dependency. - Delete `EnumMappings.kt` as conversion logic is now encapsulated within the API implementation. --- .../onboarding/model/ActivityLevelOption.kt | 9 ++ .../yaseyo/onboarding/model/GoalSuggestion.kt | 8 ++ .../onboarding/model/OnboardingSummary.kt | 10 ++ .../yaseyo/onboarding/network/EnumMappings.kt | 16 --- .../onboarding/network/OnboardingApi.kt | 28 ++++- .../onboarding/network/OnboardingApiImpl.kt | 98 +++++++++++++-- .../ui/profilesetup/ProfileSetupUiState.kt | 28 +---- .../ui/profilesetup/ProfileSetupViewModel.kt | 112 +++++------------- .../ui/profilesetup/widgets/SummaryStep.kt | 2 +- 9 files changed, 172 insertions(+), 139 deletions(-) create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/ActivityLevelOption.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/GoalSuggestion.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/OnboardingSummary.kt delete mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/EnumMappings.kt 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/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/network/EnumMappings.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/EnumMappings.kt deleted file mode 100644 index 87e9bcd..0000000 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/EnumMappings.kt +++ /dev/null @@ -1,16 +0,0 @@ -package dev.yaseyo.onboarding.network - -import dev.yaseyo.user.api.ActivityLevel -import dev.yaseyo.user.api.Sex - -internal fun Sex.toApiValue(): String = - when (this) { - Sex.Male -> "MALE" - Sex.Female -> "FEMALE" - } - -internal 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/OnboardingApi.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingApi.kt index 6444d5c..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,11 +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 getActivityLevels(): List - suspend fun getGoalSuggestion(request: GoalSuggestionRequestNet): GoalSuggestionNet + suspend fun getGoalSuggestion( + sex: Sex, + heightCm: Int, + dateOfBirth: LocalDate, + currentWeightKg: Int, + ): GoalSuggestion - suspend fun getSummary(request: OnboardingSummaryRequestNet): OnboardingSummaryNet + 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 a5622df..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,5 +1,11 @@ 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 @@ -7,25 +13,97 @@ 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 getActivityLevels(): List = client.get("/user/activity-levels").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 getGoalSuggestion(request: GoalSuggestionRequestNet): GoalSuggestionNet = + 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(request) - }.body() + 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(request: OnboardingSummaryRequestNet): OnboardingSummaryNet = - client + 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(request) - }.body() + 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/ui/profilesetup/ProfileSetupUiState.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupUiState.kt index 9a59e0d..cef3c45 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,6 +1,9 @@ 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 @@ -16,41 +19,16 @@ internal data class ProfileSetupUiState( val dobYear: Int = 1990, val heightCm: Int = 170, val currentWeightKg: Int = 70, - // Step 5 — activity level val activityLevels: List = emptyList(), val selectedActivityLevel: ActivityLevel? = null, - // Step 6 — goal weight val goalSuggestion: GoalSuggestion? = null, val isLoadingGoalSuggestion: Boolean = false, val targetWeightKg: Int = 70, - // Step 7 — summary val dailyDeficitKcal: Int = 500, val onboardingSummary: OnboardingSummary? = null, val isLoadingSummary: Boolean = false, ) -internal data class OnboardingSummary( - val tdee: Int, - val dailyCalorieTarget: Int, - val weeklyLossKg: Double, - val proteinG: Int, - val carbsG: Int, - val fatG: Int, -) - -internal data class GoalSuggestion( - val currentBmi: Double, - val currentBodyFatPct: Double?, - val currentBodyFatCategory: String?, - val suggestedTargetKg: Int, -) - -internal data class ActivityLevelOption( - val level: ActivityLevel, - val label: String, - val description: String, -) - internal fun daysInMonth( month: Int, year: Int, 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 7b65c98..68e27b3 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,12 +2,7 @@ package dev.yaseyo.onboarding.ui.profilesetup import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.yaseyo.navigation.AppRouter -import dev.yaseyo.onboarding.model.OnboardingStep -import dev.yaseyo.onboarding.network.GoalSuggestionRequestNet import dev.yaseyo.onboarding.network.OnboardingApi -import dev.yaseyo.onboarding.network.OnboardingSummaryRequestNet -import dev.yaseyo.onboarding.network.toApiValue import dev.yaseyo.user.api.ActivityLevel import dev.yaseyo.user.api.Sex import kotlinx.coroutines.async @@ -21,7 +16,6 @@ import kotlinx.coroutines.launch internal class ProfileSetupViewModel( private val onboardingApi: OnboardingApi, - private val router: AppRouter, ) : ViewModel(), ProfileSetupEventHandler { private val _uiState = MutableStateFlow(ProfileSetupUiState()) @@ -40,40 +34,14 @@ internal class ProfileSetupViewModel( stepsDeferred .await() - .onSuccess { nets -> - _uiState.update { state -> - state.copy( - steps = nets.map { - OnboardingStep( - key = it.key, - title = it.title, - subtitle = it.subtitle, - ) - }, - isLoadingSteps = false, - ) - } + .onSuccess { steps -> + _uiState.update { it.copy(steps = steps, isLoadingSteps = false) } }.onFailure { _uiState.update { it.copy(isLoadingSteps = false) } } - levelsDeferred.await().onSuccess { nets -> - _uiState.update { state -> - state.copy( - activityLevels = nets.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, - ) - }, - ) - } + levelsDeferred.await().onSuccess { levels -> + _uiState.update { it.copy(activityLevels = levels) } } } } @@ -113,30 +81,31 @@ internal class ProfileSetupViewModel( } } + override fun onBack() { + val currentIndex = _uiState.value.currentStepIndex + if (currentIndex == 0) { + // TODO: navigate back via UiAction (Chunk 4) + } else { + _uiState.update { it.copy(currentStepIndex = currentIndex - 1) } + } + } + private fun fetchGoalSuggestion(state: ProfileSetupUiState) { val sex = state.sex ?: return _uiState.update { it.copy(isLoadingGoalSuggestion = true) } viewModelScope.launch { runCatching { onboardingApi.getGoalSuggestion( - GoalSuggestionRequestNet( - sex = sex.toApiValue(), - height = state.heightCm.toDouble(), - dateOfBirth = state.dateOfBirth().toString(), - currentWeightKg = state.currentWeightKg.toDouble(), - ), + sex = sex, + heightCm = state.heightCm, + dateOfBirth = state.dateOfBirth(), + currentWeightKg = state.currentWeightKg, ) - }.onSuccess { net -> - val suggested = net.suggestedTargetKg.toInt() + }.onSuccess { suggestion -> _uiState.update { it.copy( - goalSuggestion = GoalSuggestion( - currentBmi = net.current.bmi, - currentBodyFatPct = net.current.bodyFat?.estimatedPct, - currentBodyFatCategory = net.current.bodyFat?.category, - suggestedTargetKg = suggested, - ), - targetWeightKg = suggested, + goalSuggestion = suggestion, + targetWeightKg = suggestion.suggestedTargetKg, isLoadingGoalSuggestion = false, ) } @@ -146,15 +115,6 @@ internal class ProfileSetupViewModel( } } - override fun onBack() { - val currentIndex = _uiState.value.currentStepIndex - if (currentIndex == 0) { - router.goBack() - } else { - _uiState.update { it.copy(currentStepIndex = currentIndex - 1) } - } - } - private fun fetchSummary(state: ProfileSetupUiState) { val sex = state.sex ?: return val activityLevel = state.selectedActivityLevel ?: return @@ -162,29 +122,15 @@ internal class ProfileSetupViewModel( viewModelScope.launch { runCatching { onboardingApi.getSummary( - OnboardingSummaryRequestNet( - sex = sex.toApiValue(), - height = state.heightCm.toDouble(), - dateOfBirth = state.dateOfBirth().toString(), - currentWeightKg = state.currentWeightKg.toDouble(), - activityLevel = activityLevel.toApiValue(), - dailyDeficitKcal = state.dailyDeficitKcal, - ), + sex = sex, + heightCm = state.heightCm, + dateOfBirth = state.dateOfBirth(), + currentWeightKg = state.currentWeightKg, + activityLevel = activityLevel, + dailyDeficitKcal = state.dailyDeficitKcal, ) - }.onSuccess { net -> - _uiState.update { - it.copy( - onboardingSummary = 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(), - ), - isLoadingSummary = false, - ) - } + }.onSuccess { summary -> + _uiState.update { it.copy(onboardingSummary = summary, isLoadingSummary = false) } }.onFailure { _uiState.update { it.copy(isLoadingSummary = false) } } @@ -192,6 +138,6 @@ internal class ProfileSetupViewModel( } private fun submitProfile() { - // TODO: POST /user + POST /weight — wired up on the final step + // TODO: POST /user — wired up in Chunk 4 } } 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 index 88cd38f..9e06141 100644 --- 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 @@ -26,7 +26,7 @@ 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.ui.profilesetup.OnboardingSummary +import dev.yaseyo.onboarding.model.OnboardingSummary private val deficitOptions = listOf( 250 to "Mild", From 2a562290c8fbe4d9b5a6279f6abe087ac5e21fb2 Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 15 May 2026 21:59:37 +0900 Subject: [PATCH 10/27] Add libs:datastore module and integrate with Koin di module --- .../onboarding/model/OnboardingDraft.kt | 15 +++++++++++++ .../profilesetup/widgets/ActivityLevelStep.kt | 2 +- .../ui/profilesetup/widgets/GoalWeightStep.kt | 2 +- gradle/libs.versions.toml | 3 +++ libs/datastore/build.gradle.kts | 22 +++++++++++++++++++ .../datastore/DataStoreFactory.android.kt | 12 ++++++++++ .../dev/yaseyo/datastore/DataStoreFactory.kt | 17 ++++++++++++++ .../dev/yaseyo/datastore/DataStoreModule.kt | 9 ++++++++ .../yaseyo/datastore/DataStoreFactory.ios.kt | 22 +++++++++++++++++++ libs/di/build.gradle.kts | 7 ++++-- .../kotlin/dev/yaseyo/di/KoinInitializer.kt | 7 +++++- .../kotlin/dev/yaseyo/di/AppModules.kt | 2 ++ settings.gradle.kts | 1 + 13 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/OnboardingDraft.kt create mode 100644 libs/datastore/build.gradle.kts create mode 100644 libs/datastore/src/androidMain/kotlin/dev/yaseyo/datastore/DataStoreFactory.android.kt create mode 100644 libs/datastore/src/commonMain/kotlin/dev/yaseyo/datastore/DataStoreFactory.kt create mode 100644 libs/datastore/src/commonMain/kotlin/dev/yaseyo/datastore/DataStoreModule.kt create mode 100644 libs/datastore/src/iosMain/kotlin/dev/yaseyo/datastore/DataStoreFactory.ios.kt 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..a0f10e6 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/OnboardingDraft.kt @@ -0,0 +1,15 @@ +package dev.yaseyo.onboarding.model + +internal data class OnboardingDraft( + val currentStepIndex: Int = 0, + val name: String = "", + val sex: String? = null, + val dobDay: Int = 1, + val dobMonth: Int = 1, + val dobYear: Int = 1990, + val heightCm: Int = 170, + val currentWeightKg: Int = 70, + val activityLevel: String? = null, + val targetWeightKg: Int = 70, + val dailyDeficitKcal: Int = 500, +) 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 index 29a2f9e..b62eaeb 100644 --- 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 @@ -21,7 +21,7 @@ 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.ui.profilesetup.ActivityLevelOption +import dev.yaseyo.onboarding.model.ActivityLevelOption import dev.yaseyo.user.api.ActivityLevel @Composable 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 index 7157e30..f85e46d 100644 --- 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 @@ -22,7 +22,7 @@ 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.ui.profilesetup.GoalSuggestion +import dev.yaseyo.onboarding.model.GoalSuggestion @Composable internal fun GoalWeightStep( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 267aaf9..e02d296 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" } diff --git a/libs/datastore/build.gradle.kts b/libs/datastore/build.gradle.kts new file mode 100644 index 0000000..468e405 --- /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) + implementation(libs.androidx.datastore) + implementation(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..8326c05 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,12 @@ kotlin { // Libs implementation(projects.libs.coroutines) - api(projects.libs.auth.api) implementation(projects.libs.auth) implementation(projects.libs.network) + implementation(projects.libs.datastore) + + // Exported Libs + api(projects.libs.auth.api) 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/commonMain/kotlin/dev/yaseyo/di/AppModules.kt b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt index b9dd6e6..209c404 100644 --- a/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt +++ b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt @@ -2,6 +2,7 @@ package dev.yaseyo.di import dev.yaseyo.auth.di.authModule 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 @@ -16,4 +17,5 @@ internal val appModules = listOf( module { single { networkConfig } }, networkModule, onboardingModule, + dataStoreModule, ) diff --git a/settings.gradle.kts b/settings.gradle.kts index aaca923..1184043 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -44,4 +44,5 @@ include(":libs:navigation") include(":libs:design") include(":libs:network") include(":libs:di") +include(":libs:datastore") include(":feature:onboarding") From 538aee3ab8ec3495f36f69f880fe49de001a006c Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 15 May 2026 22:26:08 +0900 Subject: [PATCH 11/27] Implement local data source and repository for onboarding draft persistence - Add `OnboardingLocalDataSource` and `OnboardingRepository` to handle data persistence via DataStore - Update `OnboardingDraft` and `ProfileSetupUiState` to use `Sex` and `ActivityLevel` type-safe enums - Refactor `ProfileSetupViewModel` to reactively combine draft state with UI state and persist changes - Configure Koin dependencies for new data source and repository modules - Expose DataStore dependencies in `libs:datastore` module --- feature/onboarding/build.gradle.kts | 1 + .../datasource/OnboardingLocalDataSource.kt | 12 ++ .../OnboardingLocalDataSourceImpl.kt | 92 ++++++++++++ .../yaseyo/onboarding/di/OnboardingModule.kt | 7 + .../onboarding/model/OnboardingDraft.kt | 7 +- .../repository/OnboardingRepository.kt | 39 +++++ .../repository/OnboardingRepositoryImpl.kt | 45 ++++++ .../ui/profilesetup/ProfileSetupViewModel.kt | 137 +++++++++++------- libs/datastore/build.gradle.kts | 4 +- 9 files changed, 284 insertions(+), 60 deletions(-) create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/datasource/OnboardingLocalDataSource.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/datasource/OnboardingLocalDataSourceImpl.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/repository/OnboardingRepository.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/repository/OnboardingRepositoryImpl.kt diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index 08d597e..740afd3 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -33,6 +33,7 @@ kotlin { implementation(projects.libs.user) api(projects.libs.navigation) implementation(projects.libs.design) + implementation(projects.libs.datastore) implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.compose.runtime) 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/OnboardingDraft.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/OnboardingDraft.kt index a0f10e6..c3d62d5 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/OnboardingDraft.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/OnboardingDraft.kt @@ -1,15 +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: String? = null, + 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: String? = null, + val activityLevel: ActivityLevel? = null, val targetWeightKg: Int = 70, val dailyDeficitKcal: Int = 500, ) 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/profilesetup/ProfileSetupViewModel.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupViewModel.kt index 68e27b3..7112e9b 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,25 +2,48 @@ package dev.yaseyo.onboarding.ui.profilesetup import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.yaseyo.onboarding.network.OnboardingApi +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 kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.onStart 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, ) : ViewModel(), ProfileSetupEventHandler { + private val draftState: StateFlow = repo.draft + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = OnboardingDraft(), + ) + private val _uiState = MutableStateFlow(ProfileSetupUiState()) - val uiState: StateFlow = _uiState - .onStart { loadInitialData() } + + 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), @@ -29,115 +52,117 @@ internal class ProfileSetupViewModel( private fun loadInitialData() { viewModelScope.launch { - val stepsDeferred = async { runCatching { onboardingApi.getSteps() } } - val levelsDeferred = async { runCatching { onboardingApi.getActivityLevels() } } + val stepsDeferred = async { repo.getSteps() } + val levelsDeferred = async { repo.getActivityLevels() } 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) } - } + _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) = _uiState.update { it.copy(sex = sex) } + override fun onSexChanged(sex: Sex) = save { it.copy(sex = sex) } - override fun onDobMonthChanged(month: Int) = _uiState.update { it.copy(dobMonth = month) } + override fun onDobMonthChanged(month: Int) = save { it.copy(dobMonth = month) } - override fun onDobDayChanged(day: Int) = _uiState.update { it.copy(dobDay = day) } + override fun onDobDayChanged(day: Int) = save { it.copy(dobDay = day) } - override fun onDobYearChanged(year: Int) = _uiState.update { it.copy(dobYear = year) } + override fun onDobYearChanged(year: Int) = save { it.copy(dobYear = year) } - override fun onHeightChanged(heightCm: Int) = _uiState.update { it.copy(heightCm = heightCm) } + override fun onHeightChanged(heightCm: Int) = save { it.copy(heightCm = heightCm) } - override fun onCurrentWeightChanged(weightKg: Int) = _uiState.update { it.copy(currentWeightKg = weightKg) } + override fun onCurrentWeightChanged(weightKg: Int) = save { it.copy(currentWeightKg = weightKg) } - override fun onActivityLevelSelected(level: ActivityLevel) = _uiState.update { it.copy(selectedActivityLevel = level) } + override fun onActivityLevelSelected(level: ActivityLevel) = save { it.copy(activityLevel = level) } - override fun onTargetWeightChanged(weightKg: Int) = _uiState.update { it.copy(targetWeightKg = weightKg) } + override fun onTargetWeightChanged(weightKg: Int) = save { it.copy(targetWeightKg = weightKg) } override fun onDeficitChanged(kcal: Int) { - _uiState.update { it.copy(dailyDeficitKcal = kcal) } - fetchSummary(_uiState.value) + 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.currentStepIndex == 4) fetchGoalSuggestion(state) + if (state.currentStepIndex == 4) fetchGoalSuggestion() if (nextIndex < state.totalSteps) { - _uiState.update { it.copy(currentStepIndex = nextIndex) } - if (nextIndex == state.totalSteps - 1) fetchSummary(_uiState.value) + save { it.copy(currentStepIndex = nextIndex) } + if (nextIndex == state.totalSteps - 1) fetchSummary() } else { submitProfile() } } override fun onBack() { - val currentIndex = _uiState.value.currentStepIndex + val currentIndex = draftState.value.currentStepIndex if (currentIndex == 0) { // TODO: navigate back via UiAction (Chunk 4) } else { - _uiState.update { it.copy(currentStepIndex = currentIndex - 1) } + save { it.copy(currentStepIndex = currentIndex - 1) } } } - private fun fetchGoalSuggestion(state: ProfileSetupUiState) { + private fun fetchGoalSuggestion() { + val state = uiState.value val sex = state.sex ?: return _uiState.update { it.copy(isLoadingGoalSuggestion = true) } viewModelScope.launch { - runCatching { - onboardingApi.getGoalSuggestion( + repo + .getGoalSuggestion( sex = sex, heightCm = state.heightCm, dateOfBirth = state.dateOfBirth(), currentWeightKg = state.currentWeightKg, - ) - }.onSuccess { suggestion -> - _uiState.update { - it.copy( - goalSuggestion = suggestion, - targetWeightKg = suggestion.suggestedTargetKg, - isLoadingGoalSuggestion = false, - ) + ).onSuccess { suggestion -> + _uiState.update { it.copy(goalSuggestion = suggestion, isLoadingGoalSuggestion = false) } + save { it.copy(targetWeightKg = suggestion.suggestedTargetKg) } + }.onFailure { + _uiState.update { it.copy(isLoadingGoalSuggestion = false) } } - }.onFailure { - _uiState.update { it.copy(isLoadingGoalSuggestion = false) } - } } } - private fun fetchSummary(state: ProfileSetupUiState) { + 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 { - runCatching { - onboardingApi.getSummary( + repo + .getSummary( sex = sex, heightCm = state.heightCm, dateOfBirth = state.dateOfBirth(), currentWeightKg = state.currentWeightKg, activityLevel = activityLevel, - dailyDeficitKcal = state.dailyDeficitKcal, - ) - }.onSuccess { summary -> - _uiState.update { it.copy(onboardingSummary = summary, isLoadingSummary = false) } - }.onFailure { - _uiState.update { it.copy(isLoadingSummary = false) } - } + 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 { repo.saveDraft(transform(draftState.value)) } + } + private fun submitProfile() { - // TODO: POST /user — wired up in Chunk 4 + // TODO: POST /user — Chunk 4 } } diff --git a/libs/datastore/build.gradle.kts b/libs/datastore/build.gradle.kts index 468e405..659e929 100644 --- a/libs/datastore/build.gradle.kts +++ b/libs/datastore/build.gradle.kts @@ -13,8 +13,8 @@ kotlin { commonMain { dependencies { implementation(libs.kotlinx.serialization.json) - implementation(libs.androidx.datastore) - implementation(libs.androidx.datastore.preferences) + api(libs.androidx.datastore) + api(libs.androidx.datastore.preferences) implementation(libs.koin.core) } } From 179abefbeebeb5b3bef58e6028fe2202b203833e Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 15 May 2026 22:46:38 +0900 Subject: [PATCH 12/27] Add coroutines-testing module and implement Onboarding tests - Create `TestDispatcherProvider` and a new `:libs:coroutines:testing` module. - Update `ProfileSetupViewModel` to use `DispatcherProvider` for better testability. - Add unit tests for `OnboardingRepositoryImpl` and `ProfileSetupViewModel` using Turbine and coroutines test utilities. - Implement fakes for `OnboardingApi`, `OnboardingLocalDataSource`, and `OnboardingRepository`. - Update `feature:onboarding` dependencies to include coroutines and testing utilities. --- feature/onboarding/build.gradle.kts | 3 + .../ui/profilesetup/ProfileSetupViewModel.kt | 10 +- .../onboarding/fake/FakeOnboardingApi.kt | 65 ++++ .../fake/FakeOnboardingLocalDataSource.kt | 21 ++ .../fake/FakeOnboardingRepository.kt | 55 ++++ .../OnboardingRepositoryImplTest.kt | 147 +++++++++ .../profilesetup/ProfileSetupViewModelTest.kt | 288 ++++++++++++++++++ gradle/libs.versions.toml | 2 + libs/coroutines/testing/build.gradle.kts | 18 ++ .../testing/TestDispatcherProvider.kt | 14 + settings.gradle.kts | 1 + 11 files changed, 620 insertions(+), 4 deletions(-) create mode 100644 feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeOnboardingApi.kt create mode 100644 feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeOnboardingLocalDataSource.kt create mode 100644 feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeOnboardingRepository.kt create mode 100644 feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/repository/OnboardingRepositoryImplTest.kt create mode 100644 feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupViewModelTest.kt create mode 100644 libs/coroutines/testing/build.gradle.kts create mode 100644 libs/coroutines/testing/src/commonMain/kotlin/dev/yaseyo/coroutines/testing/TestDispatcherProvider.kt diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index 740afd3..9f3c69f 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -31,6 +31,7 @@ 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) @@ -48,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/ui/profilesetup/ProfileSetupViewModel.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupViewModel.kt index 7112e9b..0c9c2e7 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,6 +2,7 @@ package dev.yaseyo.onboarding.ui.profilesetup import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dev.yaseyo.coroutines.DispatcherProvider import dev.yaseyo.onboarding.model.OnboardingDraft import dev.yaseyo.onboarding.repository.OnboardingRepository import dev.yaseyo.user.api.ActivityLevel @@ -18,6 +19,7 @@ import kotlinx.coroutines.launch internal class ProfileSetupViewModel( private val repo: OnboardingRepository, + private val dispatchers: DispatcherProvider, ) : ViewModel(), ProfileSetupEventHandler { private val draftState: StateFlow = repo.draft @@ -51,7 +53,7 @@ internal class ProfileSetupViewModel( ) private fun loadInitialData() { - viewModelScope.launch { + viewModelScope.launch(dispatchers.io) { val stepsDeferred = async { repo.getSteps() } val levelsDeferred = async { repo.getActivityLevels() } @@ -120,7 +122,7 @@ internal class ProfileSetupViewModel( val state = uiState.value val sex = state.sex ?: return _uiState.update { it.copy(isLoadingGoalSuggestion = true) } - viewModelScope.launch { + viewModelScope.launch(dispatchers.io) { repo .getGoalSuggestion( sex = sex, @@ -141,7 +143,7 @@ internal class ProfileSetupViewModel( val sex = state.sex ?: return val activityLevel = state.selectedActivityLevel ?: return _uiState.update { it.copy(isLoadingSummary = true) } - viewModelScope.launch { + viewModelScope.launch(dispatchers.io) { repo .getSummary( sex = sex, @@ -159,7 +161,7 @@ internal class ProfileSetupViewModel( } private fun save(transform: (OnboardingDraft) -> OnboardingDraft) { - viewModelScope.launch { repo.saveDraft(transform(draftState.value)) } + viewModelScope.launch(dispatchers.io) { repo.saveDraft(transform(draftState.value)) } } private fun submitProfile() { 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..ca3540a --- /dev/null +++ b/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeOnboardingApi.kt @@ -0,0 +1,65 @@ +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 = "about_you", title = "About you", 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/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..959df91 --- /dev/null +++ b/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupViewModelTest.kt @@ -0,0 +1,288 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package dev.yaseyo.onboarding.ui.profilesetup + +import app.cash.turbine.test +import dev.yaseyo.coroutines.testing.TestDispatcherProvider +import dev.yaseyo.onboarding.fake.FakeOnboardingApi +import dev.yaseyo.onboarding.fake.FakeOnboardingRepository +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()) = + ProfileSetupViewModel(repo = repo, dispatchers = dispatchers) + + // --- 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 = 4, + 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 = 4, 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() + expectMostRecentItem() + vm.onDeficitChanged(500) + advanceUntilIdle() + assertNull(expectMostRecentItem().onboardingSummary) + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e02d296..0c98074 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -60,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/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/settings.gradle.kts b/settings.gradle.kts index 1184043..25fe6cc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -40,6 +40,7 @@ include(":libs:user:impl") include(":libs:auth:api") include(":libs:auth:impl") include(":libs:coroutines") +include(":libs:coroutines:testing") include(":libs:navigation") include(":libs:design") include(":libs:network") From 43d33e215345ae182313618c033d85bf13322bf9 Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 15 May 2026 23:13:50 +0900 Subject: [PATCH 13/27] Implement clear backstack navigation and update onboarding flow - Add `ClearBackStackAndGoTo` event and `navigateAndClearBackStack` to `AppRouter`. - Implement `clearAndGoTo` and `FinishHandler` in Android's `Navigator`. - Update `AppCoordinator` in iOS to handle backstack clearing and modal dismissal. - Integrate `AppRouter` into `ProfileSetupViewModel` and use it to navigate to `Home` after clearing the draft. - Update `AuthViewModel` to clear the backstack when navigating to profile setup. - Add `profileSetupScreenViewController` for iOS and update view controller factory logic. - Apply UI tweaks to `SignInSheetScreen` and `SignUpSheetScreen` by adding clips to text fields. --- .../src/androidMain/kotlin/dev/yaseyo/App.kt | 1 + .../kotlin/dev/yaseyo/MainActivity.kt | 6 +++++- .../yaseyo/onboarding/ui/auth/AuthViewModel.kt | 4 ++-- .../onboarding/ui/auth/SignInSheetScreen.kt | 8 ++++++-- .../onboarding/ui/auth/SignUpSheetScreen.kt | 8 ++++++-- .../ui/profilesetup/ProfileSetupViewModel.kt | 8 +++++++- .../profilesetup/ProfileSetupViewModelTest.kt | 3 ++- .../yaseyo/onboarding/AuthViewControllers.kt | 3 +++ iosApp/iosApp/AppCoordinator.swift | 18 +++++++++++++----- .../dev/yaseyo/navigation/FinishHandler.kt | 5 +++++ .../kotlin/dev/yaseyo/navigation/Navigator.kt | 15 +++++++++++++-- .../kotlin/dev/yaseyo/navigation/AppRouter.kt | 4 ++++ .../dev/yaseyo/navigation/NavigationEvent.kt | 4 ++++ 13 files changed, 71 insertions(+), 16 deletions(-) create mode 100644 libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/FinishHandler.kt 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/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..59dc06b 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 @@ -35,6 +35,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 @@ -157,7 +158,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 +200,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..d8531ef 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 @@ -32,6 +32,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 @@ -143,7 +144,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 +186,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/ProfileSetupViewModel.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupViewModel.kt index 0c9c2e7..e6851d0 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 @@ -3,6 +3,8 @@ 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.navigation.Home import dev.yaseyo.onboarding.model.OnboardingDraft import dev.yaseyo.onboarding.repository.OnboardingRepository import dev.yaseyo.user.api.ActivityLevel @@ -20,6 +22,7 @@ import kotlinx.coroutines.launch internal class ProfileSetupViewModel( private val repo: OnboardingRepository, private val dispatchers: DispatcherProvider, + private val router: AppRouter, ) : ViewModel(), ProfileSetupEventHandler { private val draftState: StateFlow = repo.draft @@ -165,6 +168,9 @@ internal class ProfileSetupViewModel( } private fun submitProfile() { - // TODO: POST /user — Chunk 4 + viewModelScope.launch(dispatchers.io) { + repo.clearDraft() + router.navigateAndClearBackStack(Home) + } } } 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 index 959df91..f4f7756 100644 --- 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 @@ -4,6 +4,7 @@ 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.model.OnboardingDraft @@ -40,7 +41,7 @@ class ProfileSetupViewModelTest { } private fun viewModel(repo: FakeOnboardingRepository = FakeOnboardingRepository()) = - ProfileSetupViewModel(repo = repo, dispatchers = dispatchers) + ProfileSetupViewModel(repo = repo, dispatchers = dispatchers, router = AppRouter()) // --- draft restoration --- 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/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/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 } From 3c7dffe395796fa52cc19fe726e2fa9a8142f529 Mon Sep 17 00:00:00 2001 From: Taki Date: Sat, 16 May 2026 00:11:48 +0900 Subject: [PATCH 14/27] Refine onboarding UI and navigation bottom sheet styles - Remove redundant `YaseyoTheme` and window insets from `BottomSheetSceneStrategy`. - Update `OnboardingProgressBar` to a segmented step indicator and update preview step counts. - Enhance `SignUpSheetScreen` and `SignInSheetScreen` with consistent button heights. - Improve `NameStep` by migrating to `rememberTextFieldState` and `InputTransformation`. - Add conditional back button visibility in `ProfileSetupScreen` based on the current step. - Apply clipping to `TextField` in `AuthLandingScreen`. --- .../onboarding/ui/auth/AuthLandingScreen.kt | 4 +- .../onboarding/ui/auth/SignInSheetScreen.kt | 5 +- .../onboarding/ui/auth/SignUpSheetScreen.kt | 4 +- .../ui/profilesetup/ProfileSetupScreen.kt | 16 +++-- .../ui/profilesetup/widgets/NameStep.kt | 14 ++++- .../widgets/OnboardingProgressBar.kt | 63 ++++++++----------- .../navigation/BottomSheetSceneStrategy.kt | 27 +++----- 7 files changed, 65 insertions(+), 68 deletions(-) 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/SignInSheetScreen.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheetScreen.kt index 59dc06b..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 @@ -113,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, 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 d8531ef..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 @@ -99,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, 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 16debfd..1715b23 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 @@ -79,12 +79,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", 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 6c35931..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 @@ -29,6 +33,7 @@ internal fun NameStep( modifier: Modifier = Modifier, ) { val colors = YaseyoTheme.colors + val localNameState = rememberTextFieldState(initialText = name) Column(modifier = modifier) { Text( @@ -51,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", @@ -72,7 +80,7 @@ internal fun NameStep( unfocusedTextColor = colors.contentPrimary, focusedTextColor = colors.contentPrimary, ), - singleLine = true, + lineLimits = TextFieldLineLimits.SingleLine, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), ) } 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 bd2bef1..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,17 @@ 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 @@ -25,36 +22,26 @@ 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, - ) } } @@ -62,7 +49,7 @@ internal fun OnboardingProgressBar( @Composable private fun OnboardingProgressBarStep1Preview() { YaseyoPreview(darkTheme = false) { - OnboardingProgressBar(currentStep = 1, totalSteps = 6) + OnboardingProgressBar(currentStep = 1, totalSteps = 7) } } @@ -70,7 +57,7 @@ private fun OnboardingProgressBarStep1Preview() { @Composable private fun OnboardingProgressBarStep4Preview() { YaseyoPreview(darkTheme = false) { - OnboardingProgressBar(currentStep = 4, totalSteps = 6) + OnboardingProgressBar(currentStep = 4, totalSteps = 7) } } @@ -78,6 +65,6 @@ private fun OnboardingProgressBarStep4Preview() { @Composable private fun OnboardingProgressBarDarkPreview() { YaseyoPreview(darkTheme = true) { - OnboardingProgressBar(currentStep = 5, totalSteps = 6) + OnboardingProgressBar(currentStep = 5, totalSteps = 7) } } 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() } } } From 8bf3ff538896c71677488e907edd7d1eb8f66974 Mon Sep 17 00:00:00 2001 From: Taki Date: Sun, 17 May 2026 14:15:01 +0900 Subject: [PATCH 15/27] Implement user creation and profile submission in ProfileSetupViewModel --- .../ui/profilesetup/ProfileSetupScreen.kt | 22 +++++++---- .../ui/profilesetup/ProfileSetupUiState.kt | 1 + .../ui/profilesetup/ProfileSetupViewModel.kt | 38 +++++++++++++++++-- 3 files changed, 51 insertions(+), 10 deletions(-) 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 1715b23..4d146da 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,6 +32,7 @@ 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 @@ -48,6 +50,7 @@ import org.koin.compose.viewmodel.koinViewModel internal fun ProfileSetupScreen(viewModel: ProfileSetupViewModel = koinViewModel()) { val state by viewModel.uiState.collectAsStateWithLifecycle() val navState = rememberNavigationEventState(NavigationEventInfo.None) + val snackBarHostState = LocalSnackBarHostState.current NavigationBackHandler( state = navState, @@ -57,6 +60,10 @@ internal fun ProfileSetupScreen(viewModel: ProfileSetupViewModel = koinViewModel } ProfileSetupContent(state = state, eventHandler = viewModel) + + LaunchedEffect(viewModel) { + viewModel.action.collect { snackBarHostState.showSnackbar(it) } + } } @Composable @@ -180,13 +187,14 @@ private fun ProfileSetupContent( containerColor = colors.accentDefault, disabledContainerColor = colors.accentSubtle, ), - enabled = when (state.currentStepIndex) { - 0 -> state.name.isNotBlank() - 1 -> state.sex != null - 4 -> state.selectedActivityLevel != null - 6 -> state.onboardingSummary != null - else -> true - }, + enabled = !state.isSubmitting && + when (state.currentStepIndex) { + 0 -> state.name.isNotBlank() + 1 -> state.sex != null + 4 -> state.selectedActivityLevel != null + 6 -> state.onboardingSummary != null + else -> true + }, ) { Text( text = "CONTINUE →", 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 cef3c45..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 @@ -11,6 +11,7 @@ import kotlinx.datetime.LocalDate internal data class ProfileSetupUiState( val steps: List = emptyList(), val isLoadingSteps: Boolean = true, + val isSubmitting: Boolean = false, val currentStepIndex: Int = 0, val name: String = "", val sex: Sex? = null, 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 e6851d0..0355804 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 @@ -9,22 +9,28 @@ 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.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 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, @@ -115,7 +121,7 @@ internal class ProfileSetupViewModel( override fun onBack() { val currentIndex = draftState.value.currentStepIndex if (currentIndex == 0) { - // TODO: navigate back via UiAction (Chunk 4) + router.goBack() } else { save { it.copy(currentStepIndex = currentIndex - 1) } } @@ -168,9 +174,35 @@ internal class ProfileSetupViewModel( } private fun submitProfile() { + 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) { - repo.clearDraft() - router.navigateAndClearBackStack(Home) + 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.") + }, + ) } } } From 2245185b812627236814726f4c98d17c81e89cf6 Mon Sep 17 00:00:00 2001 From: Taki Date: Sun, 17 May 2026 14:20:19 +0900 Subject: [PATCH 16/27] Extract `format1dp` extension to `FormatUtils.kt` and optimize `WheelPicker` state management --- .../onboarding/ui/profilesetup/widgets/FormatUtils.kt | 6 ++++++ .../onboarding/ui/profilesetup/widgets/GoalWeightStep.kt | 5 ----- .../onboarding/ui/profilesetup/widgets/SummaryStep.kt | 5 ----- .../onboarding/ui/profilesetup/widgets/WheelPicker.kt | 8 +++++++- 4 files changed, 13 insertions(+), 11 deletions(-) create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/FormatUtils.kt 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 index f85e46d..e474a82 100644 --- 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 @@ -156,11 +156,6 @@ private fun StatRow( } } -private fun Double.format1dp(): String { - val scaled = (this * 10).toLong() - return "${scaled / 10}.${kotlin.math.abs(scaled % 10)}" -} - private val previewSuggestion = GoalSuggestion( currentBmi = 27.4, currentBodyFatPct = 22.1, 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 index 9e06141..d1cd197 100644 --- 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 @@ -223,11 +223,6 @@ private fun MacroColumn( } } -private fun Double.format1dp(): String { - val scaled = (this * 10).toLong() - return "${scaled / 10}.${kotlin.math.abs(scaled % 10)}" -} - private val previewSummary = OnboardingSummary( tdee = 2200, dailyCalorieTarget = 1700, 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..bbd1060 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 @@ -12,6 +12,8 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -68,7 +70,11 @@ internal fun WheelPicker( horizontalAlignment = Alignment.CenterHorizontally, ) { itemsIndexed(paddedItems) { index, item -> - val isSelected = index == listState.firstVisibleItemIndex + 1 + val isSelected by remember { + derivedStateOf { + index == listState.firstVisibleItemIndex + 1 + } + } Box( modifier = Modifier .fillMaxWidth() From 1758bb3a858d3aa50b8932567b9076ad0ed78e1e Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 18 May 2026 14:05:23 +0900 Subject: [PATCH 17/27] Refactor WheelPicker and update WheelDatePicker selection UI --- .../profilesetup/widgets/WheelDatePicker.kt | 53 ++++++++++++------- .../ui/profilesetup/widgets/WheelPicker.kt | 33 ++---------- 2 files changed, 39 insertions(+), 47 deletions(-) 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 e739cb2..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 @@ -49,30 +53,43 @@ 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), + ) + } } } 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 bbd1060..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,21 +4,16 @@ 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 -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue 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 @@ -26,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. @@ -69,12 +64,7 @@ internal fun WheelPicker( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - itemsIndexed(paddedItems) { index, item -> - val isSelected by remember { - derivedStateOf { - index == listState.firstVisibleItemIndex + 1 - } - } + itemsIndexed(paddedItems) { _, item -> Box( modifier = Modifier .fillMaxWidth() @@ -83,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, - ) } } From 67877ee9e65f0f3a6cbdbb3dbc22230fafe78337 Mon Sep 17 00:00:00 2001 From: Taki Date: Sun, 19 Jul 2026 18:31:07 +0900 Subject: [PATCH 18/27] Add feature:home module and update android target in libs:auth --- .gitignore | 3 +- feature/home/build.gradle.kts | 74 +++++++++++++++++++++++++++++++++++ libs/auth/build.gradle.kts | 2 +- settings.gradle.kts | 1 + 4 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 feature/home/build.gradle.kts 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/feature/home/build.gradle.kts b/feature/home/build.gradle.kts new file mode 100644 index 0000000..e064422 --- /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 { + } + + withDeviceTestBuilder { + sourceSetTreeName = "test" + }.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/libs/auth/build.gradle.kts b/libs/auth/build.gradle.kts index 6fc982c..1490f98 100644 --- a/libs/auth/build.gradle.kts +++ b/libs/auth/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } kotlin { - androidLibrary { + android { namespace = "dev.yaseyo.auth" withHostTestBuilder { diff --git a/settings.gradle.kts b/settings.gradle.kts index 25fe6cc..bd4595b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -47,3 +47,4 @@ include(":libs:network") include(":libs:di") include(":libs:datastore") include(":feature:onboarding") +include(":feature:home") From 45c592775761980ed5502ef442b62a1095c2757a Mon Sep 17 00:00:00 2001 From: Taki Date: Sun, 19 Jul 2026 18:42:15 +0900 Subject: [PATCH 19/27] Configure cleartext traffic for Android debug and update local baseUrl port --- composeApp/build.gradle.kts | 6 ++++++ composeApp/src/androidDebug/AndroidManifest.xml | 9 +++++++++ .../src/androidDebug/res/xml/network_security_config.xml | 6 ++++++ .../dev/yaseyo/di/NetworkConfigProvider.android.kt | 2 +- 4 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/androidDebug/AndroidManifest.xml create mode 100644 composeApp/src/androidDebug/res/xml/network_security_config.xml 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/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") From ad939799fa5f56b43ff3f2a265315a67e576420c Mon Sep 17 00:00:00 2001 From: Taki Date: Sun, 19 Jul 2026 18:48:07 +0900 Subject: [PATCH 20/27] formatting --- .../onboarding/ui/profilesetup/ProfileSetupViewModel.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 0355804..cf15f14 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 @@ -75,7 +75,9 @@ internal class ProfileSetupViewModel( isLoadingSteps = false, ) } - }.onFailure { _uiState.update { it.copy(isLoadingSteps = false) } } + }.onFailure { + _uiState.update { it.copy(isLoadingSteps = false) } + } levelsDeferred .await() From 50e2bd0b4028581fdfbeed4f39eb0fe44a1e59d8 Mon Sep 17 00:00:00 2001 From: Taki Date: Sun, 19 Jul 2026 18:54:42 +0900 Subject: [PATCH 21/27] Refactor onboarding steps by splitting `AboutYouStep` into separate `SexStep` and `DateOfBirthStep` modules. Update `SummaryStep` to extract deficit selection into a standalone `DeficitStep` widget. Refactor `ProfileSetupScreen` and `ProfileSetupViewModel` to navigate steps using key-based logic instead of hardcoded indices. --- .../ui/profilesetup/ProfileSetupScreen.kt | 57 +++---- .../ui/profilesetup/ProfileSetupViewModel.kt | 5 +- .../ui/profilesetup/widgets/AboutYouStep.kt | 146 ------------------ .../profilesetup/widgets/DateOfBirthStep.kt | 62 ++++++++ .../ui/profilesetup/widgets/SexStep.kt | 51 ++++++ .../ui/profilesetup/widgets/SummaryStep.kt | 102 +++++------- 6 files changed, 187 insertions(+), 236 deletions(-) delete mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/AboutYouStep.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/DateOfBirthStep.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/SexStep.kt 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 4d146da..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 @@ -36,13 +36,15 @@ 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 @@ -103,7 +105,6 @@ private fun ProfileSetupContent( fontWeight = FontWeight.SemiBold, color = colors.accentDefault, ) - // Balance the row so title stays centered Spacer(Modifier.size(48.dp)) } @@ -118,45 +119,49 @@ 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, ) - 2 -> HeightStep( + "height" -> HeightStep( heightCm = state.heightCm, title = state.currentStep?.title ?: "How tall are you?", subtitle = state.currentStep?.subtitle, onHeightChanged = eventHandler::onHeightChanged, ) - 3 -> CurrentWeightStep( + "currentWeightKg" -> CurrentWeightStep( weightKg = state.currentWeightKg, title = state.currentStep?.title ?: "What's your current weight?", subtitle = state.currentStep?.subtitle, onWeightChanged = eventHandler::onCurrentWeightChanged, ) - 4 -> ActivityLevelStep( + "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, ) - 5 -> GoalWeightStep( + "targetWeightKg" -> GoalWeightStep( targetWeightKg = state.targetWeightKg, currentWeightKg = state.currentWeightKg, suggestion = state.goalSuggestion, @@ -165,14 +170,18 @@ private fun ProfileSetupContent( subtitle = state.currentStep?.subtitle, onTargetWeightChanged = eventHandler::onTargetWeightChanged, ) - 6 -> SummaryStep( - summary = state.onboardingSummary, - isLoading = state.isLoadingSummary, + "dailyDeficitKcal" -> DeficitStep( dailyDeficitKcal = state.dailyDeficitKcal, - title = state.currentStep?.title ?: "Here's your plan", + 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, + ) } } @@ -188,11 +197,11 @@ private fun ProfileSetupContent( disabledContainerColor = colors.accentSubtle, ), enabled = !state.isSubmitting && - when (state.currentStepIndex) { - 0 -> state.name.isNotBlank() - 1 -> state.sex != null - 4 -> state.selectedActivityLevel != null - 6 -> state.onboardingSummary != null + when (state.currentStep?.key) { + "name" -> state.name.isNotBlank() + "sex" -> state.sex != null + "activityLevel" -> state.selectedActivityLevel != null + "summary" -> state.onboardingSummary != null else -> true }, ) { @@ -209,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( 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 cf15f14..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 @@ -111,10 +111,11 @@ internal class ProfileSetupViewModel( override fun onContinue() { val state = uiState.value val nextIndex = state.currentStepIndex + 1 - if (state.currentStepIndex == 4) fetchGoalSuggestion() + if (state.currentStep?.key == "activityLevel") fetchGoalSuggestion() if (nextIndex < state.totalSteps) { save { it.copy(currentStepIndex = nextIndex) } - if (nextIndex == state.totalSteps - 1) fetchSummary() + val nextKey = state.steps.getOrNull(nextIndex)?.key + if (nextKey == "summary") fetchSummary() } else { submitProfile() } 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 7870391..0000000 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/AboutYouStep.kt +++ /dev/null @@ -1,146 +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.YaseyoPreview -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() { - YaseyoPreview(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() { - YaseyoPreview(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() { - YaseyoPreview(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/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/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 index d1cd197..81bb5b5 100644 --- 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 @@ -35,9 +35,7 @@ private val deficitOptions = listOf( ) @Composable -internal fun SummaryStep( - summary: OnboardingSummary?, - isLoading: Boolean, +internal fun DeficitStep( dailyDeficitKcal: Int, title: String, subtitle: String?, @@ -45,26 +43,13 @@ internal fun SummaryStep( modifier: Modifier = Modifier, ) { val colors = YaseyoTheme.colors - Column(modifier = modifier) { - Text( - text = title, - fontSize = 22.sp, - fontWeight = FontWeight.Bold, - color = colors.contentPrimary, - ) - + 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, - ) + Text(text = subtitle, fontSize = 15.sp, color = colors.contentSecondary) } - Spacer(Modifier.height(24.dp)) - Text( text = "DAILY DEFICIT", fontSize = 11.sp, @@ -72,9 +57,7 @@ internal fun SummaryStep( letterSpacing = 1.sp, color = colors.contentTertiary, ) - Spacer(Modifier.height(10.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { deficitOptions.forEach { (kcal, label) -> DeficitChip( @@ -86,9 +69,25 @@ internal fun SummaryStep( ) } } + } +} +@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 @@ -127,17 +126,8 @@ private fun DeficitChip( .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, - ) + Text(text = label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.contentPrimary) + Text(text = "$kcal kcal", fontSize = 11.sp, color = colors.contentSecondary) } } @@ -166,10 +156,7 @@ private fun SummaryCard( 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, - ) + HorizontalDivider(modifier = Modifier.padding(vertical = 10.dp), color = colors.borderSubtle) Text( text = "SUGGESTED MACROS", fontSize = 11.sp, @@ -195,9 +182,7 @@ private fun SummaryRow( ) { val colors = YaseyoTheme.colors Row( - modifier = modifier - .fillMaxWidth() - .padding(vertical = 4.dp), + modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween, ) { Text(text = label, fontSize = 14.sp, color = colors.contentSecondary) @@ -234,44 +219,37 @@ private val previewSummary = OnboardingSummary( @Preview(showBackground = true) @Composable -private fun SummaryStepLoadingPreview() { +private fun DeficitStepPreview() { YaseyoPreview(darkTheme = false) { - SummaryStep( - summary = null, - isLoading = true, + DeficitStep( dailyDeficitKcal = 500, - title = "Here's your plan", - subtitle = "Based on your profile and goal.", + 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, - dailyDeficitKcal = 500, - title = "Here's your plan", - subtitle = "Based on your profile and goal.", - onDeficitChanged = {}, - ) + SummaryStep(summary = previewSummary, isLoading = false, title = "Your plan", subtitle = "Based on your profile") } } @Preview(showBackground = true) @Composable -private fun SummaryStepDarkPreview() = +private fun SummaryStepDarkPreview() { YaseyoPreview(darkTheme = true) { - SummaryStep( - summary = previewSummary, - isLoading = false, - dailyDeficitKcal = 750, - title = "Here's your plan", - subtitle = "Based on your profile and goal.", - onDeficitChanged = {}, - ) + SummaryStep(summary = previewSummary, isLoading = false, title = "Your plan", subtitle = "Based on your profile") } +} From c830cb681404a2413887682b4f35133ae34aad6d Mon Sep 17 00:00:00 2001 From: Taki Date: Sun, 19 Jul 2026 19:14:06 +0900 Subject: [PATCH 22/27] Add HttpResponseValidator to NetworkModule and fix User sex and date formatting --- .../kotlin/dev/yaseyo/network/NetworkModule.kt | 10 ++++++++++ .../kotlin/dev/yaseyo/user/impl/network/UserApiImpl.kt | 4 ++-- .../kotlin/dev/yaseyo/user/impl/network/UserNet.kt | 4 ++-- 3 files changed, 14 insertions(+), 4 deletions(-) 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/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, From 8b9b384b75d02aa73a73e0e73d14b7f59052c9e0 Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Jul 2026 14:33:16 +0900 Subject: [PATCH 23/27] Add FakeUserRepository and update onboarding steps in tests --- .../onboarding/fake/FakeOnboardingApi.kt | 9 ++- .../onboarding/fake/FakeUserRepository.kt | 58 +++++++++++++++++++ .../profilesetup/ProfileSetupViewModelTest.kt | 11 ++-- 3 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeUserRepository.kt 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 index ca3540a..15f08c0 100644 --- a/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeOnboardingApi.kt +++ b/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeOnboardingApi.kt @@ -41,7 +41,14 @@ internal class FakeOnboardingApi( companion object { val DEFAULT_STEPS = listOf( OnboardingStep(key = "name", title = "Name", subtitle = null), - OnboardingStep(key = "about_you", title = "About you", 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"), 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/ui/profilesetup/ProfileSetupViewModelTest.kt b/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupViewModelTest.kt index f4f7756..812468c 100644 --- 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 @@ -7,6 +7,7 @@ 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 @@ -40,8 +41,10 @@ class ProfileSetupViewModelTest { Dispatchers.resetMain() } - private fun viewModel(repo: FakeOnboardingRepository = FakeOnboardingRepository()) = - ProfileSetupViewModel(repo = repo, dispatchers = dispatchers, router = AppRouter()) + private fun viewModel( + repo: FakeOnboardingRepository = FakeOnboardingRepository(), + userRepository: FakeUserRepository = FakeUserRepository(), + ) = ProfileSetupViewModel(repo = repo, dispatchers = dispatchers, router = AppRouter(), userRepository = userRepository) // --- draft restoration --- @@ -203,7 +206,7 @@ class ProfileSetupViewModelTest { runTest { val repo = FakeOnboardingRepository( initialDraft = OnboardingDraft( - currentStepIndex = 4, + currentStepIndex = 5, sex = Sex.Male, heightCm = 178, currentWeightKg = 80, @@ -227,7 +230,7 @@ class ProfileSetupViewModelTest { fun `fetchGoalSuggestion clears loading on failure`() = runTest { val repo = FakeOnboardingRepository( - initialDraft = OnboardingDraft(currentStepIndex = 4, sex = Sex.Male), + initialDraft = OnboardingDraft(currentStepIndex = 5, sex = Sex.Male), goalSuggestionResult = Result.failure(RuntimeException("error")), ) val vm = viewModel(repo = repo) From f54b8b6b65fe668b54d51d7bae6bbafc7cda7bfd Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Jul 2026 15:11:57 +0900 Subject: [PATCH 24/27] Remove authModule and update DI to use authImplModule directly --- libs/auth/build.gradle.kts | 1 - .../commonMain/kotlin/dev/yaseyo/auth/di/AuthModule.kt | 8 -------- libs/di/build.gradle.kts | 2 +- libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt | 4 ++-- 4 files changed, 3 insertions(+), 12 deletions(-) delete mode 100644 libs/auth/src/commonMain/kotlin/dev/yaseyo/auth/di/AuthModule.kt diff --git a/libs/auth/build.gradle.kts b/libs/auth/build.gradle.kts index 1490f98..a0e5897 100644 --- a/libs/auth/build.gradle.kts +++ b/libs/auth/build.gradle.kts @@ -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/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/di/build.gradle.kts b/libs/di/build.gradle.kts index 8326c05..6d10853 100644 --- a/libs/di/build.gradle.kts +++ b/libs/di/build.gradle.kts @@ -34,7 +34,7 @@ kotlin { // Libs implementation(projects.libs.coroutines) - implementation(projects.libs.auth) + implementation(projects.libs.auth.impl) implementation(projects.libs.network) implementation(projects.libs.datastore) 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 209c404..842d4d9 100644 --- a/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt +++ b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt @@ -1,6 +1,6 @@ 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 @@ -12,7 +12,7 @@ import org.koin.dsl.module internal val appModules = listOf( navigationModule, coroutinesModule, - authModule, + authImplModule, userModule, module { single { networkConfig } }, networkModule, From 006a053848bc3bd44b42301154b9f4b274d6e4fd Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Jul 2026 15:16:18 +0900 Subject: [PATCH 25/27] Remove userModule and migrate Koin initialization to use userImplModule directly --- libs/di/build.gradle.kts | 1 + libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt | 4 ++-- libs/user/build.gradle.kts | 2 -- .../commonMain/kotlin/dev/yaseyo/user/di/UserModule.kt | 8 -------- 4 files changed, 3 insertions(+), 12 deletions(-) delete mode 100644 libs/user/src/commonMain/kotlin/dev/yaseyo/user/di/UserModule.kt diff --git a/libs/di/build.gradle.kts b/libs/di/build.gradle.kts index 6d10853..5f75d5d 100644 --- a/libs/di/build.gradle.kts +++ b/libs/di/build.gradle.kts @@ -40,6 +40,7 @@ kotlin { // 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/commonMain/kotlin/dev/yaseyo/di/AppModules.kt b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt index 842d4d9..a55351d 100644 --- a/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt +++ b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt @@ -6,14 +6,14 @@ 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, authImplModule, - userModule, + userImplModule, module { single { networkConfig } }, networkModule, onboardingModule, diff --git a/libs/user/build.gradle.kts b/libs/user/build.gradle.kts index f7edded..de1e91a 100644 --- a/libs/user/build.gradle.kts +++ b/libs/user/build.gradle.kts @@ -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/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) -} From edb37663fae3b597054ba758ae4af0a249bc47c9 Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Jul 2026 15:30:03 +0900 Subject: [PATCH 26/27] Remove UserDependencies helper from libs:user iosMain --- .../src/iosMain/kotlin/dev/yaseyo/user/UserDependencies.kt | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 libs/user/src/iosMain/kotlin/dev/yaseyo/user/UserDependencies.kt 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() From a1f70695846e15eb0eb40eea215c6087bad632ff Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Jul 2026 16:08:57 +0900 Subject: [PATCH 27/27] Set sourceSetTreeName for host tests and update ProfileSetupViewModelTest assertions --- feature/home/build.gradle.kts | 2 +- feature/onboarding/build.gradle.kts | 2 +- .../onboarding/ui/profilesetup/ProfileSetupViewModelTest.kt | 4 ++-- libs/auth/api/build.gradle.kts | 2 +- libs/auth/build.gradle.kts | 2 +- libs/auth/impl/build.gradle.kts | 2 +- libs/domain-models/build.gradle.kts | 2 +- libs/user/api/build.gradle.kts | 2 +- libs/user/build.gradle.kts | 2 +- libs/user/impl/build.gradle.kts | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index e064422..0e9c093 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -9,10 +9,10 @@ kotlin { namespace = "dev.yaseyo.home" withHostTestBuilder { + sourceSetTreeName = "test" } withDeviceTestBuilder { - sourceSetTreeName = "test" }.configure { instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index 9f3c69f..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" } 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 index 812468c..25eea08 100644 --- 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 @@ -282,10 +282,10 @@ class ProfileSetupViewModelTest { vm.uiState.test { awaitItem() advanceUntilIdle() - expectMostRecentItem() + assertNull(expectMostRecentItem().onboardingSummary) vm.onDeficitChanged(500) advanceUntilIdle() - assertNull(expectMostRecentItem().onboardingSummary) + expectNoEvents() // no summary fetch means no new emission cancelAndIgnoreRemainingEvents() } } 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 a0e5897..48dd23f 100644 --- a/libs/auth/build.gradle.kts +++ b/libs/auth/build.gradle.kts @@ -7,10 +7,10 @@ kotlin { namespace = "dev.yaseyo.auth" withHostTestBuilder { + sourceSetTreeName = "test" } withDeviceTestBuilder { - sourceSetTreeName = "test" }.configure { instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } 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/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/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/build.gradle.kts b/libs/user/build.gradle.kts index de1e91a..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" } 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" }