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