From 7887f806d91450ccfb691a4ea19dab8c090d7bb0 Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 8 May 2026 22:51:09 +0900 Subject: [PATCH 01/24] Merge onboarding:api and onboarding:android modules into onboarding module --- .../kotlin/dev/yaseyo/MainViewModel.kt | 6 +-- feature/onboarding/android/build.gradle.kts | 19 ------- feature/onboarding/api/build.gradle.kts | 51 ------------------- feature/onboarding/build.gradle.kts | 50 +++++++++++++++++- .../OnboardingFeatureAndroidModule.kt | 2 +- .../OnboardingFeatureNavigation.kt | 4 +- .../onboarding}/ResolveAppStateUseCase.kt | 4 +- .../yaseyo/onboarding}/di/OnboardingModule.kt | 4 +- .../dev/yaseyo/onboarding}/model/AppState.kt | 2 +- .../navigation/OnboardingRoutes.kt | 2 +- libs/di/build.gradle.kts | 2 - .../kotlin/dev/yaseyo/di/KoinInitializer.kt | 2 +- .../kotlin/dev/yaseyo/di/AppModules.kt | 2 +- settings.gradle.kts | 2 - 14 files changed, 62 insertions(+), 90 deletions(-) delete mode 100644 feature/onboarding/android/build.gradle.kts delete mode 100644 feature/onboarding/api/build.gradle.kts rename feature/onboarding/{android/src/main/kotlin/dev/yaseyo/onboarding/android => src/androidMain/kotlin/dev/yaseyo/onboarding}/OnboardingFeatureAndroidModule.kt (83%) rename feature/onboarding/{android/src/main/kotlin/dev/yaseyo/onboarding/android => src/androidMain/kotlin/dev/yaseyo/onboarding}/OnboardingFeatureNavigation.kt (86%) rename feature/onboarding/{api/src/commonMain/kotlin/dev/yaseyo/onboarding/api => src/commonMain/kotlin/dev/yaseyo/onboarding}/ResolveAppStateUseCase.kt (89%) rename feature/onboarding/{api/src/commonMain/kotlin/dev/yaseyo/onboarding/api => src/commonMain/kotlin/dev/yaseyo/onboarding}/di/OnboardingModule.kt (60%) rename feature/onboarding/{api/src/commonMain/kotlin/dev/yaseyo/onboarding/api => src/commonMain/kotlin/dev/yaseyo/onboarding}/model/AppState.kt (80%) rename feature/onboarding/{api/src/commonMain/kotlin/dev/yaseyo/onboarding/api => src/commonMain/kotlin/dev/yaseyo/onboarding}/navigation/OnboardingRoutes.kt (79%) diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt index 166fcca..7122413 100644 --- a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt @@ -4,9 +4,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.yaseyo.navigation.AppRoute import dev.yaseyo.navigation.Home -import dev.yaseyo.onboarding.api.ResolveAppStateUseCase -import dev.yaseyo.onboarding.api.model.AppState -import dev.yaseyo.onboarding.api.navigation.OnboardingRoutes +import dev.yaseyo.onboarding.ResolveAppStateUseCase +import dev.yaseyo.onboarding.model.AppState +import dev.yaseyo.onboarding.navigation.OnboardingRoutes import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flow diff --git a/feature/onboarding/android/build.gradle.kts b/feature/onboarding/android/build.gradle.kts deleted file mode 100644 index 317d496..0000000 --- a/feature/onboarding/android/build.gradle.kts +++ /dev/null @@ -1,19 +0,0 @@ -plugins { - id("yaseyo.android.library") - alias(libs.plugins.composeCompiler) -} - -android { - namespace = "dev.yaseyo.onboarding.android" -} - -dependencies { - implementation(projects.feature.onboarding.api) - - implementation(libs.kotlin.stdlib) - implementation(libs.koin.core) - implementation(libs.koin.android) - implementation(libs.koin.compose.nav3) - implementation(libs.compose.foundation) - implementation(libs.compose.material3) -} diff --git a/feature/onboarding/api/build.gradle.kts b/feature/onboarding/api/build.gradle.kts deleted file mode 100644 index f4dfe60..0000000 --- a/feature/onboarding/api/build.gradle.kts +++ /dev/null @@ -1,51 +0,0 @@ -plugins { - id("yaseyo.kmp.library") -} - -kotlin { - androidLibrary { - namespace = "dev.yaseyo.onboarding.api" - - withHostTestBuilder { - } - - withDeviceTestBuilder { - sourceSetTreeName = "test" - }.configure { - instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - } - - sourceSets { - commonMain { - dependencies { - implementation(libs.kotlin.stdlib) - implementation(libs.koin.core) - api(libs.kotlinx.coroutines.core) - - implementation(projects.libs.auth) - implementation(projects.libs.user) - api(projects.libs.navigation) - } - } - - commonTest { - dependencies { - implementation(libs.kotlin.test) - } - } - - 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 41fe8ea..07f4ab5 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -1,15 +1,61 @@ plugins { id("yaseyo.kmp.library") + alias(libs.plugins.composeCompiler) } kotlin { androidLibrary { namespace = "dev.yaseyo.onboarding" + + withHostTestBuilder { + } + + withDeviceTestBuilder { + sourceSetTreeName = "test" + }.configure { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } } sourceSets { - commonMain.dependencies { - api(projects.feature.onboarding.api) + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + implementation(libs.koin.core) + api(libs.kotlinx.coroutines.core) + + implementation(projects.libs.auth) + implementation(projects.libs.user) + api(projects.libs.navigation) + } + } + + commonTest { + dependencies { + implementation(libs.kotlin.test) + } + } + + androidMain { + dependencies { + implementation(libs.koin.android) + implementation(libs.koin.compose.nav3) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + } + } + + getByName("androidDeviceTest") { + dependencies { + implementation(libs.androidx.runner) + implementation(libs.androidx.core) + implementation(libs.androidx.testExt.junit) + } + } + + iosMain { + dependencies { + } } } } diff --git a/feature/onboarding/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureAndroidModule.kt b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureAndroidModule.kt similarity index 83% rename from feature/onboarding/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureAndroidModule.kt rename to feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureAndroidModule.kt index 4173cec..8bd975f 100644 --- a/feature/onboarding/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureAndroidModule.kt +++ b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureAndroidModule.kt @@ -1,4 +1,4 @@ -package dev.yaseyo.onboarding.android +package dev.yaseyo.onboarding import dev.yaseyo.navigation.FeatureNavigation import org.koin.dsl.module diff --git a/feature/onboarding/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureNavigation.kt b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt similarity index 86% rename from feature/onboarding/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureNavigation.kt rename to feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt index 69e3fbf..202e49a 100644 --- a/feature/onboarding/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureNavigation.kt +++ b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt @@ -1,4 +1,4 @@ -package dev.yaseyo.onboarding.android +package dev.yaseyo.onboarding import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -8,7 +8,7 @@ import androidx.compose.ui.graphics.Color import androidx.navigation3.runtime.EntryProviderScope import dev.yaseyo.navigation.AppRoute import dev.yaseyo.navigation.FeatureNavigation -import dev.yaseyo.onboarding.api.navigation.OnboardingRoutes +import dev.yaseyo.onboarding.navigation.OnboardingRoutes internal class OnboardingFeatureNavigation : FeatureNavigation { override val navEntryProvider: EntryProviderScope.() -> Unit = { diff --git a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/ResolveAppStateUseCase.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ResolveAppStateUseCase.kt similarity index 89% rename from feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/ResolveAppStateUseCase.kt rename to feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ResolveAppStateUseCase.kt index 06ea145..34647d2 100644 --- a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/ResolveAppStateUseCase.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ResolveAppStateUseCase.kt @@ -1,8 +1,8 @@ -package dev.yaseyo.onboarding.api +package dev.yaseyo.onboarding import dev.yaseyo.auth.api.AuthRepository import dev.yaseyo.auth.api.AuthState -import dev.yaseyo.onboarding.api.model.AppState +import dev.yaseyo.onboarding.model.AppState import dev.yaseyo.user.api.UserRepository class ResolveAppStateUseCase( diff --git a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/di/OnboardingModule.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/di/OnboardingModule.kt similarity index 60% rename from feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/di/OnboardingModule.kt rename to feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/di/OnboardingModule.kt index 2c0d265..6068ad8 100644 --- a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/di/OnboardingModule.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/di/OnboardingModule.kt @@ -1,6 +1,6 @@ -package dev.yaseyo.onboarding.api.di +package dev.yaseyo.onboarding.di -import dev.yaseyo.onboarding.api.ResolveAppStateUseCase +import dev.yaseyo.onboarding.ResolveAppStateUseCase import org.koin.core.module.dsl.factoryOf import org.koin.dsl.module diff --git a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/model/AppState.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/AppState.kt similarity index 80% rename from feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/model/AppState.kt rename to feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/AppState.kt index ef17749..e2f96a0 100644 --- a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/model/AppState.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/AppState.kt @@ -1,4 +1,4 @@ -package dev.yaseyo.onboarding.api.model +package dev.yaseyo.onboarding.model sealed interface AppState { data object FullySetup : AppState diff --git a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/navigation/OnboardingRoutes.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/navigation/OnboardingRoutes.kt similarity index 79% rename from feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/navigation/OnboardingRoutes.kt rename to feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/navigation/OnboardingRoutes.kt index 2baac56..033ba60 100644 --- a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/navigation/OnboardingRoutes.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/navigation/OnboardingRoutes.kt @@ -1,4 +1,4 @@ -package dev.yaseyo.onboarding.api.navigation +package dev.yaseyo.onboarding.navigation import dev.yaseyo.navigation.AppRoute diff --git a/libs/di/build.gradle.kts b/libs/di/build.gradle.kts index 1f92ceb..bc74730 100644 --- a/libs/di/build.gradle.kts +++ b/libs/di/build.gradle.kts @@ -48,8 +48,6 @@ kotlin { dependencies { implementation(libs.koin.android) implementation(libs.androidx.startup.runtime) - - implementation(projects.feature.onboarding.android) } } diff --git a/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt b/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt index 24a1147..926f2e1 100644 --- a/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt +++ b/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt @@ -3,7 +3,7 @@ package dev.yaseyo.di import android.content.Context import androidx.startup.Initializer import dev.yaseyo.navigation.navigationAndroidModule -import dev.yaseyo.onboarding.android.onboardingFeatureAndroidModule +import dev.yaseyo.onboarding.onboardingFeatureAndroidModule import org.koin.android.ext.koin.androidContext import org.koin.core.KoinApplication import org.koin.core.context.startKoin 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 7faaca3..3d4d4b6 100644 --- a/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt +++ b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt @@ -3,7 +3,7 @@ package dev.yaseyo.di import dev.yaseyo.auth.di.authModule import dev.yaseyo.coroutines.coroutinesModule import dev.yaseyo.network.networkModule -import dev.yaseyo.onboarding.api.di.onboardingModule +import dev.yaseyo.onboarding.di.onboardingModule import dev.yaseyo.user.di.userModule import org.koin.dsl.module diff --git a/settings.gradle.kts b/settings.gradle.kts index 0234004..aaca923 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,5 +45,3 @@ include(":libs:design") include(":libs:network") include(":libs:di") include(":feature:onboarding") -include(":feature:onboarding:api") -include(":feature:onboarding:android") From c041f57a284a875370be7d3cb8cae1db3b188cff Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 8 May 2026 22:54:27 +0900 Subject: [PATCH 02/24] Add AuthScreen to commonMain and migrate onboarding module to Compose Multiplatform --- feature/onboarding/build.gradle.kts | 7 +++++-- .../onboarding/OnboardingFeatureNavigation.kt | 8 ++------ .../kotlin/dev/yaseyo/onboarding/ui/AuthScreen.kt | 13 +++++++++++++ 3 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreen.kt diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index 07f4ab5..b4d38a1 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("yaseyo.kmp.library") + alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) } @@ -27,6 +28,10 @@ kotlin { implementation(projects.libs.auth) implementation(projects.libs.user) api(projects.libs.navigation) + + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) } } @@ -40,8 +45,6 @@ kotlin { dependencies { implementation(libs.koin.android) implementation(libs.koin.compose.nav3) - implementation(libs.compose.foundation) - implementation(libs.compose.material3) } } diff --git a/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt index 202e49a..7283f5d 100644 --- a/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt +++ b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt @@ -1,19 +1,15 @@ package dev.yaseyo.onboarding -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.navigation3.runtime.EntryProviderScope import dev.yaseyo.navigation.AppRoute import dev.yaseyo.navigation.FeatureNavigation import dev.yaseyo.onboarding.navigation.OnboardingRoutes +import dev.yaseyo.onboarding.ui.AuthScreen internal class OnboardingFeatureNavigation : FeatureNavigation { override val navEntryProvider: EntryProviderScope.() -> Unit = { entry { - Box(modifier = Modifier.fillMaxSize().background(Color.Red)) + AuthScreen() } } } diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreen.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreen.kt new file mode 100644 index 0000000..0f6c31b --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreen.kt @@ -0,0 +1,13 @@ +package dev.yaseyo.onboarding.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +@Composable +fun AuthScreen() { + Box(modifier = Modifier.fillMaxSize().background(Color.Red)) +} From e87e734f8e21e3f9ca849ea67f4bfd5a6e3c48c5 Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 8 May 2026 23:14:03 +0900 Subject: [PATCH 03/24] Implement AuthScreen UI and migrate YaseyoTheme to commonMain --- feature/onboarding/build.gradle.kts | 10 +- .../yaseyo/onboarding/ui/AuthScreenPreview.kt | 17 ++ .../dev/yaseyo/onboarding/ui/AuthScreen.kt | 261 +++++++++++++++++- gradle/libs.versions.toml | 1 + libs/design/build.gradle.kts | 10 +- .../kotlin/dev/yaseyo/design/YaseyoTheme.kt | 0 6 files changed, 287 insertions(+), 12 deletions(-) create mode 100644 feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreenPreview.kt rename libs/design/src/{androidMain => commonMain}/kotlin/dev/yaseyo/design/YaseyoTheme.kt (100%) diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index b4d38a1..7fd1571 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -28,10 +28,13 @@ kotlin { implementation(projects.libs.auth) implementation(projects.libs.user) api(projects.libs.navigation) + implementation(projects.libs.design) - implementation(compose.runtime) - implementation(compose.foundation) - implementation(compose.material3) + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + // Pinned at 1.7.3 — CMP 1.8+ recommends Material Symbols for future icon work + implementation("org.jetbrains.compose.material:material-icons-extended:1.7.3") } } @@ -45,6 +48,7 @@ kotlin { dependencies { implementation(libs.koin.android) implementation(libs.koin.compose.nav3) + implementation(libs.compose.uiToolingPreview) } } diff --git a/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreenPreview.kt b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreenPreview.kt new file mode 100644 index 0000000..2fd04dc --- /dev/null +++ b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreenPreview.kt @@ -0,0 +1,17 @@ +package dev.yaseyo.onboarding.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import dev.yaseyo.design.YaseyoTheme + +@Preview(showBackground = true) +@Composable +private fun AuthScreenLightPreview() { + YaseyoTheme(darkTheme = false) { AuthScreen() } +} + +@Preview(showBackground = true) +@Composable +private fun AuthScreenDarkPreview() { + YaseyoTheme(darkTheme = true) { AuthScreen() } +} diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreen.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreen.kt index 0f6c31b..5c1c532 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreen.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreen.kt @@ -1,13 +1,270 @@ package dev.yaseyo.onboarding.ui import androidx.compose.foundation.background +import androidx.compose.foundation.clickable 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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldColors import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.yaseyo.design.YaseyoTheme @Composable -fun AuthScreen() { - Box(modifier = Modifier.fillMaxSize().background(Color.Red)) +fun AuthScreen() = + YaseyoTheme { + val colors = YaseyoTheme.colors + var isSignIn by remember { mutableStateOf(true) } + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(colors.backgroundBase)), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 56.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppLogo(accentSubtle = Color(colors.accentSubtle)) + + Spacer(Modifier.height(16.dp)) + + Text( + text = "Yaseyo", + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + color = Color(colors.accentDefault), + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = "Cultivating a lighter, healthier you.", + fontSize = 15.sp, + color = Color(colors.contentSecondary), + ) + + Spacer(Modifier.height(32.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = Color(colors.backgroundElevated)), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column(modifier = Modifier.padding(20.dp)) { + AuthToggle( + isSignIn = isSignIn, + onSignInSelected = { isSignIn = true }, + onSignUpSelected = { isSignIn = false }, + ) + + Spacer(Modifier.height(24.dp)) + + FieldLabel("EMAIL ADDRESS") + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = email, + onValueChange = { email = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("hello@example.com", color = Color(colors.contentTertiary)) }, + leadingIcon = { + Icon(Icons.Default.Email, contentDescription = null, tint = Color(colors.contentTertiary)) + }, + shape = RoundedCornerShape(12.dp), + colors = fieldColors(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + ) + + Spacer(Modifier.height(16.dp)) + + FieldLabel("PASSWORD") + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { + Icon(Icons.Default.Lock, contentDescription = null, tint = Color(colors.contentTertiary)) + }, + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (passwordVisible) "Hide password" else "Show password", + tint = Color(colors.contentTertiary), + ) + } + }, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + shape = RoundedCornerShape(12.dp), + colors = fieldColors(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + + Spacer(Modifier.height(8.dp)) + + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { + Text( + text = "Forgot Password?", + color = Color(colors.accentDefault), + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + ) + } + + Spacer(Modifier.height(24.dp)) + + Button( + onClick = {}, + modifier = Modifier.fillMaxWidth().height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(colors.accentDefault)), + ) { + Text( + text = "Log In →", + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = Color(colors.contentOnAccent), + ) + } + } + } + + Spacer(Modifier.height(24.dp)) + + Text( + text = "By continuing, you agree to Yaseyo's Terms of Service and Privacy Policy.", + fontSize = 12.sp, + color = Color(colors.contentTertiary), + textAlign = TextAlign.Center, + ) + } + } + } + +@Composable +private fun AppLogo(accentSubtle: Color) { + Box( + modifier = Modifier + .size(80.dp) + .clip(RoundedCornerShape(20.dp)) + .background(accentSubtle), + contentAlignment = Alignment.Center, + ) { + Text(text = "🌿", fontSize = 36.sp) + } +} + +@Composable +private fun AuthToggle( + isSignIn: Boolean, + onSignInSelected: () -> Unit, + onSignUpSelected: () -> Unit, +) { + val colors = YaseyoTheme.colors + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(50.dp)) + .background(Color(colors.backgroundSubtle)) + .padding(4.dp), + ) { + AuthTab(label = "Sign In", selected = isSignIn, modifier = Modifier.weight(1f), onClick = onSignInSelected) + AuthTab(label = "Sign Up", selected = !isSignIn, modifier = Modifier.weight(1f), onClick = onSignUpSelected) + } +} + +@Composable +private fun AuthTab( + label: String, + selected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val colors = YaseyoTheme.colors + Box( + modifier = modifier + .clip(RoundedCornerShape(50.dp)) + .background(if (selected) Color(colors.accentDefault) else Color.Transparent) + .clickable(onClick = onClick) + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = if (selected) Color(colors.contentOnAccent) else Color(colors.contentSecondary), + fontWeight = FontWeight.SemiBold, + fontSize = 15.sp, + ) + } +} + +@Composable +private fun FieldLabel(text: String) { + val colors = YaseyoTheme.colors + Text( + text = text, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + color = Color(colors.contentTertiary), + ) +} + +@Composable +private fun fieldColors(): TextFieldColors { + val colors = YaseyoTheme.colors + return OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color(colors.borderDefault), + focusedBorderColor = Color(colors.accentDefault), + unfocusedTextColor = Color(colors.contentPrimary), + focusedTextColor = Color(colors.contentPrimary), + ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6bed243..b877b30 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,6 +51,7 @@ compose-material3 = { module = "org.jetbrains.compose.material3:material3", vers compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } +compose-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version = "1.7.3" } 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" } diff --git a/libs/design/build.gradle.kts b/libs/design/build.gradle.kts index 564cc9d..f0d4d01 100644 --- a/libs/design/build.gradle.kts +++ b/libs/design/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("yaseyo.kmp.library") + alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) } @@ -12,14 +13,9 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) - } - } - - androidMain { - dependencies { - implementation(libs.compose.ui) - implementation(libs.compose.material3) + implementation(libs.compose.runtime) implementation(libs.compose.foundation) + implementation(libs.compose.material3) } } } diff --git a/libs/design/src/androidMain/kotlin/dev/yaseyo/design/YaseyoTheme.kt b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoTheme.kt similarity index 100% rename from libs/design/src/androidMain/kotlin/dev/yaseyo/design/YaseyoTheme.kt rename to libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoTheme.kt From a654f81a4b0e1277ef4e9fac2c911c49f29bb6eb Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 8 May 2026 23:29:51 +0900 Subject: [PATCH 04/24] Expose ResolveAppStateUseCase to iOS and implement navigation logic in iosApp --- .../yaseyo/onboarding/AuthViewControllers.kt | 6 ++ iosApp/iosApp/ContentView.swift | 94 +++++++++++++++---- .../dev/yaseyo/di/DependencyProvider.kt | 3 + 3 files changed, 86 insertions(+), 17 deletions(-) create mode 100644 feature/onboarding/src/iosMain/kotlin/dev/yaseyo/onboarding/AuthViewControllers.kt diff --git a/feature/onboarding/src/iosMain/kotlin/dev/yaseyo/onboarding/AuthViewControllers.kt b/feature/onboarding/src/iosMain/kotlin/dev/yaseyo/onboarding/AuthViewControllers.kt new file mode 100644 index 0000000..ef20b04 --- /dev/null +++ b/feature/onboarding/src/iosMain/kotlin/dev/yaseyo/onboarding/AuthViewControllers.kt @@ -0,0 +1,6 @@ +package dev.yaseyo.onboarding + +import androidx.compose.ui.window.ComposeUIViewController +import dev.yaseyo.onboarding.ui.AuthScreen + +fun authScreenViewController() = ComposeUIViewController { AuthScreen() } diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index ea1066c..4db0394 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -1,29 +1,89 @@ import SwiftUI +import Di + +// ── App coordinator ─────────────────────────────────────────────────────────── +// Equivalent of MainViewModel on Android. +// @MainActor pins all @Published mutations to the main thread, same as +// how Android's StateFlow.value updates land on Dispatchers.Main in Compose. + +@MainActor +private class AppCoordinator: ObservableObject { + + // nil == "not resolved yet" — mirrors the initialValue = null in StateFlow + @Published var destination: AppDestination? + + func resolveDestination() async { + let useCase = getResolveAppStateUseCase() + + // SKIE turns suspend fun into async throws. + // try? mirrors the fact that Android's coroutine scope swallows errors silently here. + guard let state = try? await useCase.execute() else { + destination = .auth + return + } + + // onEnum(of:) is SKIE's exhaustive switch helper for Kotlin sealed interfaces. + // Android analogy: it's the Swift equivalent of a Kotlin `when (state)` expression. + // The case labels are camelCase versions of the Kotlin subclass names. + switch onEnum(of: state) { + case .fullySetup: + destination = .home + case .requiresLogin: + destination = .auth + case .requiresProfileSetup: + destination = .profileSetup + } + } +} + +private enum AppDestination { + case auth, home, profileSetup +} + +// ── Root view ───────────────────────────────────────────────────────────────── +// Mirrors the setContent { } block in MainActivity. struct ContentView: View { - @State private var showContent = false - var body: some View { - VStack { - Button("Click me!") { - withAnimation { - showContent = !showContent - } - } - if showContent { - VStack(spacing: 16) { - Image(systemName: "swift") - .font(.system(size: 200)) - .foregroundColor(.accentColor) - } - .transition(.move(edge: .top).combined(with: .opacity)) + // @StateObject ≈ viewModel() in Compose — one instance, survives recomposition + @StateObject private var coordinator = AppCoordinator() + + var body: some View { + Group { + switch coordinator.destination { + case .auth: + AuthComposeView() + case .home, .profileSetup: + // TODO: implement home / profile-setup screens + Text("Coming soon") + case .none: + // Holds a blank screen while the use case resolves, + // same role as splashScreen.setKeepOnScreenCondition on Android. + Color(.systemBackground).ignoresSafeArea() } } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding() + // .task { } ≈ LaunchedEffect(Unit) — fires once on first appearance + .task { + await coordinator.resolveDestination() + } } } +// ── CMP screen bridge ───────────────────────────────────────────────────────── +// UIViewControllerRepresentable ≈ AndroidView { } — wraps a UIViewController +// (the ComposeUIViewController produced by authScreenViewController()) inside +// a SwiftUI view so NavigationStack can treat it like any other SwiftUI screen. + +private struct AuthComposeView: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + authScreenViewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +// ── Preview ─────────────────────────────────────────────────────────────────── + struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() diff --git a/libs/di/src/iosMain/kotlin/dev/yaseyo/di/DependencyProvider.kt b/libs/di/src/iosMain/kotlin/dev/yaseyo/di/DependencyProvider.kt index 5f764c6..f694356 100644 --- a/libs/di/src/iosMain/kotlin/dev/yaseyo/di/DependencyProvider.kt +++ b/libs/di/src/iosMain/kotlin/dev/yaseyo/di/DependencyProvider.kt @@ -1,6 +1,9 @@ package dev.yaseyo.di import dev.yaseyo.auth.api.AuthRepository +import dev.yaseyo.onboarding.ResolveAppStateUseCase import org.koin.mp.KoinPlatform fun getAuthRepository(): AuthRepository = KoinPlatform.getKoin().get() + +fun getResolveAppStateUseCase(): ResolveAppStateUseCase = KoinPlatform.getKoin().get() From f6e2e3beb8e29ba7b2f48f2d8cace571a41ecb4c Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 11 May 2026 14:27:32 +0900 Subject: [PATCH 05/24] Implement centralized navigation with `AppRouter` and migrate `AuthScreen` to MVI - Add `AppRouter` and `NavigationEvent` to `libs:navigation` for shared navigation logic - Replace `ResolveAppStateUseCase` with `ResolveStartDestinationUseCase` returning `AppRoute` - Implement `AuthViewModel`, `AuthUiState`, and `AuthEventHandler` for `AuthScreen` - Update `AuthScreen` to use `koinViewModel` and reactive UI state - Configure `AppRouter` event collection in the Android main entry point - Update iOS `AppCoordinator` to use the new start destination use case - Add Koin ViewModel dependencies to `feature:onboarding` and `libs:navigation` modules --- .../src/androidMain/kotlin/dev/yaseyo/App.kt | 14 +++ .../kotlin/dev/yaseyo/MainViewModel.kt | 21 +--- feature/onboarding/build.gradle.kts | 8 +- .../yaseyo/onboarding/ui/AuthScreenPreview.kt | 17 --- ...e.kt => ResolveStartDestinationUseCase.kt} | 18 ++- .../yaseyo/onboarding/di/OnboardingModule.kt | 7 +- .../yaseyo/onboarding/ui/AuthEventHandler.kt | 15 +++ .../dev/yaseyo/onboarding/ui/AuthScreen.kt | 114 +++++++++++++----- .../dev/yaseyo/onboarding/ui/AuthUiState.kt | 11 ++ .../dev/yaseyo/onboarding/ui/AuthViewModel.kt | 32 +++++ gradle/libs.versions.toml | 1 + iosApp/iosApp/ContentView.swift | 24 +--- .../kotlin/dev/yaseyo/di/AppModules.kt | 2 + .../dev/yaseyo/di/DependencyProvider.kt | 4 +- libs/navigation/build.gradle.kts | 2 + ...onModule.kt => NavigationAndroidModule.kt} | 0 .../kotlin/dev/yaseyo/navigation/AppRouter.kt | 18 +++ .../dev/yaseyo/navigation/NavigationEvent.kt | 9 ++ .../dev/yaseyo/navigation/NavigationModule.kt | 7 ++ 19 files changed, 233 insertions(+), 91 deletions(-) delete mode 100644 feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreenPreview.kt rename feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/{ResolveAppStateUseCase.kt => ResolveStartDestinationUseCase.kt} (56%) create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthEventHandler.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthUiState.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthViewModel.kt rename libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/{NavigationModule.kt => NavigationAndroidModule.kt} (100%) create mode 100644 libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRouter.kt create mode 100644 libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/NavigationEvent.kt create mode 100644 libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/NavigationModule.kt diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt index b678bda..3dd0208 100644 --- a/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavEntry @@ -11,16 +12,29 @@ import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import dev.yaseyo.navigation.AppRoute +import dev.yaseyo.navigation.AppRouter import dev.yaseyo.navigation.FeatureNavigation +import dev.yaseyo.navigation.NavigationEvent import dev.yaseyo.navigation.Navigator import org.koin.compose.getKoin +import org.koin.compose.koinInject import org.koin.core.annotation.KoinExperimentalAPI @OptIn(KoinExperimentalAPI::class) @Composable fun App(navigator: Navigator) { + val appRouter = koinInject() val allFeatureNavigation = getKoin().getAll() + LaunchedEffect(appRouter) { + appRouter.events.collect { event -> + when (event) { + is NavigationEvent.GoTo -> navigator.goTo(event.route) + is NavigationEvent.GoBack -> navigator.goBack() + } + } + } + MaterialTheme { Scaffold { padding -> NavDisplay( diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt index 7122413..6a7e9ff 100644 --- a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt @@ -3,32 +3,17 @@ package dev.yaseyo import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.yaseyo.navigation.AppRoute -import dev.yaseyo.navigation.Home -import dev.yaseyo.onboarding.ResolveAppStateUseCase -import dev.yaseyo.onboarding.model.AppState -import dev.yaseyo.onboarding.navigation.OnboardingRoutes +import dev.yaseyo.onboarding.ResolveStartDestinationUseCase import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn class MainViewModel( - private val resolveAppStateUseCase: ResolveAppStateUseCase, + private val resolveStartDestinationUseCase: ResolveStartDestinationUseCase, ) : ViewModel() { val startDestination: StateFlow = flow { - val appState = resolveAppStateUseCase.execute() - - emit( - value = appState.let { - when (it) { - AppState.FullySetup -> Home - - AppState.RequiresLogin -> OnboardingRoutes.Auth - - AppState.RequiresProfileSetup -> OnboardingRoutes.ProfileSetup - } - }, - ) + emit(resolveStartDestinationUseCase.execute()) }.stateIn( scope = viewModelScope, initialValue = null, diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index 7fd1571..07c8e72 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -23,6 +23,9 @@ kotlin { 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.auth) @@ -30,11 +33,13 @@ kotlin { 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) // Pinned at 1.7.3 — CMP 1.8+ recommends Material Symbols for future icon work - implementation("org.jetbrains.compose.material:material-icons-extended:1.7.3") + implementation(libs.compose.material.icons.extended) } } @@ -48,7 +53,6 @@ kotlin { dependencies { implementation(libs.koin.android) implementation(libs.koin.compose.nav3) - implementation(libs.compose.uiToolingPreview) } } diff --git a/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreenPreview.kt b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreenPreview.kt deleted file mode 100644 index 2fd04dc..0000000 --- a/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreenPreview.kt +++ /dev/null @@ -1,17 +0,0 @@ -package dev.yaseyo.onboarding.ui - -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview -import dev.yaseyo.design.YaseyoTheme - -@Preview(showBackground = true) -@Composable -private fun AuthScreenLightPreview() { - YaseyoTheme(darkTheme = false) { AuthScreen() } -} - -@Preview(showBackground = true) -@Composable -private fun AuthScreenDarkPreview() { - YaseyoTheme(darkTheme = true) { AuthScreen() } -} diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ResolveAppStateUseCase.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ResolveStartDestinationUseCase.kt similarity index 56% rename from feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ResolveAppStateUseCase.kt rename to feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ResolveStartDestinationUseCase.kt index 34647d2..e3c83af 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ResolveAppStateUseCase.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ResolveStartDestinationUseCase.kt @@ -2,15 +2,18 @@ package dev.yaseyo.onboarding import dev.yaseyo.auth.api.AuthRepository import dev.yaseyo.auth.api.AuthState +import dev.yaseyo.navigation.AppRoute +import dev.yaseyo.navigation.Home import dev.yaseyo.onboarding.model.AppState +import dev.yaseyo.onboarding.navigation.OnboardingRoutes import dev.yaseyo.user.api.UserRepository -class ResolveAppStateUseCase( +class ResolveStartDestinationUseCase( private val authRepository: AuthRepository, private val userRepository: UserRepository, ) { - suspend fun execute(): AppState = - when (authRepository.authState.value) { + suspend fun execute(): AppRoute { + val appState = when (authRepository.authState.value) { is AuthState.SignedOut -> AppState.RequiresLogin is AuthState.SignedIn -> { @@ -22,4 +25,13 @@ class ResolveAppStateUseCase( } } } + + return when (appState) { + AppState.FullySetup -> Home + + AppState.RequiresLogin -> OnboardingRoutes.Auth + + AppState.RequiresProfileSetup -> OnboardingRoutes.ProfileSetup + } + } } 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 6068ad8..88dbec0 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,9 +1,12 @@ package dev.yaseyo.onboarding.di -import dev.yaseyo.onboarding.ResolveAppStateUseCase +import dev.yaseyo.onboarding.ResolveStartDestinationUseCase +import dev.yaseyo.onboarding.ui.AuthViewModel import org.koin.core.module.dsl.factoryOf +import org.koin.core.module.dsl.viewModel import org.koin.dsl.module val onboardingModule = module { - factoryOf(::ResolveAppStateUseCase) + factoryOf(::ResolveStartDestinationUseCase) + viewModel { AuthViewModel(get()) } } diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthEventHandler.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthEventHandler.kt new file mode 100644 index 0000000..e246a57 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthEventHandler.kt @@ -0,0 +1,15 @@ +package dev.yaseyo.onboarding.ui + +interface AuthEventHandler { + fun onTabChanged(tab: AuthTab) + + fun onEmailChanged(email: String) + + fun onPasswordChanged(password: String) + + fun onPasswordVisibilityToggled() + + fun onSubmitClicked() + + fun onForgotPasswordClicked() +} diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreen.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreen.kt index 5c1c532..4165c15 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreen.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreen.kt @@ -31,10 +31,8 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextFieldColors import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -44,18 +42,25 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign +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 org.koin.compose.viewmodel.koinViewModel @Composable -fun AuthScreen() = +fun AuthScreen(viewModel: AuthViewModel = koinViewModel()) { + val state by viewModel.uiState.collectAsState() + AuthContent(state = state, eventHandler = viewModel) +} + +@Composable +fun AuthContent( + state: AuthUiState, + eventHandler: AuthEventHandler, +) { YaseyoTheme { val colors = YaseyoTheme.colors - var isSignIn by remember { mutableStateOf(true) } - var email by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - var passwordVisible by remember { mutableStateOf(false) } Box( modifier = Modifier @@ -98,9 +103,8 @@ fun AuthScreen() = ) { Column(modifier = Modifier.padding(20.dp)) { AuthToggle( - isSignIn = isSignIn, - onSignInSelected = { isSignIn = true }, - onSignUpSelected = { isSignIn = false }, + selectedTab = state.tab, + onTabSelected = eventHandler::onTabChanged, ) Spacer(Modifier.height(24.dp)) @@ -108,8 +112,8 @@ fun AuthScreen() = FieldLabel("EMAIL ADDRESS") Spacer(Modifier.height(8.dp)) OutlinedTextField( - value = email, - onValueChange = { email = it }, + value = state.email, + onValueChange = eventHandler::onEmailChanged, modifier = Modifier.fillMaxWidth(), placeholder = { Text("hello@example.com", color = Color(colors.contentTertiary)) }, leadingIcon = { @@ -126,22 +130,30 @@ fun AuthScreen() = FieldLabel("PASSWORD") Spacer(Modifier.height(8.dp)) OutlinedTextField( - value = password, - onValueChange = { password = it }, + value = state.password, + onValueChange = eventHandler::onPasswordChanged, modifier = Modifier.fillMaxWidth(), leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null, tint = Color(colors.contentTertiary)) }, trailingIcon = { - IconButton(onClick = { passwordVisible = !passwordVisible }) { + IconButton(onClick = eventHandler::onPasswordVisibilityToggled) { Icon( - imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (passwordVisible) "Hide password" else "Show password", + imageVector = if (state.isPasswordVisible) { + Icons.Default.VisibilityOff + } else { + Icons.Default.Visibility + }, + contentDescription = if (state.isPasswordVisible) "Hide password" else "Show password", tint = Color(colors.contentTertiary), ) } }, - visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + visualTransformation = if (state.isPasswordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, shape = RoundedCornerShape(12.dp), colors = fieldColors(), singleLine = true, @@ -156,19 +168,21 @@ fun AuthScreen() = color = Color(colors.accentDefault), fontWeight = FontWeight.Medium, fontSize = 14.sp, + modifier = Modifier.clickable { eventHandler.onForgotPasswordClicked() }, ) } Spacer(Modifier.height(24.dp)) Button( - onClick = {}, + onClick = eventHandler::onSubmitClicked, modifier = Modifier.fillMaxWidth().height(56.dp), shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors(containerColor = Color(colors.accentDefault)), + enabled = !state.isLoading, ) { Text( - text = "Log In →", + text = if (state.tab == AuthTab.SignIn) "Log In →" else "Sign Up →", fontSize = 17.sp, fontWeight = FontWeight.SemiBold, color = Color(colors.contentOnAccent), @@ -188,6 +202,7 @@ fun AuthScreen() = } } } +} @Composable private fun AppLogo(accentSubtle: Color) { @@ -204,9 +219,8 @@ private fun AppLogo(accentSubtle: Color) { @Composable private fun AuthToggle( - isSignIn: Boolean, - onSignInSelected: () -> Unit, - onSignUpSelected: () -> Unit, + selectedTab: AuthTab, + onTabSelected: (AuthTab) -> Unit, ) { val colors = YaseyoTheme.colors Row( @@ -216,13 +230,23 @@ private fun AuthToggle( .background(Color(colors.backgroundSubtle)) .padding(4.dp), ) { - AuthTab(label = "Sign In", selected = isSignIn, modifier = Modifier.weight(1f), onClick = onSignInSelected) - AuthTab(label = "Sign Up", selected = !isSignIn, modifier = Modifier.weight(1f), onClick = onSignUpSelected) + AuthTabButton( + label = "Sign In", + selected = selectedTab == AuthTab.SignIn, + modifier = Modifier.weight(1f), + onClick = { onTabSelected(AuthTab.SignIn) }, + ) + AuthTabButton( + label = "Sign Up", + selected = selectedTab == AuthTab.SignUp, + modifier = Modifier.weight(1f), + onClick = { onTabSelected(AuthTab.SignUp) }, + ) } } @Composable -private fun AuthTab( +private fun AuthTabButton( label: String, selected: Boolean, modifier: Modifier = Modifier, @@ -268,3 +292,39 @@ private fun fieldColors(): TextFieldColors { focusedTextColor = Color(colors.contentPrimary), ) } + +private val previewState = AuthUiState() + +private val previewHandler = object : AuthEventHandler { + override fun onTabChanged(tab: AuthTab) {} + + override fun onEmailChanged(email: String) {} + + override fun onPasswordChanged(password: String) {} + + override fun onPasswordVisibilityToggled() {} + + override fun onSubmitClicked() {} + + override fun onForgotPasswordClicked() {} +} + +@Preview(showBackground = true) +@Composable +private fun AuthContentLightPreview() { + YaseyoTheme(darkTheme = false) { AuthContent(state = previewState, eventHandler = previewHandler) } +} + +@Preview(showBackground = true) +@Composable +private fun AuthContentSignUpPreview() { + YaseyoTheme(darkTheme = false) { + AuthContent(state = previewState.copy(tab = AuthTab.SignUp), eventHandler = previewHandler) + } +} + +@Preview(showBackground = true) +@Composable +private fun AuthContentDarkPreview() { + YaseyoTheme(darkTheme = true) { AuthContent(state = previewState, eventHandler = previewHandler) } +} diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthUiState.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthUiState.kt new file mode 100644 index 0000000..4bf5547 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthUiState.kt @@ -0,0 +1,11 @@ +package dev.yaseyo.onboarding.ui + +enum class AuthTab { SignIn, SignUp } + +data class AuthUiState( + val tab: AuthTab = AuthTab.SignIn, + val email: String = "", + val password: String = "", + val isPasswordVisible: Boolean = false, + val isLoading: Boolean = false, +) diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthViewModel.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthViewModel.kt new file mode 100644 index 0000000..d0a5168 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthViewModel.kt @@ -0,0 +1,32 @@ +package dev.yaseyo.onboarding.ui + +import androidx.lifecycle.ViewModel +import dev.yaseyo.navigation.AppRouter +import dev.yaseyo.navigation.Home +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class AuthViewModel( + private val router: AppRouter, +) : ViewModel(), + AuthEventHandler { + private val _uiState = MutableStateFlow(AuthUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + override fun onTabChanged(tab: AuthTab) = _uiState.update { it.copy(tab = tab) } + + override fun onEmailChanged(email: String) = _uiState.update { it.copy(email = email) } + + override fun onPasswordChanged(password: String) = _uiState.update { it.copy(password = password) } + + override fun onPasswordVisibilityToggled() = _uiState.update { it.copy(isPasswordVisible = !it.isPasswordVisible) } + + override fun onForgotPasswordClicked() { /* TODO */ } + + override fun onSubmitClicked() { + // TODO: call auth use case, navigate on success + router.navigate(Home) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b877b30..c245fe6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -61,6 +61,7 @@ androidx-startup-runtime = { module = "androidx.startup:startup-runtime", versio androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } +koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } koin-compose-nav3 = { module = "io.insert-koin:koin-compose-navigation3", version.ref = "koin" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlinStdlib" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 4db0394..e437935 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -10,36 +10,20 @@ import Di private class AppCoordinator: ObservableObject { // nil == "not resolved yet" — mirrors the initialValue = null in StateFlow - @Published var destination: AppDestination? + @Published var destination: NavigationAppRoute? func resolveDestination() async { - let useCase = getResolveAppStateUseCase() + let useCase = getResolveStartDestinationUseCase() // SKIE turns suspend fun into async throws. // try? mirrors the fact that Android's coroutine scope swallows errors silently here. - guard let state = try? await useCase.execute() else { - destination = .auth + guard let state: NavigationAppRoute = try? await useCase.execute() else { + destination = OnboardingRoutesAuth() return } - - // onEnum(of:) is SKIE's exhaustive switch helper for Kotlin sealed interfaces. - // Android analogy: it's the Swift equivalent of a Kotlin `when (state)` expression. - // The case labels are camelCase versions of the Kotlin subclass names. - switch onEnum(of: state) { - case .fullySetup: - destination = .home - case .requiresLogin: - destination = .auth - case .requiresProfileSetup: - destination = .profileSetup - } } } -private enum AppDestination { - case auth, home, profileSetup -} - // ── Root view ───────────────────────────────────────────────────────────────── // Mirrors the setContent { } block in MainActivity. 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 3d4d4b6..b9dd6e6 100644 --- a/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt +++ b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt @@ -2,12 +2,14 @@ package dev.yaseyo.di import dev.yaseyo.auth.di.authModule import dev.yaseyo.coroutines.coroutinesModule +import dev.yaseyo.navigation.navigationModule import dev.yaseyo.network.networkModule import dev.yaseyo.onboarding.di.onboardingModule import dev.yaseyo.user.di.userModule import org.koin.dsl.module internal val appModules = listOf( + navigationModule, coroutinesModule, authModule, userModule, diff --git a/libs/di/src/iosMain/kotlin/dev/yaseyo/di/DependencyProvider.kt b/libs/di/src/iosMain/kotlin/dev/yaseyo/di/DependencyProvider.kt index f694356..c4d8ac7 100644 --- a/libs/di/src/iosMain/kotlin/dev/yaseyo/di/DependencyProvider.kt +++ b/libs/di/src/iosMain/kotlin/dev/yaseyo/di/DependencyProvider.kt @@ -1,9 +1,9 @@ package dev.yaseyo.di import dev.yaseyo.auth.api.AuthRepository -import dev.yaseyo.onboarding.ResolveAppStateUseCase +import dev.yaseyo.onboarding.ResolveStartDestinationUseCase import org.koin.mp.KoinPlatform fun getAuthRepository(): AuthRepository = KoinPlatform.getKoin().get() -fun getResolveAppStateUseCase(): ResolveAppStateUseCase = KoinPlatform.getKoin().get() +fun getResolveStartDestinationUseCase(): ResolveStartDestinationUseCase = KoinPlatform.getKoin().get() diff --git a/libs/navigation/build.gradle.kts b/libs/navigation/build.gradle.kts index 780653e..fe2a55d 100644 --- a/libs/navigation/build.gradle.kts +++ b/libs/navigation/build.gradle.kts @@ -11,6 +11,8 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.koin.core) } } diff --git a/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/NavigationModule.kt b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/NavigationAndroidModule.kt similarity index 100% rename from libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/NavigationModule.kt rename to libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/NavigationAndroidModule.kt diff --git a/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRouter.kt b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRouter.kt new file mode 100644 index 0000000..968c9aa --- /dev/null +++ b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRouter.kt @@ -0,0 +1,18 @@ +package dev.yaseyo.navigation + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class AppRouter { + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events: SharedFlow = _events.asSharedFlow() + + fun navigate(route: AppRoute) { + _events.tryEmit(NavigationEvent.GoTo(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 new file mode 100644 index 0000000..b0bbdf8 --- /dev/null +++ b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/NavigationEvent.kt @@ -0,0 +1,9 @@ +package dev.yaseyo.navigation + +sealed interface NavigationEvent { + data class GoTo( + val route: AppRoute, + ) : NavigationEvent + + data object GoBack : NavigationEvent +} diff --git a/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/NavigationModule.kt b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/NavigationModule.kt new file mode 100644 index 0000000..3910255 --- /dev/null +++ b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/NavigationModule.kt @@ -0,0 +1,7 @@ +package dev.yaseyo.navigation + +import org.koin.dsl.module + +val navigationModule = module { + single { AppRouter() } +} From 79bc96150783219d55ced85bba264e049ff61561 Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 11 May 2026 14:43:04 +0900 Subject: [PATCH 06/24] Update Gradle to 9.3.1, AGP to 9.1.1, and remove module boundary enforcement --- .../yaseyo/AndroidLibraryConventionPlugin.kt | 2 - .../dev/yaseyo/KmpLibraryConventionPlugin.kt | 2 - .../dev/yaseyo/ModuleBoundaryEnforcement.kt | 46 ------------------- feature/onboarding/build.gradle.kts | 2 +- gradle.properties | 12 ++++- gradle/libs.versions.toml | 14 +++--- gradle/wrapper/gradle-wrapper.properties | 2 +- 7 files changed, 21 insertions(+), 59 deletions(-) delete mode 100644 build-logic/convention/src/main/kotlin/dev/yaseyo/ModuleBoundaryEnforcement.kt diff --git a/build-logic/convention/src/main/kotlin/dev/yaseyo/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/dev/yaseyo/AndroidLibraryConventionPlugin.kt index 5ff4333..3783dc6 100644 --- a/build-logic/convention/src/main/kotlin/dev/yaseyo/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/dev/yaseyo/AndroidLibraryConventionPlugin.kt @@ -17,8 +17,6 @@ class AndroidLibraryConventionPlugin : Plugin { } } - target.enforceInternalModuleBoundaries() - val libs = target .extensions diff --git a/build-logic/convention/src/main/kotlin/dev/yaseyo/KmpLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/dev/yaseyo/KmpLibraryConventionPlugin.kt index e561f3f..2e1ba36 100644 --- a/build-logic/convention/src/main/kotlin/dev/yaseyo/KmpLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/dev/yaseyo/KmpLibraryConventionPlugin.kt @@ -43,8 +43,6 @@ class KmpLibraryConventionPlugin : Plugin { ) } - target.enforceInternalModuleBoundaries() - target.afterEvaluate { val frameworkName = extension.iosFrameworkName.orNull diff --git a/build-logic/convention/src/main/kotlin/dev/yaseyo/ModuleBoundaryEnforcement.kt b/build-logic/convention/src/main/kotlin/dev/yaseyo/ModuleBoundaryEnforcement.kt deleted file mode 100644 index 4e37244..0000000 --- a/build-logic/convention/src/main/kotlin/dev/yaseyo/ModuleBoundaryEnforcement.kt +++ /dev/null @@ -1,46 +0,0 @@ -package dev.yaseyo - -import org.gradle.api.GradleException -import org.gradle.api.Project -import org.gradle.api.artifacts.ProjectDependency - -private val INTERNAL_MODULE_SUFFIXES = setOf(":impl", ":android") -private val INTERNAL_MODULE_CONSUMER_ALLOWLIST = setOf(":libs:di") - -fun Project.enforceInternalModuleBoundaries() { - afterEvaluate { - if (path in INTERNAL_MODULE_CONSUMER_ALLOWLIST) return@afterEvaluate - - configurations.forEach { configuration -> - configuration.dependencies - .withType(ProjectDependency::class.java) - .forEach { dep -> - val depPath = dep.dependencyProject.path - if (depPath == path) return@forEach - val suffix = INTERNAL_MODULE_SUFFIXES.firstOrNull { depPath.endsWith(it) } ?: return@forEach - val expectedParent = depPath.removeSuffix(suffix) - val isSameTree = path == expectedParent || path.startsWith("$expectedParent:") - if (!isSameTree) { - throw GradleException( - buildString { - appendLine() - appendLine("DESIGN BREACH ───────────────────────────────────────────────────") - appendLine(" Module : $path") - appendLine(" Depends on : $depPath ← $suffix is internal") - appendLine() - appendLine(" $suffix modules are internal to their parent. No module outside") - appendLine(" the parent ('$expectedParent') is allowed to depend on them.") - appendLine(" The parent re-exports the public API and aggregates Koin modules.") - appendLine() - appendLine(" In $path/build.gradle.kts, replace:") - appendLine(" implementation(projects${depPath.replace(":", ".")})") - appendLine(" with:") - appendLine(" implementation(projects${expectedParent.replace(":", ".")})") - appendLine("─────────────────────────────────────────────────────────────────") - }, - ) - } - } - } - } -} diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index 07c8e72..0941698 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -38,7 +38,6 @@ kotlin { implementation(libs.compose.foundation) implementation(libs.compose.material3) implementation(libs.compose.uiToolingPreview) - // Pinned at 1.7.3 — CMP 1.8+ recommends Material Symbols for future icon work implementation(libs.compose.material.icons.extended) } } @@ -53,6 +52,7 @@ kotlin { dependencies { implementation(libs.koin.android) implementation(libs.koin.compose.nav3) + implementation(libs.androidx.ui.tooling) } } diff --git a/gradle.properties b/gradle.properties index 6f8e6ea..1f172e1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,4 +9,14 @@ org.gradle.caching=true #Android android.nonTransitiveRClass=true -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=true +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c245fe6..59a92e4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.11.2" +agp = "9.1.1" firebase-gitlive = "2.4.0" firebase-android-bom = "34.12.0" googleServices = "4.4.4" @@ -23,14 +23,15 @@ androidx-splashscreen = "1.0.1" runner = "1.7.0" core = "1.7.0" skie = "0.10.11" -kotlinx-coroutines = "1.10.2" +kotlinx-coroutines = "1.11.0" spotless = "8.4.0" ktlint = "1.5.0" -ktor = "3.4.2" +ktor = "3.4.3" kotlinx-serialization = "1.11.0" -kotlinx-datetime = "0.7.1" -nav3Core = "1.1.0" -lifecycleViewmodelNav3 = "2.11.0-alpha03" +kotlinx-datetime = "0.8.0" +nav3Core = "1.1.1" +lifecycleViewmodelNav3 = "2.11.0-beta01" +ui-tooling = "1.11.1" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -81,6 +82,7 @@ kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version. androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "ui-tooling" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d4081da..37f78a6 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From d0de9876a1de43562e9a76cb076fd92822ac27b3 Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 11 May 2026 15:02:52 +0900 Subject: [PATCH 07/24] Export navigation project in DI module frameworks and change dependency to api scope --- libs/di/build.gradle.kts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/di/build.gradle.kts b/libs/di/build.gradle.kts index bc74730..37491c6 100644 --- a/libs/di/build.gradle.kts +++ b/libs/di/build.gradle.kts @@ -21,6 +21,7 @@ kotlin { target.binaries.withType().configureEach { export(projects.libs.auth.api) export(projects.libs.user) + export(projects.libs.navigation) export(projects.feature.onboarding) } } @@ -37,7 +38,7 @@ kotlin { implementation(projects.libs.auth) implementation(projects.libs.network) api(projects.libs.user) - implementation(projects.libs.navigation) + api(projects.libs.navigation) // Features api(projects.feature.onboarding) From bf08ca771cd3b1ef7cd8078099373ae5f3fef6b7 Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 11 May 2026 16:07:51 +0900 Subject: [PATCH 08/24] Implement navigation using AppCoordinator and AppRouter in iOS app --- iosApp/iosApp/AppCoordinator.swift | 42 +++++++++++ iosApp/iosApp/ContentView.swift | 70 ++++--------------- iosApp/iosApp/KmpObservable.swift | 19 ----- iosApp/iosApp/iOSApp.swift | 4 +- .../dev/yaseyo/di/DependencyProvider.kt | 3 + 5 files changed, 61 insertions(+), 77 deletions(-) create mode 100644 iosApp/iosApp/AppCoordinator.swift delete mode 100644 iosApp/iosApp/KmpObservable.swift diff --git a/iosApp/iosApp/AppCoordinator.swift b/iosApp/iosApp/AppCoordinator.swift new file mode 100644 index 0000000..09b838b --- /dev/null +++ b/iosApp/iosApp/AppCoordinator.swift @@ -0,0 +1,42 @@ +// +// AppCoordinator.swift +// iosApp +// +// Created by Taki Enari on 2026/05/11. +// +import Di +import UIKit + +@MainActor +class AppCoordinator: ObservableObject { + private let resolveStartDestinationUseCase = getResolveStartDestinationUseCase() + let appRouter = getAppRouter() + weak var navigationController: UINavigationController? + + func subscribeToNavigationEvents() async { + for await navEvent in appRouter.events { + switch onEnum(of: navEvent) { + case .goBack: + navigationController?.popViewController(animated: true) + case .goTo(let navigationEvent): + guard let vc = makeViewController(for: navigationEvent.route) else { return } + navigationController?.pushViewController(vc, animated: true) + } + } + } + + func resolveInitialDestination() async { + let startRoute = try? await resolveStartDestinationUseCase.execute() + let vc = startRoute.flatMap { route in + makeViewController(for: route) + } ?? authScreenViewController() + navigationController?.setViewControllers([vc], animated: false) + } + + private func makeViewController(for route: any AppRoute) -> UIViewController? { + if route is OnboardingRoutesAuth { return authScreenViewController() } + if route is OnboardingRoutesProfileSetup { return nil } // TODO + if route is Home { return nil } // TODO + return nil + } +} diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index e437935..b456fe8 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -1,75 +1,31 @@ import SwiftUI import Di -// ── App coordinator ─────────────────────────────────────────────────────────── -// Equivalent of MainViewModel on Android. -// @MainActor pins all @Published mutations to the main thread, same as -// how Android's StateFlow.value updates land on Dispatchers.Main in Compose. - -@MainActor -private class AppCoordinator: ObservableObject { - - // nil == "not resolved yet" — mirrors the initialValue = null in StateFlow - @Published var destination: NavigationAppRoute? - - func resolveDestination() async { - let useCase = getResolveStartDestinationUseCase() - - // SKIE turns suspend fun into async throws. - // try? mirrors the fact that Android's coroutine scope swallows errors silently here. - guard let state: NavigationAppRoute = try? await useCase.execute() else { - destination = OnboardingRoutesAuth() - return - } - } -} - -// ── Root view ───────────────────────────────────────────────────────────────── -// Mirrors the setContent { } block in MainActivity. - struct ContentView: View { - - // @StateObject ≈ viewModel() in Compose — one instance, survives recomposition - @StateObject private var coordinator = AppCoordinator() + @ObservedObject var coordinator: AppCoordinator var body: some View { - Group { - switch coordinator.destination { - case .auth: - AuthComposeView() - case .home, .profileSetup: - // TODO: implement home / profile-setup screens - Text("Coming soon") - case .none: - // Holds a blank screen while the use case resolves, - // same role as splashScreen.setKeepOnScreenCondition on Android. - Color(.systemBackground).ignoresSafeArea() - } - } - // .task { } ≈ LaunchedEffect(Unit) — fires once on first appearance - .task { - await coordinator.resolveDestination() - } + RootNavigationView(coordinator: coordinator) + .ignoresSafeArea() + .task { await coordinator.resolveInitialDestination() } + .task { await coordinator.subscribeToNavigationEvents() } } } -// ── CMP screen bridge ───────────────────────────────────────────────────────── -// UIViewControllerRepresentable ≈ AndroidView { } — wraps a UIViewController -// (the ComposeUIViewController produced by authScreenViewController()) inside -// a SwiftUI view so NavigationStack can treat it like any other SwiftUI screen. +private struct RootNavigationView: UIViewControllerRepresentable { + let coordinator: AppCoordinator -private struct AuthComposeView: UIViewControllerRepresentable { - func makeUIViewController(context: Context) -> UIViewController { - authScreenViewController() + func makeUIViewController(context: Context) -> UINavigationController { + let navController = UINavigationController() + coordinator.navigationController = navController + return navController } - func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} + func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {} } -// ── Preview ─────────────────────────────────────────────────────────────────── - struct ContentView_Previews: PreviewProvider { static var previews: some View { - ContentView() + ContentView(coordinator: AppCoordinator()) } } diff --git a/iosApp/iosApp/KmpObservable.swift b/iosApp/iosApp/KmpObservable.swift deleted file mode 100644 index 4266f2f..0000000 --- a/iosApp/iosApp/KmpObservable.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -/// Wraps a KMP ViewModel-like object as a SwiftUI ObservableObject. -/// The onDeinit closure is called when this holder is deallocated, -/// which is where you call kmp.onCleared() to cancel the coroutine scope. -@MainActor -final class KmpObservable: ObservableObject { - let kmp: T - private let onDeinit: () -> Void - - init(_ kmp: T, onDeinit: @escaping () -> Void) { - self.kmp = kmp - self.onDeinit = onDeinit - } - - deinit { - onDeinit() - } -} diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 42d1efb..1c84c35 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -4,6 +4,8 @@ import FirebaseCore @main struct iOSApp: App { + @StateObject private var coordinator = AppCoordinator() + init() { startKoin() FirebaseApp.configure() @@ -11,7 +13,7 @@ struct iOSApp: App { var body: some Scene { WindowGroup { - ContentView() + ContentView(coordinator: self.coordinator) } } } diff --git a/libs/di/src/iosMain/kotlin/dev/yaseyo/di/DependencyProvider.kt b/libs/di/src/iosMain/kotlin/dev/yaseyo/di/DependencyProvider.kt index c4d8ac7..3b3a379 100644 --- a/libs/di/src/iosMain/kotlin/dev/yaseyo/di/DependencyProvider.kt +++ b/libs/di/src/iosMain/kotlin/dev/yaseyo/di/DependencyProvider.kt @@ -1,9 +1,12 @@ package dev.yaseyo.di import dev.yaseyo.auth.api.AuthRepository +import dev.yaseyo.navigation.AppRouter import dev.yaseyo.onboarding.ResolveStartDestinationUseCase import org.koin.mp.KoinPlatform fun getAuthRepository(): AuthRepository = KoinPlatform.getKoin().get() fun getResolveStartDestinationUseCase(): ResolveStartDestinationUseCase = KoinPlatform.getKoin().get() + +fun getAppRouter(): AppRouter = KoinPlatform.getKoin().get() From 8f83e91588fed1053849a7a69cb7f393cf6cec57 Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 11 May 2026 16:47:11 +0900 Subject: [PATCH 09/24] Implement sign-in logic in AuthViewModel and add Snackbar support --- composeApp/build.gradle.kts | 1 + .../src/androidMain/kotlin/dev/yaseyo/App.kt | 52 ++-- .../yaseyo/onboarding/di/OnboardingModule.kt | 4 +- .../dev/yaseyo/onboarding/ui/AuthScreen.kt | 274 +++++++++--------- .../dev/yaseyo/onboarding/ui/AuthViewModel.kt | 36 ++- .../kotlin/dev/yaseyo/design/YaseyoTheme.kt | 10 + 6 files changed, 216 insertions(+), 161 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 34e0785..d3329ae 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -19,6 +19,7 @@ kotlin { androidMain.dependencies { // Libs implementation(projects.libs.di) + implementation(projects.libs.design) // Dependencies implementation(libs.androidx.startup.runtime) diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt index 3dd0208..692516e 100644 --- a/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt @@ -3,6 +3,7 @@ package dev.yaseyo import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier @@ -11,6 +12,8 @@ import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay +import dev.yaseyo.design.LocalSnackBarHostState +import dev.yaseyo.design.YaseyoTheme import dev.yaseyo.navigation.AppRoute import dev.yaseyo.navigation.AppRouter import dev.yaseyo.navigation.FeatureNavigation @@ -22,34 +25,39 @@ import org.koin.core.annotation.KoinExperimentalAPI @OptIn(KoinExperimentalAPI::class) @Composable -fun App(navigator: Navigator) { - val appRouter = koinInject() - val allFeatureNavigation = getKoin().getAll() +fun App(navigator: Navigator) = + YaseyoTheme { + val appRouter = koinInject() + val allFeatureNavigation = getKoin().getAll() - LaunchedEffect(appRouter) { - appRouter.events.collect { event -> - when (event) { - is NavigationEvent.GoTo -> navigator.goTo(event.route) - is NavigationEvent.GoBack -> navigator.goBack() + LaunchedEffect(appRouter) { + appRouter.events.collect { event -> + when (event) { + is NavigationEvent.GoTo -> navigator.goTo(event.route) + is NavigationEvent.GoBack -> navigator.goBack() + } } } - } - MaterialTheme { - Scaffold { padding -> - NavDisplay( - backStack = navigator.backStack, - modifier = Modifier.padding(padding), - onBack = { navigator.goBack() }, - entryDecorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator(), - ), - entryProvider = allFeatureNavigation.registerAll(), - ) + MaterialTheme { + Scaffold( + snackbarHost = { + SnackbarHost(hostState = LocalSnackBarHostState.current) + }, + ) { padding -> + NavDisplay( + backStack = navigator.backStack, + modifier = Modifier.padding(padding), + onBack = { navigator.goBack() }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = allFeatureNavigation.registerAll(), + ) + } } } -} private fun List.registerAll(): (AppRoute) -> NavEntry = entryProvider { 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 88dbec0..f18e63e 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 @@ -3,10 +3,10 @@ package dev.yaseyo.onboarding.di import dev.yaseyo.onboarding.ResolveStartDestinationUseCase import dev.yaseyo.onboarding.ui.AuthViewModel import org.koin.core.module.dsl.factoryOf -import org.koin.core.module.dsl.viewModel +import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module val onboardingModule = module { factoryOf(::ResolveStartDestinationUseCase) - viewModel { AuthViewModel(get()) } + viewModelOf(::AuthViewModel) } diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreen.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreen.kt index 4165c15..06b5d0b 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreen.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreen.kt @@ -31,7 +31,7 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextFieldColors import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -45,161 +45,169 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.yaseyo.design.LocalSnackBarHostState import dev.yaseyo.design.YaseyoTheme import org.koin.compose.viewmodel.koinViewModel @Composable -fun AuthScreen(viewModel: AuthViewModel = koinViewModel()) { - val state by viewModel.uiState.collectAsState() +internal fun AuthScreen(viewModel: AuthViewModel = koinViewModel()) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val snackBarHostState = LocalSnackBarHostState.current + AuthContent(state = state, eventHandler = viewModel) + + LaunchedEffect(viewModel) { + viewModel.action.collect { + snackBarHostState.showSnackbar(it) + } + } } @Composable -fun AuthContent( +private fun AuthContent( state: AuthUiState, eventHandler: AuthEventHandler, ) { - YaseyoTheme { - val colors = YaseyoTheme.colors + val colors = YaseyoTheme.colors - Box( + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(colors.backgroundBase)), + ) { + Column( modifier = Modifier .fillMaxSize() - .background(Color(colors.backgroundBase)), + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 56.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp, vertical = 56.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppLogo(accentSubtle = Color(colors.accentSubtle)) - - Spacer(Modifier.height(16.dp)) - - Text( - text = "Yaseyo", - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - color = Color(colors.accentDefault), - ) - - Spacer(Modifier.height(8.dp)) - - Text( - text = "Cultivating a lighter, healthier you.", - fontSize = 15.sp, - color = Color(colors.contentSecondary), - ) - - Spacer(Modifier.height(32.dp)) - - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - colors = CardDefaults.cardColors(containerColor = Color(colors.backgroundElevated)), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), - ) { - Column(modifier = Modifier.padding(20.dp)) { - AuthToggle( - selectedTab = state.tab, - onTabSelected = eventHandler::onTabChanged, - ) + AppLogo(accentSubtle = Color(colors.accentSubtle)) - Spacer(Modifier.height(24.dp)) - - FieldLabel("EMAIL ADDRESS") - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = state.email, - onValueChange = eventHandler::onEmailChanged, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("hello@example.com", color = Color(colors.contentTertiary)) }, - leadingIcon = { - Icon(Icons.Default.Email, contentDescription = null, tint = Color(colors.contentTertiary)) - }, - shape = RoundedCornerShape(12.dp), - colors = fieldColors(), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), - ) + Spacer(Modifier.height(16.dp)) + + Text( + text = "Yaseyo", + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + color = Color(colors.accentDefault), + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = "Cultivating a lighter, healthier you.", + fontSize = 15.sp, + color = Color(colors.contentSecondary), + ) + + Spacer(Modifier.height(32.dp)) - Spacer(Modifier.height(16.dp)) - - FieldLabel("PASSWORD") - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = state.password, - onValueChange = eventHandler::onPasswordChanged, - modifier = Modifier.fillMaxWidth(), - leadingIcon = { - Icon(Icons.Default.Lock, contentDescription = null, tint = Color(colors.contentTertiary)) - }, - trailingIcon = { - IconButton(onClick = eventHandler::onPasswordVisibilityToggled) { - Icon( - imageVector = if (state.isPasswordVisible) { - Icons.Default.VisibilityOff - } else { - Icons.Default.Visibility - }, - contentDescription = if (state.isPasswordVisible) "Hide password" else "Show password", - tint = Color(colors.contentTertiary), - ) - } - }, - visualTransformation = if (state.isPasswordVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - shape = RoundedCornerShape(12.dp), - colors = fieldColors(), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = Color(colors.backgroundElevated)), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column(modifier = Modifier.padding(20.dp)) { + AuthToggle( + selectedTab = state.tab, + onTabSelected = eventHandler::onTabChanged, + ) + + Spacer(Modifier.height(24.dp)) + + FieldLabel("EMAIL ADDRESS") + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = state.email, + onValueChange = eventHandler::onEmailChanged, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("hello@example.com", color = Color(colors.contentTertiary)) }, + leadingIcon = { + Icon(Icons.Default.Email, contentDescription = null, tint = Color(colors.contentTertiary)) + }, + shape = RoundedCornerShape(12.dp), + colors = fieldColors(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + ) + + Spacer(Modifier.height(16.dp)) + + FieldLabel("PASSWORD") + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = state.password, + onValueChange = eventHandler::onPasswordChanged, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { + Icon(Icons.Default.Lock, contentDescription = null, tint = Color(colors.contentTertiary)) + }, + trailingIcon = { + IconButton(onClick = eventHandler::onPasswordVisibilityToggled) { + Icon( + imageVector = if (state.isPasswordVisible) { + Icons.Default.VisibilityOff + } else { + Icons.Default.Visibility + }, + contentDescription = if (state.isPasswordVisible) "Hide password" else "Show password", + tint = Color(colors.contentTertiary), + ) + } + }, + visualTransformation = if (state.isPasswordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + shape = RoundedCornerShape(12.dp), + colors = fieldColors(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + + Spacer(Modifier.height(8.dp)) + + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { + Text( + text = "Forgot Password?", + color = Color(colors.accentDefault), + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + modifier = Modifier.clickable { eventHandler.onForgotPasswordClicked() }, ) + } - Spacer(Modifier.height(8.dp)) - - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { - Text( - text = "Forgot Password?", - color = Color(colors.accentDefault), - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - modifier = Modifier.clickable { eventHandler.onForgotPasswordClicked() }, - ) - } - - Spacer(Modifier.height(24.dp)) - - Button( - onClick = eventHandler::onSubmitClicked, - modifier = Modifier.fillMaxWidth().height(56.dp), - shape = RoundedCornerShape(16.dp), - colors = ButtonDefaults.buttonColors(containerColor = Color(colors.accentDefault)), - enabled = !state.isLoading, - ) { - Text( - text = if (state.tab == AuthTab.SignIn) "Log In →" else "Sign Up →", - fontSize = 17.sp, - fontWeight = FontWeight.SemiBold, - color = Color(colors.contentOnAccent), - ) - } + Spacer(Modifier.height(24.dp)) + + Button( + onClick = eventHandler::onSubmitClicked, + modifier = Modifier.fillMaxWidth().height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(colors.accentDefault)), + enabled = !state.isLoading, + ) { + Text( + text = if (state.tab == AuthTab.SignIn) "Log In →" else "Sign Up →", + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = Color(colors.contentOnAccent), + ) } } + } - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(24.dp)) - Text( - text = "By continuing, you agree to Yaseyo's Terms of Service and Privacy Policy.", - fontSize = 12.sp, - color = Color(colors.contentTertiary), - textAlign = TextAlign.Center, - ) - } + Text( + text = "By continuing, you agree to Yaseyo's Terms of Service and Privacy Policy.", + fontSize = 12.sp, + color = Color(colors.contentTertiary), + textAlign = TextAlign.Center, + ) } } } diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthViewModel.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthViewModel.kt index d0a5168..074b20e 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthViewModel.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthViewModel.kt @@ -1,20 +1,29 @@ package dev.yaseyo.onboarding.ui import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.yaseyo.auth.api.AuthRepository import dev.yaseyo.navigation.AppRouter -import dev.yaseyo.navigation.Home +import dev.yaseyo.onboarding.navigation.OnboardingRoutes +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch -class AuthViewModel( +internal class AuthViewModel( private val router: AppRouter, + private val authRepository: AuthRepository, ) : ViewModel(), AuthEventHandler { private val _uiState = MutableStateFlow(AuthUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _action = Channel(capacity = Channel.UNLIMITED) + val action = _action.receiveAsFlow() + override fun onTabChanged(tab: AuthTab) = _uiState.update { it.copy(tab = tab) } override fun onEmailChanged(email: String) = _uiState.update { it.copy(email = email) } @@ -26,7 +35,26 @@ class AuthViewModel( override fun onForgotPasswordClicked() { /* TODO */ } override fun onSubmitClicked() { - // TODO: call auth use case, navigate on success - router.navigate(Home) + when (uiState.value.tab) { + AuthTab.SignIn -> { + viewModelScope.launch { + val signInResult = authRepository.signInWithEmailAndPassword( + email = uiState.value.email, + password = uiState.value.password, + ) + + signInResult.fold( + onSuccess = { + router.navigate(route = OnboardingRoutes.ProfileSetup) + }, + onFailure = { + _action.send("Something went wrong") + }, + ) + } + } + AuthTab.SignUp -> { + } + } } } diff --git a/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoTheme.kt b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoTheme.kt index 0819429..fa523f2 100644 --- a/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoTheme.kt +++ b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoTheme.kt @@ -1,22 +1,32 @@ package dev.yaseyo.design import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf private val LocalAppColors = staticCompositionLocalOf { error("No AppColors provided — wrap your UI in YaseyoTheme { }") } +val LocalSnackBarHostState = staticCompositionLocalOf { + error("No SnackBarHostState provided") +} + @Composable fun YaseyoTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit, ) { + val snackBarHostState = remember { + SnackbarHostState() + } CompositionLocalProvider( LocalAppColors provides if (darkTheme) DarkAppColors else LightAppColors, + LocalSnackBarHostState provides snackBarHostState, content = content, ) } From b6059147ad1189608e447cf0832718340ee53071 Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 11 May 2026 17:02:18 +0900 Subject: [PATCH 10/24] Implement sign-in logic in AuthViewModel and add Snackbar support --- feature/onboarding/build.gradle.kts | 2 +- .../yaseyo/onboarding/AuthViewControllers.kt | 4 ++-- libs/design/build.gradle.kts | 2 +- .../yaseyo/design/YaseyoAuthViewController.kt | 19 +++++++++++++++++++ 4 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 libs/design/src/iosMain/kotlin/dev/yaseyo/design/YaseyoAuthViewController.kt diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index 0941698..5422e5e 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } kotlin { - androidLibrary { + android { namespace = "dev.yaseyo.onboarding" withHostTestBuilder { 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 ef20b04..2b1a2d1 100644 --- a/feature/onboarding/src/iosMain/kotlin/dev/yaseyo/onboarding/AuthViewControllers.kt +++ b/feature/onboarding/src/iosMain/kotlin/dev/yaseyo/onboarding/AuthViewControllers.kt @@ -1,6 +1,6 @@ package dev.yaseyo.onboarding -import androidx.compose.ui.window.ComposeUIViewController +import dev.yaseyo.design.createYaseyoUiViewController import dev.yaseyo.onboarding.ui.AuthScreen -fun authScreenViewController() = ComposeUIViewController { AuthScreen() } +fun authScreenViewController() = createYaseyoUiViewController { AuthScreen() } diff --git a/libs/design/build.gradle.kts b/libs/design/build.gradle.kts index f0d4d01..93b9dee 100644 --- a/libs/design/build.gradle.kts +++ b/libs/design/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } kotlin { - androidLibrary { + android { namespace = "dev.yaseyo.design" } diff --git a/libs/design/src/iosMain/kotlin/dev/yaseyo/design/YaseyoAuthViewController.kt b/libs/design/src/iosMain/kotlin/dev/yaseyo/design/YaseyoAuthViewController.kt new file mode 100644 index 0000000..c6fbf54 --- /dev/null +++ b/libs/design/src/iosMain/kotlin/dev/yaseyo/design/YaseyoAuthViewController.kt @@ -0,0 +1,19 @@ +package dev.yaseyo.design + +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.ComposeUIViewController + +fun createYaseyoUiViewController(screen: @Composable () -> Unit) = + ComposeUIViewController { + YaseyoTheme { + Scaffold( + snackbarHost = { + SnackbarHost(hostState = LocalSnackBarHostState.current) + }, + ) { + screen() + } + } + } From 357133616ef4ab4f787687a962c7a624c6fb625c Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 11 May 2026 17:09:34 +0900 Subject: [PATCH 11/24] Implement email and password sign-up logic in AuthViewModel --- .../dev/yaseyo/onboarding/ui/AuthViewModel.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthViewModel.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthViewModel.kt index 074b20e..c84ffa5 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthViewModel.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthViewModel.kt @@ -54,6 +54,21 @@ internal class AuthViewModel( } } AuthTab.SignUp -> { + viewModelScope.launch { + val signUpResult = authRepository.signUpWithEmailAndPassword( + email = uiState.value.email, + password = uiState.value.password, + ) + + signUpResult.fold( + onSuccess = { + router.navigate(route = OnboardingRoutes.ProfileSetup) + }, + onFailure = { + _action.send("Something went wrong") + }, + ) + } } } } From 94b9834d2366627925fa6b29278b88855b4cf1cb Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 11 May 2026 20:00:52 +0900 Subject: [PATCH 12/24] Implement Profile Setup screen and onboarding steps flow --- feature/onboarding/build.gradle.kts | 2 + .../onboarding/OnboardingFeatureNavigation.kt | 4 + .../yaseyo/onboarding/di/OnboardingModule.kt | 6 + .../yaseyo/onboarding/model/OnboardingStep.kt | 7 + .../onboarding/network/OnboardingApi.kt | 5 + .../onboarding/network/OnboardingApiImpl.kt | 11 ++ .../onboarding/network/OnboardingStepNet.kt | 11 ++ .../profilesetup/ProfileSetupEventHandler.kt | 9 ++ .../ui/profilesetup/ProfileSetupScreen.kt | 138 ++++++++++++++++++ .../ui/profilesetup/ProfileSetupUiState.kt | 19 +++ .../ui/profilesetup/ProfileSetupViewModel.kt | 66 +++++++++ .../components/OnboardingProgressBar.kt | 58 ++++++++ .../ui/profilesetup/steps/NameStep.kt | 78 ++++++++++ gradle/libs.versions.toml | 2 + 14 files changed, 416 insertions(+) create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/OnboardingStep.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingApi.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingApiImpl.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingStepNet.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupEventHandler.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupScreen.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupUiState.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupViewModel.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/components/OnboardingProgressBar.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/steps/NameStep.kt diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index 5422e5e..08d597e 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -27,6 +27,7 @@ kotlin { implementation(libs.koin.compose) implementation(libs.koin.compose.viewmodel) api(libs.kotlinx.coroutines.core) + implementation(libs.ktor.client.core) implementation(projects.libs.auth) implementation(projects.libs.user) @@ -39,6 +40,7 @@ kotlin { implementation(libs.compose.material3) implementation(libs.compose.uiToolingPreview) implementation(libs.compose.material.icons.extended) + implementation(libs.compose.navigationevent) } } diff --git a/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt index 7283f5d..4ea3794 100644 --- a/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt +++ b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt @@ -5,11 +5,15 @@ import dev.yaseyo.navigation.AppRoute import dev.yaseyo.navigation.FeatureNavigation import dev.yaseyo.onboarding.navigation.OnboardingRoutes import dev.yaseyo.onboarding.ui.AuthScreen +import dev.yaseyo.onboarding.ui.profilesetup.ProfileSetupScreen internal class OnboardingFeatureNavigation : FeatureNavigation { override val navEntryProvider: EntryProviderScope.() -> Unit = { entry { AuthScreen() } + entry { + ProfileSetupScreen() + } } } 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 f18e63e..77b141e 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,7 +1,10 @@ package dev.yaseyo.onboarding.di import dev.yaseyo.onboarding.ResolveStartDestinationUseCase +import dev.yaseyo.onboarding.network.OnboardingApi +import dev.yaseyo.onboarding.network.OnboardingApiImpl import dev.yaseyo.onboarding.ui.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.module @@ -9,4 +12,7 @@ import org.koin.dsl.module val onboardingModule = module { factoryOf(::ResolveStartDestinationUseCase) viewModelOf(::AuthViewModel) + + single { OnboardingApiImpl(client = get()) } + viewModelOf(::ProfileSetupViewModel) } diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/OnboardingStep.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/OnboardingStep.kt new file mode 100644 index 0000000..f988f71 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/model/OnboardingStep.kt @@ -0,0 +1,7 @@ +package dev.yaseyo.onboarding.model + +internal data class OnboardingStep( + val key: String, + val title: String, + val subtitle: String?, +) 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 new file mode 100644 index 0000000..02e3340 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingApi.kt @@ -0,0 +1,5 @@ +package dev.yaseyo.onboarding.network + +internal interface OnboardingApi { + suspend fun getSteps(): List +} diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingApiImpl.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingApiImpl.kt new file mode 100644 index 0000000..83fcae2 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingApiImpl.kt @@ -0,0 +1,11 @@ +package dev.yaseyo.onboarding.network + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get + +internal class OnboardingApiImpl( + private val client: HttpClient, +) : OnboardingApi { + override suspend fun getSteps(): List = client.get("/onboarding/steps").body() +} diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingStepNet.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingStepNet.kt new file mode 100644 index 0000000..5c09763 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/network/OnboardingStepNet.kt @@ -0,0 +1,11 @@ +package dev.yaseyo.onboarding.network + +import kotlinx.serialization.Serializable + +@Serializable +internal data class OnboardingStepNet( + val key: String, + val type: String, + val title: String, + val subtitle: String? = null, +) 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 new file mode 100644 index 0000000..eac7721 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupEventHandler.kt @@ -0,0 +1,9 @@ +package dev.yaseyo.onboarding.ui.profilesetup + +internal interface ProfileSetupEventHandler { + fun onNameChanged(name: String) + + 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 new file mode 100644 index 0000000..2f5ffa0 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupScreen.kt @@ -0,0 +1,138 @@ +package dev.yaseyo.onboarding.ui.profilesetup + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState +import dev.yaseyo.design.YaseyoTheme +import dev.yaseyo.onboarding.ui.profilesetup.components.OnboardingProgressBar +import dev.yaseyo.onboarding.ui.profilesetup.steps.NameStep +import org.koin.compose.viewmodel.koinViewModel + +@Composable +internal fun ProfileSetupScreen(viewModel: ProfileSetupViewModel = koinViewModel()) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val navState = rememberNavigationEventState(NavigationEventInfo.None) + + NavigationBackHandler( + state = navState, + isBackEnabled = state.currentStepIndex > 0, + ) { + viewModel.onBack() + } + + ProfileSetupContent(state = state, eventHandler = viewModel) +} + +@Composable +private fun ProfileSetupContent( + state: ProfileSetupUiState, + eventHandler: ProfileSetupEventHandler, +) { + val colors = YaseyoTheme.colors + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(colors.backgroundBase)), + ) { + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + IconButton(onClick = eventHandler::onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = Color(colors.contentPrimary), + ) + } + Text( + text = "Yaseyo", + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = Color(colors.accentDefault), + ) + // Balance the row so title stays centered + Spacer(Modifier.size(48.dp)) + } + + OnboardingProgressBar( + currentStep = state.currentStepIndex + 1, + totalSteps = state.totalSteps, + modifier = Modifier.padding(horizontal = 24.dp), + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 24.dp, vertical = 32.dp), + ) { + when (state.currentStepIndex) { + 0 -> NameStep( + name = state.name, + title = state.currentStep?.title ?: "What should we call you?", + subtitle = state.currentStep?.subtitle, + onNameChanged = eventHandler::onNameChanged, + ) + } + } + + Button( + onClick = eventHandler::onContinue, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp) + .height(56.dp), + shape = RoundedCornerShape(50.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(colors.accentDefault), + disabledContainerColor = Color(colors.accentSubtle), + ), + enabled = when (state.currentStepIndex) { + 0 -> state.name.isNotBlank() + else -> true + }, + ) { + Text( + text = "CONTINUE →", + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.sp, + color = Color(colors.contentOnAccent), + ) + } + } + } +} 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 new file mode 100644 index 0000000..3a70541 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupUiState.kt @@ -0,0 +1,19 @@ +package dev.yaseyo.onboarding.ui.profilesetup + +import dev.yaseyo.onboarding.model.OnboardingStep + +internal data class ProfileSetupUiState( + val steps: List = emptyList(), + val isLoadingSteps: Boolean = true, + val currentStepIndex: Int = 0, + val name: String = "", +) + +internal val ProfileSetupUiState.totalSteps: Int + get() = steps.size.takeIf { it > 0 } ?: 6 + +internal val ProfileSetupUiState.currentStep: OnboardingStep? + get() = steps.getOrNull(currentStepIndex) + +internal val ProfileSetupUiState.progressFraction: Float + get() = (currentStepIndex + 1).toFloat() / totalSteps 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 new file mode 100644 index 0000000..e81db4b --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupViewModel.kt @@ -0,0 +1,66 @@ +package dev.yaseyo.onboarding.ui.profilesetup + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.yaseyo.navigation.AppRouter +import dev.yaseyo.onboarding.model.OnboardingStep +import dev.yaseyo.onboarding.network.OnboardingApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal class ProfileSetupViewModel( + private val onboardingApi: OnboardingApi, + private val router: AppRouter, +) : ViewModel(), + ProfileSetupEventHandler { + private val _uiState = MutableStateFlow(ProfileSetupUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + fetchSteps() + } + + 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) }, + isLoadingSteps = false, + ) + } + }.onFailure { + _uiState.update { it.copy(isLoadingSteps = false) } + } + } + } + + override fun onNameChanged(name: String) = _uiState.update { it.copy(name = name) } + + override fun onContinue() { + val state = _uiState.value + val nextIndex = state.currentStepIndex + 1 + if (nextIndex < state.totalSteps) { + _uiState.update { it.copy(currentStepIndex = nextIndex) } + } else { + submitProfile() + } + } + + override fun onBack() { + val currentIndex = _uiState.value.currentStepIndex + if (currentIndex == 0) { + router.goBack() + } else { + _uiState.update { it.copy(currentStepIndex = currentIndex - 1) } + } + } + + private fun submitProfile() { + // TODO: POST /user + POST /weight — wired up on the final step + } +} diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/components/OnboardingProgressBar.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/components/OnboardingProgressBar.kt new file mode 100644 index 0000000..8a8a2e7 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/components/OnboardingProgressBar.kt @@ -0,0 +1,58 @@ +package dev.yaseyo.onboarding.ui.profilesetup.components + +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.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.yaseyo.design.YaseyoTheme + +@Composable +internal fun OnboardingProgressBar( + currentStep: Int, + totalSteps: Int, + 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 = Color(colors.accentDefault), + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + modifier = Modifier.weight(1f), + ) + Text( + text = "$percentage%", + color = Color(colors.contentSecondary), + fontSize = 13.sp, + ) + } + Spacer(Modifier.height(6.dp)) + LinearProgressIndicator( + progress = { currentStep.toFloat() / totalSteps }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp), + color = Color(colors.accentDefault), + trackColor = Color(colors.backgroundSubtle), + strokeCap = StrokeCap.Round, + ) + } +} diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/steps/NameStep.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/steps/NameStep.kt new file mode 100644 index 0000000..11ba96a --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/steps/NameStep.kt @@ -0,0 +1,78 @@ +package dev.yaseyo.onboarding.ui.profilesetup.steps + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.yaseyo.design.YaseyoTheme + +@Composable +internal fun NameStep( + name: String, + title: String, + subtitle: String?, + onNameChanged: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val colors = YaseyoTheme.colors + + Column(modifier = modifier) { + Text( + text = title, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = Color(colors.contentPrimary), + lineHeight = 34.sp, + ) + + if (subtitle != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = subtitle, + fontSize = 15.sp, + color = Color(colors.contentSecondary), + ) + } + + Spacer(Modifier.height(32.dp)) + + OutlinedTextField( + value = name, + onValueChange = onNameChanged, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = "Your name", + color = Color(colors.contentTertiary), + fontSize = 17.sp, + ) + }, + textStyle = TextStyle( + fontSize = 17.sp, + color = Color(colors.contentPrimary), + ), + shape = RoundedCornerShape(16.dp), + colors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color(colors.borderDefault), + focusedBorderColor = Color(colors.accentDefault), + unfocusedTextColor = Color(colors.contentPrimary), + focusedTextColor = Color(colors.contentPrimary), + ), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 59a92e4..099ee7c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ kotlinx-datetime = "0.8.0" nav3Core = "1.1.1" lifecycleViewmodelNav3 = "2.11.0-beta01" ui-tooling = "1.11.1" +composeNavigationEvent = "1.0.1" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -83,6 +84,7 @@ androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runt androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "ui-tooling" } +compose-navigationevent = { module = "org.jetbrains.androidx.navigationevent:navigationevent-compose", version.ref = "composeNavigationEvent" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } From f3caf11ee6a6247c08687d58e9a678da8830000d Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 11 May 2026 20:09:54 +0900 Subject: [PATCH 13/24] Add Compose previews for onboarding UI components and screens --- .../ui/profilesetup/ProfileSetupScreen.kt | 50 +++++++++++++++++++ .../components/OnboardingProgressBar.kt | 25 ++++++++++ .../ui/profilesetup/steps/NameStep.kt | 40 +++++++++++++++ 3 files changed, 115 insertions(+) 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 2f5ffa0..9434b56 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 @@ -25,6 +25,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -32,6 +33,7 @@ import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState import dev.yaseyo.design.YaseyoTheme +import dev.yaseyo.onboarding.model.OnboardingStep import dev.yaseyo.onboarding.ui.profilesetup.components.OnboardingProgressBar import dev.yaseyo.onboarding.ui.profilesetup.steps.NameStep import org.koin.compose.viewmodel.koinViewModel @@ -136,3 +138,51 @@ 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.", + ), +) + +private val previewState = ProfileSetupUiState( + steps = previewSteps, + isLoadingSteps = false, + currentStepIndex = 0, + name = "", +) + +private val previewHandler = object : ProfileSetupEventHandler { + override fun onNameChanged(name: String) {} + + override fun onContinue() {} + + override fun onBack() {} +} + +@Preview(showBackground = true) +@Composable +private fun ProfileSetupNameLightPreview() { + YaseyoTheme(darkTheme = false) { + ProfileSetupContent(state = previewState, eventHandler = previewHandler) + } +} + +@Preview(showBackground = true) +@Composable +private fun ProfileSetupNameDarkPreview() { + YaseyoTheme(darkTheme = true) { + ProfileSetupContent(state = previewState, eventHandler = previewHandler) + } +} + +@Preview(showBackground = true) +@Composable +private fun ProfileSetupNameFilledPreview() { + YaseyoTheme(darkTheme = false) { + ProfileSetupContent(state = previewState.copy(name = "Taki"), eventHandler = previewHandler) + } +} diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/components/OnboardingProgressBar.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/components/OnboardingProgressBar.kt index 8a8a2e7..d9fe765 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/components/OnboardingProgressBar.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/components/OnboardingProgressBar.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap 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 @@ -56,3 +57,27 @@ internal fun OnboardingProgressBar( ) } } + +@Preview(showBackground = true) +@Composable +private fun OnboardingProgressBarStep1Preview() { + YaseyoTheme(darkTheme = false) { + OnboardingProgressBar(currentStep = 1, totalSteps = 6) + } +} + +@Preview(showBackground = true) +@Composable +private fun OnboardingProgressBarStep4Preview() { + YaseyoTheme(darkTheme = false) { + OnboardingProgressBar(currentStep = 4, totalSteps = 6) + } +} + +@Preview(showBackground = true) +@Composable +private fun OnboardingProgressBarDarkPreview() { + YaseyoTheme(darkTheme = true) { + OnboardingProgressBar(currentStep = 5, totalSteps = 6) + } +} diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/steps/NameStep.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/steps/NameStep.kt index 11ba96a..7ec42a0 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/steps/NameStep.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/steps/NameStep.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight 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.YaseyoTheme @@ -76,3 +77,42 @@ internal fun NameStep( ) } } + +@Preview(showBackground = true) +@Composable +private fun NameStepEmptyLightPreview() { + YaseyoTheme(darkTheme = false) { + NameStep( + name = "", + title = "What should we call you?", + subtitle = "We'll use this to personalize your experience.", + onNameChanged = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun NameStepFilledPreview() { + YaseyoTheme(darkTheme = false) { + NameStep( + name = "Taki", + title = "What should we call you?", + subtitle = "We'll use this to personalize your experience.", + onNameChanged = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun NameStepDarkPreview() { + YaseyoTheme(darkTheme = true) { + NameStep( + name = "", + title = "What should we call you?", + subtitle = "We'll use this to personalize your experience.", + onNameChanged = {}, + ) + } +} From 3edb71dfe2bea5f9a7e845c1fe3ece8021d602e5 Mon Sep 17 00:00:00 2001 From: Taki Date: Mon, 11 May 2026 20:10:44 +0900 Subject: [PATCH 14/24] Move Auth UI components to auth package and reorganize profile setup widgets --- .../dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt | 2 +- .../kotlin/dev/yaseyo/onboarding/di/OnboardingModule.kt | 2 +- .../dev/yaseyo/onboarding/ui/{ => auth}/AuthEventHandler.kt | 2 +- .../kotlin/dev/yaseyo/onboarding/ui/{ => auth}/AuthScreen.kt | 2 +- .../kotlin/dev/yaseyo/onboarding/ui/{ => auth}/AuthUiState.kt | 2 +- .../dev/yaseyo/onboarding/ui/{ => auth}/AuthViewModel.kt | 2 +- .../yaseyo/onboarding/ui/profilesetup/ProfileSetupScreen.kt | 4 ++-- .../onboarding/ui/profilesetup/{steps => widgets}/NameStep.kt | 2 +- .../{components => widgets}/OnboardingProgressBar.kt | 2 +- .../kotlin/dev/yaseyo/onboarding/AuthViewControllers.kt | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) rename feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/{ => auth}/AuthEventHandler.kt (86%) rename feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/{ => auth}/AuthScreen.kt (99%) rename feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/{ => auth}/AuthUiState.kt (86%) rename feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/{ => auth}/AuthViewModel.kt (98%) rename feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/{steps => widgets}/NameStep.kt (98%) rename feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/{components => widgets}/OnboardingProgressBar.kt (97%) diff --git a/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt index 4ea3794..d486ffd 100644 --- a/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt +++ b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt @@ -4,7 +4,7 @@ import androidx.navigation3.runtime.EntryProviderScope import dev.yaseyo.navigation.AppRoute import dev.yaseyo.navigation.FeatureNavigation import dev.yaseyo.onboarding.navigation.OnboardingRoutes -import dev.yaseyo.onboarding.ui.AuthScreen +import dev.yaseyo.onboarding.ui.auth.AuthScreen import dev.yaseyo.onboarding.ui.profilesetup.ProfileSetupScreen internal class OnboardingFeatureNavigation : FeatureNavigation { 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 77b141e..5802678 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 @@ -3,7 +3,7 @@ package dev.yaseyo.onboarding.di import dev.yaseyo.onboarding.ResolveStartDestinationUseCase import dev.yaseyo.onboarding.network.OnboardingApi import dev.yaseyo.onboarding.network.OnboardingApiImpl -import dev.yaseyo.onboarding.ui.AuthViewModel +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 diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthEventHandler.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthEventHandler.kt similarity index 86% rename from feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthEventHandler.kt rename to feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthEventHandler.kt index e246a57..8915f05 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthEventHandler.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthEventHandler.kt @@ -1,4 +1,4 @@ -package dev.yaseyo.onboarding.ui +package dev.yaseyo.onboarding.ui.auth interface AuthEventHandler { fun onTabChanged(tab: AuthTab) diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreen.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthScreen.kt similarity index 99% rename from feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreen.kt rename to feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthScreen.kt index 06b5d0b..8d63f8f 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthScreen.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthScreen.kt @@ -1,4 +1,4 @@ -package dev.yaseyo.onboarding.ui +package dev.yaseyo.onboarding.ui.auth import androidx.compose.foundation.background import androidx.compose.foundation.clickable diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthUiState.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthUiState.kt similarity index 86% rename from feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthUiState.kt rename to feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthUiState.kt index 4bf5547..89fce13 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthUiState.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthUiState.kt @@ -1,4 +1,4 @@ -package dev.yaseyo.onboarding.ui +package dev.yaseyo.onboarding.ui.auth enum class AuthTab { SignIn, SignUp } diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthViewModel.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthViewModel.kt similarity index 98% rename from feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthViewModel.kt rename to feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthViewModel.kt index c84ffa5..7569929 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/AuthViewModel.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthViewModel.kt @@ -1,4 +1,4 @@ -package dev.yaseyo.onboarding.ui +package dev.yaseyo.onboarding.ui.auth import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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 9434b56..c426c51 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupScreen.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupScreen.kt @@ -34,8 +34,8 @@ import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState import dev.yaseyo.design.YaseyoTheme import dev.yaseyo.onboarding.model.OnboardingStep -import dev.yaseyo.onboarding.ui.profilesetup.components.OnboardingProgressBar -import dev.yaseyo.onboarding.ui.profilesetup.steps.NameStep +import dev.yaseyo.onboarding.ui.profilesetup.widgets.NameStep +import dev.yaseyo.onboarding.ui.profilesetup.widgets.OnboardingProgressBar import org.koin.compose.viewmodel.koinViewModel @Composable diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/steps/NameStep.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/NameStep.kt similarity index 98% rename from feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/steps/NameStep.kt rename to feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/NameStep.kt index 7ec42a0..0d1c671 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/steps/NameStep.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/NameStep.kt @@ -1,4 +1,4 @@ -package dev.yaseyo.onboarding.ui.profilesetup.steps +package dev.yaseyo.onboarding.ui.profilesetup.widgets import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/components/OnboardingProgressBar.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/OnboardingProgressBar.kt similarity index 97% rename from feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/components/OnboardingProgressBar.kt rename to feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/OnboardingProgressBar.kt index d9fe765..fd5679f 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/components/OnboardingProgressBar.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/OnboardingProgressBar.kt @@ -1,4 +1,4 @@ -package dev.yaseyo.onboarding.ui.profilesetup.components +package dev.yaseyo.onboarding.ui.profilesetup.widgets import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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 2b1a2d1..1e685c4 100644 --- a/feature/onboarding/src/iosMain/kotlin/dev/yaseyo/onboarding/AuthViewControllers.kt +++ b/feature/onboarding/src/iosMain/kotlin/dev/yaseyo/onboarding/AuthViewControllers.kt @@ -1,6 +1,6 @@ package dev.yaseyo.onboarding import dev.yaseyo.design.createYaseyoUiViewController -import dev.yaseyo.onboarding.ui.AuthScreen +import dev.yaseyo.onboarding.ui.auth.AuthScreen fun authScreenViewController() = createYaseyoUiViewController { AuthScreen() } From 31b62923ec8e0d710a8d65d26b7bf437647e74e2 Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 12 May 2026 01:31:11 +0900 Subject: [PATCH 15/24] Implement "About You" onboarding step with custom wheel pickers - Update iOS project configuration with development team ID - Add `WheelPicker` and `WheelDatePicker` components for date selection - Add `SexSelectionRow` and `SexCard` widgets for biological sex selection - Implement `AboutYouStep` UI for onboarding flow - Update `ProfileSetupUiState`, `ViewModel`, and `EventHandler` to manage sex and date of birth state - Integrate the "About You" step into `ProfileSetupScreen` with validation logic --- .../profilesetup/ProfileSetupEventHandler.kt | 12 ++ .../ui/profilesetup/ProfileSetupScreen.kt | 22 +++ .../ui/profilesetup/ProfileSetupUiState.kt | 26 ++++ .../ui/profilesetup/ProfileSetupViewModel.kt | 9 ++ .../ui/profilesetup/widgets/AboutYouStep.kt | 146 ++++++++++++++++++ .../ui/profilesetup/widgets/SexCard.kt | 122 +++++++++++++++ .../profilesetup/widgets/WheelDatePicker.kt | 107 +++++++++++++ .../ui/profilesetup/widgets/WheelPicker.kt | 103 ++++++++++++ iosApp/iosApp.xcodeproj/project.pbxproj | 4 +- 9 files changed, 549 insertions(+), 2 deletions(-) create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/AboutYouStep.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/SexCard.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/WheelDatePicker.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/WheelPicker.kt diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupEventHandler.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupEventHandler.kt index eac7721..60bf42d 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,8 +1,20 @@ package dev.yaseyo.onboarding.ui.profilesetup +import dev.yaseyo.user.api.Sex + internal interface ProfileSetupEventHandler { + // Step 1 fun onNameChanged(name: String) + // Step 2 + fun onSexChanged(sex: Sex) + + fun onDobMonthChanged(month: Int) + + fun onDobDayChanged(day: Int) + + fun onDobYearChanged(year: 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 c426c51..eac702e 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupScreen.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupScreen.kt @@ -34,6 +34,7 @@ import androidx.navigationevent.compose.NavigationBackHandler import androidx.navigationevent.compose.rememberNavigationEventState import dev.yaseyo.design.YaseyoTheme import dev.yaseyo.onboarding.model.OnboardingStep +import dev.yaseyo.onboarding.ui.profilesetup.widgets.AboutYouStep import dev.yaseyo.onboarding.ui.profilesetup.widgets.NameStep import dev.yaseyo.onboarding.ui.profilesetup.widgets.OnboardingProgressBar import org.koin.compose.viewmodel.koinViewModel @@ -108,6 +109,18 @@ private fun ProfileSetupContent( subtitle = state.currentStep?.subtitle, onNameChanged = eventHandler::onNameChanged, ) + 1 -> AboutYouStep( + sex = state.sex, + dobMonth = state.dobMonth, + dobDay = state.dobDay, + dobYear = state.dobYear, + title = state.currentStep?.title ?: "Tell us about yourself", + subtitle = state.currentStep?.subtitle, + onSexChanged = eventHandler::onSexChanged, + onMonthChanged = eventHandler::onDobMonthChanged, + onDayChanged = eventHandler::onDobDayChanged, + onYearChanged = eventHandler::onDobYearChanged, + ) } } @@ -124,6 +137,7 @@ private fun ProfileSetupContent( ), enabled = when (state.currentStepIndex) { 0 -> state.name.isNotBlank() + 1 -> state.sex != null else -> true }, ) { @@ -158,6 +172,14 @@ private val previewState = ProfileSetupUiState( private val previewHandler = object : ProfileSetupEventHandler { override fun onNameChanged(name: String) {} + override fun onSexChanged(sex: dev.yaseyo.user.api.Sex) {} + + override fun onDobMonthChanged(month: Int) {} + + override fun onDobDayChanged(day: Int) {} + + override fun onDobYearChanged(year: Int) {} + override fun onContinue() {} override fun onBack() {} diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupUiState.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupUiState.kt index 3a70541..0767674 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,14 +1,40 @@ package dev.yaseyo.onboarding.ui.profilesetup import dev.yaseyo.onboarding.model.OnboardingStep +import dev.yaseyo.user.api.Sex +import kotlinx.datetime.LocalDate internal data class ProfileSetupUiState( val steps: List = emptyList(), val isLoadingSteps: Boolean = true, val currentStepIndex: Int = 0, + // Step 1 — name val name: String = "", + // Step 2 — about you + val sex: Sex? = null, + val dobMonth: Int = 1, + val dobDay: Int = 1, + val dobYear: Int = 1990, ) +internal fun daysInMonth( + month: Int, + year: Int, +): Int = + when (month) { + 1, 3, 5, 7, 8, 10, 12 -> 31 + 4, 6, 9, 11 -> 30 + 2 -> if (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) 29 else 28 + else -> 31 + } + +internal fun ProfileSetupUiState.dateOfBirth(): LocalDate = + LocalDate( + year = dobYear, + month = dobMonth, + day = dobDay.coerceAtMost(daysInMonth(dobMonth, dobYear)), + ) + internal val ProfileSetupUiState.totalSteps: Int get() = steps.size.takeIf { it > 0 } ?: 6 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 e81db4b..d70abbf 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupViewModel.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import dev.yaseyo.navigation.AppRouter import dev.yaseyo.onboarding.model.OnboardingStep import dev.yaseyo.onboarding.network.OnboardingApi +import dev.yaseyo.user.api.Sex import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -41,6 +42,14 @@ internal class ProfileSetupViewModel( override fun onNameChanged(name: String) = _uiState.update { it.copy(name = name) } + override fun onSexChanged(sex: Sex) = _uiState.update { it.copy(sex = sex) } + + override fun onDobMonthChanged(month: Int) = _uiState.update { it.copy(dobMonth = month) } + + override fun onDobDayChanged(day: Int) = _uiState.update { it.copy(dobDay = day) } + + override fun onDobYearChanged(year: Int) = _uiState.update { it.copy(dobYear = year) } + override fun onContinue() { val state = _uiState.value val nextIndex = state.currentStepIndex + 1 diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/AboutYouStep.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/AboutYouStep.kt new file mode 100644 index 0000000..dbd9fdf --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/AboutYouStep.kt @@ -0,0 +1,146 @@ +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.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.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 = Color(colors.contentPrimary), + ) + + if (subtitle != null) { + Spacer(Modifier.height(6.dp)) + Text( + text = subtitle, + fontSize = 15.sp, + color = 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 = 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/SexCard.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/SexCard.kt new file mode 100644 index 0000000..3d9e34b --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/SexCard.kt @@ -0,0 +1,122 @@ +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.layout.size +import androidx.compose.foundation.shape.CircleShape +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.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.YaseyoTheme +import dev.yaseyo.user.api.Sex + +@Composable +internal fun SexSelectionRow( + selected: Sex?, + onSelected: (Sex) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SexCard( + label = "Male", + symbol = "♂", + selected = selected == Sex.MALE, + onClick = { onSelected(Sex.MALE) }, + modifier = Modifier.weight(1f), + ) + SexCard( + label = "Female", + symbol = "♀", + selected = selected == Sex.FEMALE, + onClick = { onSelected(Sex.FEMALE) }, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun SexCard( + label: String, + symbol: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = YaseyoTheme.colors + val iconBg = if (selected) Color(colors.accentDefault) else Color(colors.backgroundSubtle) + val iconTint = if (selected) Color(colors.contentOnAccent) else Color(colors.contentSecondary) + val labelColor = if (selected) Color(colors.accentDefault) else Color(colors.contentPrimary) + val borderColor = if (selected) Color(colors.accentDefault) else Color.Transparent + val cardBg = if (selected) Color(colors.accentSubtle) else Color(colors.backgroundElevated) + + Column( + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .background(cardBg) + .border(width = 1.5.dp, color = borderColor, shape = RoundedCornerShape(16.dp)) + .clickable(onClick = onClick) + .padding(vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(52.dp) + .clip(CircleShape) + .background(iconBg), + contentAlignment = Alignment.Center, + ) { + Text(text = symbol, fontSize = 24.sp, color = iconTint) + } + Spacer(Modifier.height(12.dp)) + Text( + text = label, + fontWeight = FontWeight.SemiBold, + fontSize = 15.sp, + color = labelColor, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun SexSelectionNonePreview() { + YaseyoTheme(darkTheme = false) { + SexSelectionRow(selected = null, onSelected = {}) + } +} + +@Preview(showBackground = true) +@Composable +private fun SexSelectionFemalePreview() { + YaseyoTheme(darkTheme = false) { + SexSelectionRow(selected = Sex.FEMALE, onSelected = {}) + } +} + +@Preview(showBackground = true) +@Composable +private fun SexSelectionDarkPreview() { + YaseyoTheme(darkTheme = true) { + SexSelectionRow(selected = Sex.MALE, onSelected = {}) + } +} diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/WheelDatePicker.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/WheelDatePicker.kt new file mode 100644 index 0000000..9b5d5d4 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/WheelDatePicker.kt @@ -0,0 +1,107 @@ +package dev.yaseyo.onboarding.ui.profilesetup.widgets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import dev.yaseyo.design.YaseyoTheme +import dev.yaseyo.onboarding.ui.profilesetup.daysInMonth + +private val months = listOf( + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +) + +// Reasonable birth year range: age 13–100 +private val years = (1926..2013).map { it.toString() } + +@Composable +internal fun WheelDatePicker( + month: Int, + day: Int, + year: Int, + onMonthChanged: (Int) -> Unit, + onDayChanged: (Int) -> Unit, + onYearChanged: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val colors = YaseyoTheme.colors + + // Day list adjusts to the selected month/year + val days = remember(month, year) { + (1..daysInMonth(month, year)).map { it.toString() } + } + + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Color(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), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun WheelDatePickerPreview() { + YaseyoTheme(darkTheme = false) { + WheelDatePicker( + month = 2, + day = 14, + year = 1994, + onMonthChanged = {}, + onDayChanged = {}, + onYearChanged = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun WheelDatePickerDarkPreview() { + YaseyoTheme(darkTheme = true) { + WheelDatePicker( + month = 6, + day = 1, + year = 1990, + onMonthChanged = {}, + onDayChanged = {}, + onYearChanged = {}, + ) + } +} 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 new file mode 100644 index 0000000..3a86714 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/WheelPicker.kt @@ -0,0 +1,103 @@ +package dev.yaseyo.onboarding.ui.profilesetup.widgets + +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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 +import dev.yaseyo.design.YaseyoTheme +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter + +private val ItemHeight: Dp = 44.dp + +/** + * Drum-style scroll picker that snaps to items. + * + * One empty sentinel is added at each end so the first and last real items can + * be centered. The centered item index (0-based) is reported via [onIndexChanged] + * after the user lifts their finger. + */ +@Composable +internal fun WheelPicker( + items: List, + selectedIndex: Int, + onIndexChanged: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val colors = YaseyoTheme.colors + val paddedItems = remember(items) { listOf("") + items + listOf("") } + + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = selectedIndex, + ) + val snapBehavior = rememberSnapFlingBehavior(listState) + + LaunchedEffect(listState) { + snapshotFlow { listState.isScrollInProgress } + .drop(1) // skip the initial false value before any scroll + .filter { !it } + .collect { + val centeredIndex = listState.firstVisibleItemIndex + if (centeredIndex in items.indices) { + onIndexChanged(centeredIndex) + } + } + } + + Box(modifier = modifier.height(ItemHeight * 3)) { + LazyColumn( + state = listState, + flingBehavior = snapBehavior, + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + itemsIndexed(paddedItems) { index, item -> + val isSelected = index == listState.firstVisibleItemIndex + 1 + Box( + modifier = Modifier + .fillMaxWidth() + .height(ItemHeight), + contentAlignment = Alignment.Center, + ) { + Text( + text = item, + fontSize = if (isSelected) 18.sp else 14.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) Color(colors.accentDefault) else Color(colors.contentTertiary), + ) + } + } + } + + // Thin lines framing the selected center slot + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .offset(y = ItemHeight), + color = Color(colors.borderDefault), + ) + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .offset(y = ItemHeight * 2), + color = Color(colors.borderDefault), + ) + } +} diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index f54bcf3..9ae65d1 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -319,7 +319,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; + DEVELOPMENT_TEAM = 52Y6JM9XQ5; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; @@ -347,7 +347,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; + DEVELOPMENT_TEAM = 52Y6JM9XQ5; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; From 81d32fb2db7aee65eb7dcb0697422a6c1f083dbd Mon Sep 17 00:00:00 2001 From: Taki Date: Thu, 14 May 2026 23:03:36 +0900 Subject: [PATCH 16/24] Add YaseyoPreview to design module and update composeMultiplatform version --- gradle/libs.versions.toml | 2 +- .../kotlin/dev/yaseyo/design/YaseyoPreview.kt | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 099ee7c..267aaf9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ androidx-core = "1.18.0" androidx-espresso = "3.7.0" androidx-lifecycle = "2.10.0" androidx-testExt = "1.3.0" -composeMultiplatform = "1.10.3" +composeMultiplatform = "1.11.0" junit = "4.13.2" kotlin = "2.3.20" material3 = "1.10.0-alpha05" diff --git a/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt new file mode 100644 index 0000000..ea13981 --- /dev/null +++ b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt @@ -0,0 +1,26 @@ +package dev.yaseyo.design + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +@Composable +fun YaseyoPreview( + darkTheme: Boolean = false, + composable: @Composable () -> Unit, +) { + YaseyoTheme { + val colors = YaseyoTheme.colors + Scaffold( + containerColor = Color(colors.backgroundBase), + ) { + Box(modifier = Modifier.padding(it).fillMaxSize()) { + composable() + } + } + } +} From 1a2cb41bab0c56025141e5a7ac368d62f7c34007 Mon Sep 17 00:00:00 2001 From: Taki Date: Thu, 14 May 2026 23:06:59 +0900 Subject: [PATCH 17/24] Update AppColors to use Compose Color type and remove redundant wrapping - Refactor `AppColors` data class to use `androidx.compose.ui.graphics.Color` instead of `Long`. - Update `LightAppColors` and `DarkAppColors` definitions to instantiate `Color` objects directly. - Remove redundant `Color()` wrapping calls across various UI components in the onboarding feature and design library. --- .../yaseyo/onboarding/ui/auth/AuthScreen.kt | 42 +++--- .../ui/profilesetup/ProfileSetupScreen.kt | 13 +- .../ui/profilesetup/widgets/AboutYouStep.kt | 7 +- .../ui/profilesetup/widgets/NameStep.kt | 17 ++- .../widgets/OnboardingProgressBar.kt | 9 +- .../ui/profilesetup/widgets/SexCard.kt | 10 +- .../profilesetup/widgets/WheelDatePicker.kt | 3 +- .../ui/profilesetup/widgets/WheelPicker.kt | 7 +- .../kotlin/dev/yaseyo/design/AppColors.kt | 134 +++++++++--------- .../kotlin/dev/yaseyo/design/YaseyoPreview.kt | 3 +- 10 files changed, 120 insertions(+), 125 deletions(-) diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthScreen.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthScreen.kt index 8d63f8f..524e263 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthScreen.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthScreen.kt @@ -74,7 +74,7 @@ private fun AuthContent( Box( modifier = Modifier .fillMaxSize() - .background(Color(colors.backgroundBase)), + .background(colors.backgroundBase), ) { Column( modifier = Modifier @@ -83,7 +83,7 @@ private fun AuthContent( .padding(horizontal = 24.dp, vertical = 56.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - AppLogo(accentSubtle = Color(colors.accentSubtle)) + AppLogo(accentSubtle = colors.accentSubtle) Spacer(Modifier.height(16.dp)) @@ -91,7 +91,7 @@ private fun AuthContent( text = "Yaseyo", fontSize = 32.sp, fontWeight = FontWeight.Bold, - color = Color(colors.accentDefault), + color = colors.accentDefault, ) Spacer(Modifier.height(8.dp)) @@ -99,7 +99,7 @@ private fun AuthContent( Text( text = "Cultivating a lighter, healthier you.", fontSize = 15.sp, - color = Color(colors.contentSecondary), + color = colors.contentSecondary, ) Spacer(Modifier.height(32.dp)) @@ -107,7 +107,7 @@ private fun AuthContent( Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), - colors = CardDefaults.cardColors(containerColor = Color(colors.backgroundElevated)), + colors = CardDefaults.cardColors(containerColor = colors.backgroundElevated), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), ) { Column(modifier = Modifier.padding(20.dp)) { @@ -124,9 +124,9 @@ private fun AuthContent( value = state.email, onValueChange = eventHandler::onEmailChanged, modifier = Modifier.fillMaxWidth(), - placeholder = { Text("hello@example.com", color = Color(colors.contentTertiary)) }, + placeholder = { Text("hello@example.com", color = colors.contentTertiary) }, leadingIcon = { - Icon(Icons.Default.Email, contentDescription = null, tint = Color(colors.contentTertiary)) + Icon(Icons.Default.Email, contentDescription = null, tint = colors.contentTertiary) }, shape = RoundedCornerShape(12.dp), colors = fieldColors(), @@ -143,7 +143,7 @@ private fun AuthContent( onValueChange = eventHandler::onPasswordChanged, modifier = Modifier.fillMaxWidth(), leadingIcon = { - Icon(Icons.Default.Lock, contentDescription = null, tint = Color(colors.contentTertiary)) + Icon(Icons.Default.Lock, contentDescription = null, tint = colors.contentTertiary) }, trailingIcon = { IconButton(onClick = eventHandler::onPasswordVisibilityToggled) { @@ -154,7 +154,7 @@ private fun AuthContent( Icons.Default.Visibility }, contentDescription = if (state.isPasswordVisible) "Hide password" else "Show password", - tint = Color(colors.contentTertiary), + tint = colors.contentTertiary, ) } }, @@ -174,7 +174,7 @@ private fun AuthContent( Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { Text( text = "Forgot Password?", - color = Color(colors.accentDefault), + color = colors.accentDefault, fontWeight = FontWeight.Medium, fontSize = 14.sp, modifier = Modifier.clickable { eventHandler.onForgotPasswordClicked() }, @@ -187,14 +187,14 @@ private fun AuthContent( onClick = eventHandler::onSubmitClicked, modifier = Modifier.fillMaxWidth().height(56.dp), shape = RoundedCornerShape(16.dp), - colors = ButtonDefaults.buttonColors(containerColor = Color(colors.accentDefault)), + colors = ButtonDefaults.buttonColors(containerColor = colors.accentDefault), enabled = !state.isLoading, ) { Text( text = if (state.tab == AuthTab.SignIn) "Log In →" else "Sign Up →", fontSize = 17.sp, fontWeight = FontWeight.SemiBold, - color = Color(colors.contentOnAccent), + color = colors.contentOnAccent, ) } } @@ -205,7 +205,7 @@ private fun AuthContent( Text( text = "By continuing, you agree to Yaseyo's Terms of Service and Privacy Policy.", fontSize = 12.sp, - color = Color(colors.contentTertiary), + color = colors.contentTertiary, textAlign = TextAlign.Center, ) } @@ -235,7 +235,7 @@ private fun AuthToggle( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(50.dp)) - .background(Color(colors.backgroundSubtle)) + .background(colors.backgroundSubtle) .padding(4.dp), ) { AuthTabButton( @@ -264,14 +264,14 @@ private fun AuthTabButton( Box( modifier = modifier .clip(RoundedCornerShape(50.dp)) - .background(if (selected) Color(colors.accentDefault) else Color.Transparent) + .background(if (selected) colors.accentDefault else Color.Transparent) .clickable(onClick = onClick) .padding(vertical = 12.dp), contentAlignment = Alignment.Center, ) { Text( text = label, - color = if (selected) Color(colors.contentOnAccent) else Color(colors.contentSecondary), + color = if (selected) colors.contentOnAccent else colors.contentSecondary, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, ) @@ -286,7 +286,7 @@ private fun FieldLabel(text: String) { fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, - color = Color(colors.contentTertiary), + color = colors.contentTertiary, ) } @@ -294,10 +294,10 @@ private fun FieldLabel(text: String) { private fun fieldColors(): TextFieldColors { val colors = YaseyoTheme.colors return OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color(colors.borderDefault), - focusedBorderColor = Color(colors.accentDefault), - unfocusedTextColor = Color(colors.contentPrimary), - focusedTextColor = Color(colors.contentPrimary), + unfocusedBorderColor = colors.borderDefault, + focusedBorderColor = colors.accentDefault, + unfocusedTextColor = colors.contentPrimary, + focusedTextColor = colors.contentPrimary, ) } 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 eac702e..8a47f2b 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 @@ -23,7 +23,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -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 @@ -64,7 +63,7 @@ private fun ProfileSetupContent( Box( modifier = Modifier .fillMaxSize() - .background(Color(colors.backgroundBase)), + .background(colors.backgroundBase), ) { Column(modifier = Modifier.fillMaxSize()) { Row( @@ -78,14 +77,14 @@ private fun ProfileSetupContent( Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", - tint = Color(colors.contentPrimary), + tint = colors.contentPrimary, ) } Text( text = "Yaseyo", fontSize = 17.sp, fontWeight = FontWeight.SemiBold, - color = Color(colors.accentDefault), + color = colors.accentDefault, ) // Balance the row so title stays centered Spacer(Modifier.size(48.dp)) @@ -132,8 +131,8 @@ private fun ProfileSetupContent( .height(56.dp), shape = RoundedCornerShape(50.dp), colors = ButtonDefaults.buttonColors( - containerColor = Color(colors.accentDefault), - disabledContainerColor = Color(colors.accentSubtle), + containerColor = colors.accentDefault, + disabledContainerColor = colors.accentSubtle, ), enabled = when (state.currentStepIndex) { 0 -> state.name.isNotBlank() @@ -146,7 +145,7 @@ private fun ProfileSetupContent( fontSize = 15.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp, - color = Color(colors.contentOnAccent), + color = colors.contentOnAccent, ) } } diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/AboutYouStep.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/AboutYouStep.kt index dbd9fdf..a9d1947 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/AboutYouStep.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/AboutYouStep.kt @@ -6,7 +6,6 @@ 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.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -35,7 +34,7 @@ internal fun AboutYouStep( text = title, fontSize = 22.sp, fontWeight = FontWeight.Bold, - color = Color(colors.contentPrimary), + color = colors.contentPrimary, ) if (subtitle != null) { @@ -43,7 +42,7 @@ internal fun AboutYouStep( Text( text = subtitle, fontSize = 15.sp, - color = Color(colors.contentSecondary), + color = colors.contentSecondary, ) } @@ -83,7 +82,7 @@ private fun SectionLabel( fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, - color = Color(colors.contentTertiary), + color = colors.contentTertiary, modifier = modifier, ) } 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 0d1c671..58f41b0 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 @@ -11,7 +11,6 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -35,7 +34,7 @@ internal fun NameStep( text = title, fontSize = 28.sp, fontWeight = FontWeight.Bold, - color = Color(colors.contentPrimary), + color = colors.contentPrimary, lineHeight = 34.sp, ) @@ -44,7 +43,7 @@ internal fun NameStep( Text( text = subtitle, fontSize = 15.sp, - color = Color(colors.contentSecondary), + color = colors.contentSecondary, ) } @@ -57,20 +56,20 @@ internal fun NameStep( placeholder = { Text( text = "Your name", - color = Color(colors.contentTertiary), + color = colors.contentTertiary, fontSize = 17.sp, ) }, textStyle = TextStyle( fontSize = 17.sp, - color = Color(colors.contentPrimary), + color = colors.contentPrimary, ), shape = RoundedCornerShape(16.dp), colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color(colors.borderDefault), - focusedBorderColor = Color(colors.accentDefault), - unfocusedTextColor = Color(colors.contentPrimary), - focusedTextColor = Color(colors.contentPrimary), + unfocusedBorderColor = colors.borderDefault, + focusedBorderColor = colors.accentDefault, + unfocusedTextColor = colors.contentPrimary, + focusedTextColor = colors.contentPrimary, ), singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/OnboardingProgressBar.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/OnboardingProgressBar.kt index fd5679f..1cb6329 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 @@ -10,7 +10,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview @@ -34,14 +33,14 @@ internal fun OnboardingProgressBar( ) { Text( text = "Step $currentStep of $totalSteps", - color = Color(colors.accentDefault), + color = colors.accentDefault, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, modifier = Modifier.weight(1f), ) Text( text = "$percentage%", - color = Color(colors.contentSecondary), + color = colors.contentSecondary, fontSize = 13.sp, ) } @@ -51,8 +50,8 @@ internal fun OnboardingProgressBar( modifier = Modifier .fillMaxWidth() .height(4.dp), - color = Color(colors.accentDefault), - trackColor = Color(colors.backgroundSubtle), + color = colors.accentDefault, + trackColor = colors.backgroundSubtle, strokeCap = StrokeCap.Round, ) } 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 3d9e34b..90e7a14 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 @@ -63,11 +63,11 @@ private fun SexCard( modifier: Modifier = Modifier, ) { val colors = YaseyoTheme.colors - val iconBg = if (selected) Color(colors.accentDefault) else Color(colors.backgroundSubtle) - val iconTint = if (selected) Color(colors.contentOnAccent) else Color(colors.contentSecondary) - val labelColor = if (selected) Color(colors.accentDefault) else Color(colors.contentPrimary) - val borderColor = if (selected) Color(colors.accentDefault) else Color.Transparent - val cardBg = if (selected) Color(colors.accentSubtle) else Color(colors.backgroundElevated) + 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 Column( modifier = modifier 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 9b5d5d4..74c526b 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 @@ -8,7 +8,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import dev.yaseyo.design.YaseyoTheme @@ -53,7 +52,7 @@ internal fun WheelDatePicker( modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) - .background(Color(colors.backgroundSubtle)), + .background(colors.backgroundSubtle), ) { WheelPicker( items = months, 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 3a86714..64a8469 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 @@ -16,7 +16,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.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -80,7 +79,7 @@ internal fun WheelPicker( text = item, fontSize = if (isSelected) 18.sp else 14.sp, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - color = if (isSelected) Color(colors.accentDefault) else Color(colors.contentTertiary), + color = if (isSelected) colors.accentDefault else colors.contentTertiary, ) } } @@ -91,13 +90,13 @@ internal fun WheelPicker( modifier = Modifier .fillMaxWidth() .offset(y = ItemHeight), - color = Color(colors.borderDefault), + color = colors.borderDefault, ) HorizontalDivider( modifier = Modifier .fillMaxWidth() .offset(y = ItemHeight * 2), - color = Color(colors.borderDefault), + color = colors.borderDefault, ) } } diff --git a/libs/design/src/commonMain/kotlin/dev/yaseyo/design/AppColors.kt b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/AppColors.kt index d93be2b..6eed80a 100644 --- a/libs/design/src/commonMain/kotlin/dev/yaseyo/design/AppColors.kt +++ b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/AppColors.kt @@ -1,82 +1,84 @@ package dev.yaseyo.design +import androidx.compose.ui.graphics.Color + data class AppColors( // ── Backgrounds ────────────────────────────────────────────────────────── - val backgroundBase: Long, // app root background - val backgroundSubtle: Long, // recessed areas, input fills - val backgroundElevated: Long, // cards, sheets, dialogs + val backgroundBase: Color, + val backgroundSubtle: Color, + val backgroundElevated: Color, // ── Content (text & icons) ─────────────────────────────────────────────── - val contentPrimary: Long, // body text, primary icons - val contentSecondary: Long, // labels, captions - val contentTertiary: Long, // placeholder, hints - val contentDisabled: Long, // disabled state - val contentOnAccent: Long, // text / icons sitting on accent fill + val contentPrimary: Color, + val contentSecondary: Color, + val contentTertiary: Color, + val contentDisabled: Color, + val contentOnAccent: Color, // ── Accent (brand action) ──────────────────────────────────────────────── - val accentDefault: Long, // buttons, active controls, links - val accentHover: Long, // accentDefault darkened for hover/press - val accentSubtle: Long, // tinted backgrounds, selected rows - val accentMuted: Long, // very light tint, chips, tags + val accentDefault: Color, + val accentHover: Color, + val accentSubtle: Color, + val accentMuted: Color, // ── Borders ─────────────────────────────────────────────────────────────── - val borderSubtle: Long, // dividers, hairlines - val borderDefault: Long, // input rings, card edges - val borderStrong: Long, // focused input, emphasis + val borderSubtle: Color, + val borderDefault: Color, + val borderStrong: Color, // ── Feedback ────────────────────────────────────────────────────────────── - val feedbackPositive: Long, - val feedbackPositiveSubtle: Long, - val feedbackCritical: Long, - val feedbackCriticalSubtle: Long, - val feedbackWarning: Long, - val feedbackWarningSubtle: Long, + val feedbackPositive: Color, + val feedbackPositiveSubtle: Color, + val feedbackCritical: Color, + val feedbackCriticalSubtle: Color, + val feedbackWarning: Color, + val feedbackWarningSubtle: Color, // ── Scrim ───────────────────────────────────────────────────────────────── - val scrim: Long, + val scrim: Color, ) val LightAppColors = AppColors( - backgroundBase = 0xFFFBFDF9L, - backgroundSubtle = 0xFFEEF2EFL, - backgroundElevated = 0xFFFFFFFFL, - contentPrimary = 0xFF191C1AL, - contentSecondary = 0xFF404943L, - contentTertiary = 0xFF707973L, - contentDisabled = 0xFFC0C9C2L, - contentOnAccent = 0xFFFFFFFFL, - accentDefault = 0xFF1B7A5EL, - accentHover = 0xFF145C46L, - accentSubtle = 0xFFA5F2D6L, - accentMuted = 0xFFDCE5DEL, - borderSubtle = 0xFFDCE5DEL, - borderDefault = 0xFFC0C9C2L, - borderStrong = 0xFF707973L, - feedbackPositive = 0xFF1B7A5EL, - feedbackPositiveSubtle = 0xFFA5F2D6L, - feedbackCritical = 0xFFBA1A1AL, - feedbackCriticalSubtle = 0xFFFFDAD6L, - feedbackWarning = 0xFF7D5700L, - feedbackWarningSubtle = 0xFFFFDFA3L, - scrim = 0x99000000L, + backgroundBase = Color(0xFFFBFDF9), + backgroundSubtle = Color(0xFFEEF2EF), + backgroundElevated = Color(0xFFFFFFFF), + contentPrimary = Color(0xFF191C1A), + contentSecondary = Color(0xFF404943), + contentTertiary = Color(0xFF707973), + contentDisabled = Color(0xFFC0C9C2), + contentOnAccent = Color(0xFFFFFFFF), + accentDefault = Color(0xFF1B7A5E), + accentHover = Color(0xFF145C46), + accentSubtle = Color(0xFFA5F2D6), + accentMuted = Color(0xFFDCE5DE), + borderSubtle = Color(0xFFDCE5DE), + borderDefault = Color(0xFFC0C9C2), + borderStrong = Color(0xFF707973), + feedbackPositive = Color(0xFF1B7A5E), + feedbackPositiveSubtle = Color(0xFFA5F2D6), + feedbackCritical = Color(0xFFBA1A1A), + feedbackCriticalSubtle = Color(0xFFFFDAD6), + feedbackWarning = Color(0xFF7D5700), + feedbackWarningSubtle = Color(0xFFFFDFA3), + scrim = Color(0x99000000), ) val DarkAppColors = AppColors( - backgroundBase = 0xFF191C1AL, - backgroundSubtle = 0xFF1F2421L, - backgroundElevated = 0xFF2E3130L, - contentPrimary = 0xFFE1E3DFL, - contentSecondary = 0xFFC0C9C2L, - contentTertiary = 0xFF8A938CL, - contentDisabled = 0xFF404943L, - contentOnAccent = 0xFF00382AL, - accentDefault = 0xFF89D5B5L, - accentHover = 0xFFA5E8C9L, - accentSubtle = 0xFF005140L, - accentMuted = 0xFF404943L, - borderSubtle = 0xFF2E3130L, - borderDefault = 0xFF404943L, - borderStrong = 0xFF8A938CL, - feedbackPositive = 0xFF89D5B5L, - feedbackPositiveSubtle = 0xFF005140L, - feedbackCritical = 0xFFFFB4ABL, - feedbackCriticalSubtle = 0xFF93000AL, - feedbackWarning = 0xFFF5BE48L, - feedbackWarningSubtle = 0xFF4C3800L, - scrim = 0xCC000000L, + backgroundBase = Color(0xFF191C1A), + backgroundSubtle = Color(0xFF1F2421), + backgroundElevated = Color(0xFF2E3130), + contentPrimary = Color(0xFFE1E3DF), + contentSecondary = Color(0xFFC0C9C2), + contentTertiary = Color(0xFF8A938C), + contentDisabled = Color(0xFF404943), + contentOnAccent = Color(0xFF00382A), + accentDefault = Color(0xFF89D5B5), + accentHover = Color(0xFFA5E8C9), + accentSubtle = Color(0xFF005140), + accentMuted = Color(0xFF404943), + borderSubtle = Color(0xFF2E3130), + borderDefault = Color(0xFF404943), + borderStrong = Color(0xFF8A938C), + feedbackPositive = Color(0xFF89D5B5), + feedbackPositiveSubtle = Color(0xFF005140), + feedbackCritical = Color(0xFFFFB4AB), + feedbackCriticalSubtle = Color(0xFF93000A), + feedbackWarning = Color(0xFFF5BE48), + feedbackWarningSubtle = Color(0xFF4C3800), + scrim = Color(0xCC000000), ) diff --git a/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt index ea13981..8c08e8f 100644 --- a/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt +++ b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color @Composable fun YaseyoPreview( @@ -16,7 +15,7 @@ fun YaseyoPreview( YaseyoTheme { val colors = YaseyoTheme.colors Scaffold( - containerColor = Color(colors.backgroundBase), + containerColor = colors.backgroundBase, ) { Box(modifier = Modifier.padding(it).fillMaxSize()) { composable() From 6a7d44df5c9241db68620c2e93795b43878560e9 Mon Sep 17 00:00:00 2001 From: Taki Date: Thu, 14 May 2026 23:18:08 +0900 Subject: [PATCH 18/24] Update YaseyoPreview to respect darkTheme parameter --- .../src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt index 8c08e8f..a901803 100644 --- a/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt +++ b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt @@ -12,7 +12,7 @@ fun YaseyoPreview( darkTheme: Boolean = false, composable: @Composable () -> Unit, ) { - YaseyoTheme { + YaseyoTheme(darkTheme = darkTheme) { val colors = YaseyoTheme.colors Scaffold( containerColor = colors.backgroundBase, From 4ae90404d3907be5b865bfea9536206dc93ba030 Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 15 May 2026 14:07:58 +0900 Subject: [PATCH 19/24] Refactor authentication UI and logic to use separate sign-in and sign-up components - Rename `YaseyoAuthViewController` to `YaseyoUiViewController` and add content padding - Refactor `AuthScreen` into a landing page with separate Sign In and Sign Up sheets - Move form state (email, password, visibility) from `AuthViewModel` to local `TextFieldState` - Simplify `AuthUiState` to only track loading status - Update `AuthEventHandler` and `AuthViewModel` to handle explicit sign-in/sign-up actions - Expand `OnboardingRoutes` to include `Landing`, `SignIn`, and `SignUp` sub-routes --- .../onboarding/navigation/OnboardingRoutes.kt | 8 +- .../onboarding/ui/auth/AuthEventHandler.kt | 18 +- .../yaseyo/onboarding/ui/auth/AuthScreen.kt | 432 ++++++++---------- .../yaseyo/onboarding/ui/auth/AuthUiState.kt | 6 - .../onboarding/ui/auth/AuthViewModel.kt | 95 ++-- .../yaseyo/onboarding/ui/auth/SignInSheet.kt | 246 ++++++++++ .../yaseyo/onboarding/ui/auth/SignUpSheet.kt | 227 +++++++++ ...ontroller.kt => YaseyoUiViewController.kt} | 8 +- 8 files changed, 744 insertions(+), 296 deletions(-) create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheet.kt create mode 100644 feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignUpSheet.kt rename libs/design/src/iosMain/kotlin/dev/yaseyo/design/{YaseyoAuthViewController.kt => YaseyoUiViewController.kt} (63%) 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 033ba60..2db6f9b 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 @@ -3,7 +3,13 @@ package dev.yaseyo.onboarding.navigation import dev.yaseyo.navigation.AppRoute sealed interface OnboardingRoutes : AppRoute { - data object Auth : OnboardingRoutes + data object Auth : OnboardingRoutes { + data object Landing : OnboardingRoutes + + data object SignIn : OnboardingRoutes + + data object SignUp : OnboardingRoutes + } data object ProfileSetup : OnboardingRoutes } diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthEventHandler.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthEventHandler.kt index 8915f05..5b50acd 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthEventHandler.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthEventHandler.kt @@ -1,15 +1,19 @@ package dev.yaseyo.onboarding.ui.auth interface AuthEventHandler { - fun onTabChanged(tab: AuthTab) + fun onSignInClicked() - fun onEmailChanged(email: String) + fun onSignUpClicked() - fun onPasswordChanged(password: String) - - fun onPasswordVisibilityToggled() + fun onForgotPasswordClicked() - fun onSubmitClicked() + fun signInWithEmailAndPassword( + email: String, + password: String, + ) - fun onForgotPasswordClicked() + fun signUpWithEmailAndPassword( + email: String, + password: String, + ) } diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthScreen.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthScreen.kt index 524e263..4290e24 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthScreen.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthScreen.kt @@ -1,338 +1,284 @@ package dev.yaseyo.onboarding.ui.auth +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background -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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Email -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text -import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults 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 import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.yaseyo.design.LocalSnackBarHostState +import dev.yaseyo.design.YaseyoPreview import dev.yaseyo.design.YaseyoTheme import org.koin.compose.viewmodel.koinViewModel @Composable internal fun AuthScreen(viewModel: AuthViewModel = koinViewModel()) { - val state by viewModel.uiState.collectAsStateWithLifecycle() val snackBarHostState = LocalSnackBarHostState.current - AuthContent(state = state, eventHandler = viewModel) + AuthContent(eventHandler = viewModel) LaunchedEffect(viewModel) { - viewModel.action.collect { - snackBarHostState.showSnackbar(it) - } + viewModel.action.collect { snackBarHostState.showSnackbar(it) } } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun AuthContent( - state: AuthUiState, eventHandler: AuthEventHandler, + modifier: Modifier = Modifier, ) { - val colors = YaseyoTheme.colors + AuthLandingContent(eventHandler = eventHandler, modifier = modifier) +} - Box( - modifier = Modifier +@Composable +internal fun AuthLandingContent( + eventHandler: AuthEventHandler, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier .fillMaxSize() - .background(colors.backgroundBase), + .padding(horizontal = 24.dp, vertical = 48.dp), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp, vertical = 56.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppLogo(accentSubtle = colors.accentSubtle) - - Spacer(Modifier.height(16.dp)) - - Text( - text = "Yaseyo", - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - color = colors.accentDefault, - ) - - Spacer(Modifier.height(8.dp)) - - Text( - text = "Cultivating a lighter, healthier you.", - fontSize = 15.sp, - color = colors.contentSecondary, - ) - - Spacer(Modifier.height(32.dp)) - - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - colors = CardDefaults.cardColors(containerColor = colors.backgroundElevated), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), - ) { - Column(modifier = Modifier.padding(20.dp)) { - AuthToggle( - selectedTab = state.tab, - onTabSelected = eventHandler::onTabChanged, - ) - - Spacer(Modifier.height(24.dp)) - - FieldLabel("EMAIL ADDRESS") - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = state.email, - onValueChange = eventHandler::onEmailChanged, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("hello@example.com", color = colors.contentTertiary) }, - leadingIcon = { - Icon(Icons.Default.Email, contentDescription = null, tint = colors.contentTertiary) - }, - shape = RoundedCornerShape(12.dp), - colors = fieldColors(), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), - ) - - Spacer(Modifier.height(16.dp)) - - FieldLabel("PASSWORD") - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = state.password, - onValueChange = eventHandler::onPasswordChanged, - modifier = Modifier.fillMaxWidth(), - leadingIcon = { - Icon(Icons.Default.Lock, contentDescription = null, tint = colors.contentTertiary) - }, - trailingIcon = { - IconButton(onClick = eventHandler::onPasswordVisibilityToggled) { - Icon( - imageVector = if (state.isPasswordVisible) { - Icons.Default.VisibilityOff - } else { - Icons.Default.Visibility - }, - contentDescription = if (state.isPasswordVisible) "Hide password" else "Show password", - tint = colors.contentTertiary, - ) - } - }, - visualTransformation = if (state.isPasswordVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - shape = RoundedCornerShape(12.dp), - colors = fieldColors(), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - ) - - Spacer(Modifier.height(8.dp)) - - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { - Text( - text = "Forgot Password?", - color = colors.accentDefault, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - modifier = Modifier.clickable { eventHandler.onForgotPasswordClicked() }, - ) - } - - Spacer(Modifier.height(24.dp)) - - Button( - onClick = eventHandler::onSubmitClicked, - modifier = Modifier.fillMaxWidth().height(56.dp), - shape = RoundedCornerShape(16.dp), - colors = ButtonDefaults.buttonColors(containerColor = colors.accentDefault), - enabled = !state.isLoading, - ) { - Text( - text = if (state.tab == AuthTab.SignIn) "Log In →" else "Sign Up →", - fontSize = 17.sp, - fontWeight = FontWeight.SemiBold, - color = colors.contentOnAccent, - ) - } - } - } + AuthHeader() + AuthOptions( + onSignInClick = eventHandler::onSignInClicked, + onSignUpClick = eventHandler::onSignUpClicked, + ) + TermsText() + } +} - Spacer(Modifier.height(24.dp)) +@Composable +internal fun SheetContent( + title: String, + submitLabel: String, + onSubmit: (email: String, password: String) -> Unit, + modifier: Modifier = Modifier, +) { + val emailState = rememberTextFieldState() + val passwordState = rememberTextFieldState() + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = title, + fontSize = 22.sp, + fontWeight = FontWeight.SemiBold, + color = YaseyoTheme.colors.contentPrimary, + ) + AuthField(label = "Email", state = emailState, placeholder = "yamadataro@yaseyo.com") + AuthField(label = "Password", state = passwordState, placeholder = "••••••••") + Button( + onClick = { onSubmit(emailState.text.toString(), passwordState.text.toString()) }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + shape = MaterialTheme.shapes.large, + colors = ButtonDefaults.buttonColors( + containerColor = YaseyoTheme.colors.accentDefault, + ), + ) { Text( - text = "By continuing, you agree to Yaseyo's Terms of Service and Privacy Policy.", - fontSize = 12.sp, - color = colors.contentTertiary, - textAlign = TextAlign.Center, + text = submitLabel, + modifier = Modifier.padding(vertical = 8.dp), + fontSize = 16.sp, ) } } } @Composable -private fun AppLogo(accentSubtle: Color) { - Box( - modifier = Modifier - .size(80.dp) - .clip(RoundedCornerShape(20.dp)) - .background(accentSubtle), - contentAlignment = Alignment.Center, +private fun AuthHeader(modifier: Modifier = Modifier) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(text = "🌿", fontSize = 36.sp) + AppLogo( + modifier = Modifier + .size(80.dp) + .clip(RoundedCornerShape(20.dp)) + .background(YaseyoTheme.colors.accentSubtle), + ) + Text( + text = "Yaseyo", + fontSize = 24.sp, + fontWeight = FontWeight.SemiBold, + color = YaseyoTheme.colors.accentDefault, + ) + Text( + text = "Your fat-loss companion", + fontSize = 14.sp, + color = YaseyoTheme.colors.contentSecondary, + ) } } @Composable -private fun AuthToggle( - selectedTab: AuthTab, - onTabSelected: (AuthTab) -> Unit, +private fun AuthOptions( + onSignInClick: () -> Unit, + onSignUpClick: () -> Unit, + modifier: Modifier = Modifier, ) { - val colors = YaseyoTheme.colors - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(50.dp)) - .background(colors.backgroundSubtle) - .padding(4.dp), + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - AuthTabButton( - label = "Sign In", - selected = selectedTab == AuthTab.SignIn, - modifier = Modifier.weight(1f), - onClick = { onTabSelected(AuthTab.SignIn) }, - ) - AuthTabButton( - label = "Sign Up", - selected = selectedTab == AuthTab.SignUp, - modifier = Modifier.weight(1f), - onClick = { onTabSelected(AuthTab.SignUp) }, - ) + Button( + onClick = onSignUpClick, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = ButtonDefaults.buttonColors( + containerColor = YaseyoTheme.colors.accentDefault, + ), + ) { + Text( + text = "Get started", + modifier = Modifier.padding(vertical = 8.dp), + fontSize = 16.sp, + ) + } + OutlinedButton( + onClick = onSignInClick, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + border = BorderStroke(1.dp, YaseyoTheme.colors.borderDefault), + ) { + Text( + text = "Log in", + modifier = Modifier.padding(vertical = 8.dp), + fontSize = 16.sp, + color = YaseyoTheme.colors.contentPrimary, + ) + } } } @Composable -private fun AuthTabButton( +private fun TermsText(modifier: Modifier = Modifier) { + Text( + modifier = modifier, + text = "By continuing, you agree to Yaseyo's Terms of Service and Privacy Policy.", + fontSize = 12.sp, + color = YaseyoTheme.colors.contentTertiary, + ) +} + +@Composable +private fun AuthField( label: String, - selected: Boolean, + state: TextFieldState, + placeholder: String, modifier: Modifier = Modifier, - onClick: () -> Unit, ) { - val colors = YaseyoTheme.colors - Box( - modifier = modifier - .clip(RoundedCornerShape(50.dp)) - .background(if (selected) colors.accentDefault else Color.Transparent) - .clickable(onClick = onClick) - .padding(vertical = 12.dp), - contentAlignment = Alignment.Center, + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( text = label, - color = if (selected) colors.contentOnAccent else colors.contentSecondary, - fontWeight = FontWeight.SemiBold, - fontSize = 15.sp, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = YaseyoTheme.colors.contentSecondary, + ) + TextField( + modifier = Modifier.fillMaxWidth(), + state = state, + placeholder = { Text(text = placeholder, color = YaseyoTheme.colors.contentTertiary) }, + lineLimits = TextFieldLineLimits.SingleLine, + shape = MaterialTheme.shapes.large, + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), ) } } @Composable -private fun FieldLabel(text: String) { - val colors = YaseyoTheme.colors - Text( - text = text, - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 1.sp, - color = colors.contentTertiary, - ) -} - -@Composable -private fun fieldColors(): TextFieldColors { - val colors = YaseyoTheme.colors - return OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = colors.borderDefault, - focusedBorderColor = colors.accentDefault, - unfocusedTextColor = colors.contentPrimary, - focusedTextColor = colors.contentPrimary, - ) +private fun AppLogo(modifier: Modifier = Modifier) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Text(text = "🌿", fontSize = 36.sp) + } } -private val previewState = AuthUiState() +internal val previewAuthUiState = AuthUiState() -private val previewHandler = object : AuthEventHandler { - override fun onTabChanged(tab: AuthTab) {} +internal val previewAuthEventHandler = object : AuthEventHandler { + override fun onSignInClicked() {} - override fun onEmailChanged(email: String) {} + override fun onSignUpClicked() {} - override fun onPasswordChanged(password: String) {} - - override fun onPasswordVisibilityToggled() {} + override fun onForgotPasswordClicked() {} - override fun onSubmitClicked() {} + override fun signInWithEmailAndPassword( + email: String, + password: String, + ) {} - override fun onForgotPasswordClicked() {} + override fun signUpWithEmailAndPassword( + email: String, + password: String, + ) {} } @Preview(showBackground = true) @Composable -private fun AuthContentLightPreview() { - YaseyoTheme(darkTheme = false) { AuthContent(state = previewState, eventHandler = previewHandler) } +private fun AuthLandingLightPreview() { + YaseyoPreview(darkTheme = false) { + AuthLandingContent(eventHandler = previewAuthEventHandler) + } } @Preview(showBackground = true) @Composable -private fun AuthContentSignUpPreview() { - YaseyoTheme(darkTheme = false) { - AuthContent(state = previewState.copy(tab = AuthTab.SignUp), eventHandler = previewHandler) +private fun AuthLandingDarkPreview() { + YaseyoPreview(darkTheme = true) { + AuthLandingContent(eventHandler = previewAuthEventHandler) } } @Preview(showBackground = true) @Composable -private fun AuthContentDarkPreview() { - YaseyoTheme(darkTheme = true) { AuthContent(state = previewState, eventHandler = previewHandler) } +private fun SheetContentPreview() { + YaseyoPreview(darkTheme = false) { + SheetContent(title = "Sign in", submitLabel = "Sign in", onSubmit = { _, _ -> }) + } } diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthUiState.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthUiState.kt index 89fce13..906508b 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthUiState.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthUiState.kt @@ -1,11 +1,5 @@ package dev.yaseyo.onboarding.ui.auth -enum class AuthTab { SignIn, SignUp } - data class AuthUiState( - val tab: AuthTab = AuthTab.SignIn, - val email: String = "", - val password: String = "", - val isPasswordVisible: Boolean = false, val isLoading: Boolean = false, ) 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 7569929..da3fa64 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 @@ -21,54 +21,73 @@ internal class AuthViewModel( private val _uiState = MutableStateFlow(AuthUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private val _action = Channel(capacity = Channel.UNLIMITED) + private val _action = Channel(capacity = Channel.BUFFERED) val action = _action.receiveAsFlow() - override fun onTabChanged(tab: AuthTab) = _uiState.update { it.copy(tab = tab) } + override fun onSignInClicked() { + router.navigate(OnboardingRoutes.Auth.SignIn) + } - override fun onEmailChanged(email: String) = _uiState.update { it.copy(email = email) } + override fun onSignUpClicked() { + router.navigate(OnboardingRoutes.Auth.SignUp) + } - override fun onPasswordChanged(password: String) = _uiState.update { it.copy(password = password) } + override fun onForgotPasswordClicked() { /* TODO */ } - override fun onPasswordVisibilityToggled() = _uiState.update { it.copy(isPasswordVisible = !it.isPasswordVisible) } + override fun signInWithEmailAndPassword( + email: String, + password: String, + ) { + _uiState.update { + it.copy(isLoading = true) + } - override fun onForgotPasswordClicked() { /* TODO */ } + viewModelScope.launch { + val signInResult = authRepository.signInWithEmailAndPassword( + email = email, + password = password, + ) - override fun onSubmitClicked() { - when (uiState.value.tab) { - AuthTab.SignIn -> { - viewModelScope.launch { - val signInResult = authRepository.signInWithEmailAndPassword( - email = uiState.value.email, - password = uiState.value.password, - ) + signInResult.fold( + onSuccess = { + router.navigate(OnboardingRoutes.ProfileSetup) + }, + onFailure = { + _action.send("Something went wrong") + }, + ) - signInResult.fold( - onSuccess = { - router.navigate(route = OnboardingRoutes.ProfileSetup) - }, - onFailure = { - _action.send("Something went wrong") - }, - ) - } + _uiState.update { + it.copy(isLoading = false) } - AuthTab.SignUp -> { - viewModelScope.launch { - val signUpResult = authRepository.signUpWithEmailAndPassword( - email = uiState.value.email, - password = uiState.value.password, - ) + } + } + + override fun signUpWithEmailAndPassword( + email: String, + password: String, + ) { + _uiState.update { + it.copy(isLoading = true) + } + + viewModelScope.launch { + val signInResult = authRepository.signUpWithEmailAndPassword( + email = email, + password = password, + ) + + signInResult.fold( + onSuccess = { + router.navigate(OnboardingRoutes.ProfileSetup) + }, + onFailure = { + _action.send("Something went wrong") + }, + ) - signUpResult.fold( - onSuccess = { - router.navigate(route = OnboardingRoutes.ProfileSetup) - }, - onFailure = { - _action.send("Something went wrong") - }, - ) - } + _uiState.update { + it.copy(isLoading = false) } } } diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheet.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheet.kt new file mode 100644 index 0000000..86c9727 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheet.kt @@ -0,0 +1,246 @@ +package dev.yaseyo.onboarding.ui.auth + +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.yaseyo.design.LocalSnackBarHostState +import dev.yaseyo.design.YaseyoPreview +import dev.yaseyo.design.YaseyoTheme +import org.koin.compose.viewmodel.koinViewModel + +@Composable +internal fun SignInSheetScreen(viewModel: AuthViewModel = koinViewModel()) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val snackBarHostState = LocalSnackBarHostState.current + + SignInSheetContent(state = state, eventHandler = viewModel) + + LaunchedEffect(viewModel) { + viewModel.action.collect { snackBarHostState.showSnackbar(it) } + } +} + +@Composable +internal fun SignInSheetContent( + state: AuthUiState, + eventHandler: AuthEventHandler, + modifier: Modifier = Modifier, +) { + val emailState = rememberTextFieldState() + val passwordState = rememberTextFieldState() + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Sign in", + fontSize = 22.sp, + fontWeight = FontWeight.SemiBold, + color = YaseyoTheme.colors.contentPrimary, + ) + + SheetEmailField(state = emailState) + + SheetPasswordField(state = passwordState) + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterEnd, + ) { + TextButton(onClick = eventHandler::onForgotPasswordClicked) { + Text( + text = "Forgot password?", + fontSize = 13.sp, + color = YaseyoTheme.colors.accentDefault, + ) + } + } + + Button( + onClick = { + eventHandler.signInWithEmailAndPassword( + email = emailState.text.toString(), + password = passwordState.text.toString(), + ) + }, + enabled = !state.isLoading, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + shape = MaterialTheme.shapes.large, + colors = ButtonDefaults.buttonColors( + containerColor = YaseyoTheme.colors.accentDefault, + ), + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = Color.White, + strokeWidth = 2.dp, + ) + } else { + Text( + text = "Sign in", + modifier = Modifier.padding(vertical = 8.dp), + fontSize = 16.sp, + ) + } + } + } +} + +// --- Private field components --- + +@Composable +private fun SheetEmailField( + state: TextFieldState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Email", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = YaseyoTheme.colors.contentSecondary, + ) + TextField( + modifier = Modifier.fillMaxWidth(), + state = state, + placeholder = { + Text(text = "yamadataro@yaseyo.com", color = YaseyoTheme.colors.contentTertiary) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ), + lineLimits = TextFieldLineLimits.SingleLine, + shape = MaterialTheme.shapes.large, + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + } +} + +// isPasswordVisible is UI-local — kept inside this composable since nothing outside it needs to know. +@Composable +private fun SheetPasswordField( + state: TextFieldState, + modifier: Modifier = Modifier, +) { + var isVisible by remember { mutableStateOf(false) } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Password", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = YaseyoTheme.colors.contentSecondary, + ) + TextField( + modifier = Modifier.fillMaxWidth(), + state = state, + placeholder = { + Text(text = "••••••••", color = YaseyoTheme.colors.contentTertiary) + }, + // Masks characters when hidden; revealed on toggle. + // Requires material-icons-extended for the toggle icon — add dependency in plumbing step. + outputTransformation = if (!isVisible) PasswordMaskTransformation else null, + trailingIcon = { + IconButton(onClick = { isVisible = !isVisible }) { + Icon( + imageVector = if (isVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = if (isVisible) "Hide password" else "Show password", + tint = YaseyoTheme.colors.contentSecondary, + ) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + lineLimits = TextFieldLineLimits.SingleLine, + shape = MaterialTheme.shapes.large, + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + } +} + +private val PasswordMaskTransformation = OutputTransformation { + if (length > 0) replace(0, length, "•".repeat(length)) +} + +@Preview(showBackground = true) +@Composable +private fun SignInSheetLightPreview() { + YaseyoPreview(darkTheme = false) { + SignInSheetContent(state = previewAuthUiState, eventHandler = previewAuthEventHandler) + } +} + +@Preview(showBackground = true) +@Composable +private fun SignInSheetDarkPreview() { + YaseyoPreview(darkTheme = true) { + SignInSheetContent(state = previewAuthUiState, eventHandler = previewAuthEventHandler) + } +} + +@Preview(showBackground = true) +@Composable +private fun SignInSheetLoadingPreview() { + YaseyoPreview(darkTheme = false) { + SignInSheetContent(state = previewAuthUiState, eventHandler = previewAuthEventHandler) + } +} diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignUpSheet.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignUpSheet.kt new file mode 100644 index 0000000..6b7580c --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignUpSheet.kt @@ -0,0 +1,227 @@ +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.yaseyo.design.LocalSnackBarHostState +import dev.yaseyo.design.YaseyoPreview +import dev.yaseyo.design.YaseyoTheme +import org.koin.compose.viewmodel.koinViewModel + +@Composable +internal fun SignUpSheetScreen(viewModel: AuthViewModel = koinViewModel()) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val snackBarHostState = LocalSnackBarHostState.current + + SignUpSheetContent(state = state, eventHandler = viewModel) + + LaunchedEffect(viewModel) { + viewModel.action.collect { snackBarHostState.showSnackbar(it) } + } +} + +@Composable +internal fun SignUpSheetContent( + state: AuthUiState, + eventHandler: AuthEventHandler, + modifier: Modifier = Modifier, +) { + val emailState = rememberTextFieldState() + val passwordState = rememberTextFieldState() + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Create account", + fontSize = 22.sp, + fontWeight = FontWeight.SemiBold, + color = YaseyoTheme.colors.contentPrimary, + ) + + SheetEmailField(state = emailState) + + SheetPasswordField(state = passwordState) + + Button( + onClick = { + eventHandler.signUpWithEmailAndPassword( + email = emailState.text.toString(), + password = passwordState.text.toString(), + ) + }, + enabled = !state.isLoading, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + shape = MaterialTheme.shapes.large, + colors = ButtonDefaults.buttonColors( + containerColor = YaseyoTheme.colors.accentDefault, + ), + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = Color.White, + strokeWidth = 2.dp, + ) + } else { + Text( + text = "Create account", + modifier = Modifier.padding(vertical = 8.dp), + fontSize = 16.sp, + ) + } + } + } +} + +// --- Private field components --- + +@Composable +private fun SheetEmailField( + state: TextFieldState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Email", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = YaseyoTheme.colors.contentSecondary, + ) + TextField( + modifier = Modifier.fillMaxWidth(), + state = state, + placeholder = { + Text(text = "yamadataro@yaseyo.com", color = YaseyoTheme.colors.contentTertiary) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ), + lineLimits = TextFieldLineLimits.SingleLine, + shape = MaterialTheme.shapes.large, + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + } +} + +@Composable +private fun SheetPasswordField( + state: TextFieldState, + modifier: Modifier = Modifier, +) { + var isVisible by remember { mutableStateOf(false) } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Password", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = YaseyoTheme.colors.contentSecondary, + ) + TextField( + modifier = Modifier.fillMaxWidth(), + state = state, + placeholder = { + Text(text = "••••••••", color = YaseyoTheme.colors.contentTertiary) + }, + outputTransformation = if (!isVisible) PasswordMaskTransformation else null, + trailingIcon = { + IconButton(onClick = { isVisible = !isVisible }) { + Icon( + imageVector = if (isVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = if (isVisible) "Hide password" else "Show password", + tint = YaseyoTheme.colors.contentSecondary, + ) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + lineLimits = TextFieldLineLimits.SingleLine, + shape = MaterialTheme.shapes.large, + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + } +} + +private val PasswordMaskTransformation = OutputTransformation { + if (length > 0) replace(0, length, "•".repeat(length)) +} + +@Preview(showBackground = true) +@Composable +private fun SignUpSheetLightPreview() { + YaseyoPreview(darkTheme = false) { + SignUpSheetContent(state = previewAuthUiState, eventHandler = previewAuthEventHandler) + } +} + +@Preview(showBackground = true) +@Composable +private fun SignUpSheetDarkPreview() { + YaseyoPreview(darkTheme = true) { + SignUpSheetContent(state = previewAuthUiState, eventHandler = previewAuthEventHandler) + } +} + +@Preview(showBackground = true) +@Composable +private fun SignUpSheetLoadingPreview() { + YaseyoPreview(darkTheme = false) { + SignUpSheetContent(state = previewAuthUiState, eventHandler = previewAuthEventHandler) + } +} diff --git a/libs/design/src/iosMain/kotlin/dev/yaseyo/design/YaseyoAuthViewController.kt b/libs/design/src/iosMain/kotlin/dev/yaseyo/design/YaseyoUiViewController.kt similarity index 63% rename from libs/design/src/iosMain/kotlin/dev/yaseyo/design/YaseyoAuthViewController.kt rename to libs/design/src/iosMain/kotlin/dev/yaseyo/design/YaseyoUiViewController.kt index c6fbf54..fde9ff2 100644 --- a/libs/design/src/iosMain/kotlin/dev/yaseyo/design/YaseyoAuthViewController.kt +++ b/libs/design/src/iosMain/kotlin/dev/yaseyo/design/YaseyoUiViewController.kt @@ -1,8 +1,12 @@ package dev.yaseyo.design +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.compose.ui.window.ComposeUIViewController fun createYaseyoUiViewController(screen: @Composable () -> Unit) = @@ -13,7 +17,9 @@ fun createYaseyoUiViewController(screen: @Composable () -> Unit) = SnackbarHost(hostState = LocalSnackBarHostState.current) }, ) { - screen() + Box(modifier = Modifier.padding(it).fillMaxSize()) { + screen() + } } } } From 64ddd6ad09248bdc57b838a68c946927721e22b3 Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 15 May 2026 14:30:09 +0900 Subject: [PATCH 20/24] Implement `BottomSheetSceneStrategy` and integrate into onboarding flow --- .../src/androidMain/kotlin/dev/yaseyo/App.kt | 4 + .../onboarding/OnboardingFeatureNavigation.kt | 14 +++- .../ResolveStartDestinationUseCase.kt | 2 +- .../yaseyo/onboarding/ui/auth/SignInSheet.kt | 2 - libs/navigation/build.gradle.kts | 5 +- .../navigation/BottomSheetSceneStrategy.kt | 84 +++++++++++++++++++ 6 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/BottomSheetSceneStrategy.kt diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt index 692516e..a8c0c93 100644 --- a/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt @@ -6,6 +6,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavEntry @@ -16,6 +17,7 @@ import dev.yaseyo.design.LocalSnackBarHostState import dev.yaseyo.design.YaseyoTheme import dev.yaseyo.navigation.AppRoute import dev.yaseyo.navigation.AppRouter +import dev.yaseyo.navigation.BottomSheetSceneStrategy import dev.yaseyo.navigation.FeatureNavigation import dev.yaseyo.navigation.NavigationEvent import dev.yaseyo.navigation.Navigator @@ -29,6 +31,7 @@ fun App(navigator: Navigator) = YaseyoTheme { val appRouter = koinInject() val allFeatureNavigation = getKoin().getAll() + val bottomSheetStrategy = remember { BottomSheetSceneStrategy() } LaunchedEffect(appRouter) { appRouter.events.collect { event -> @@ -54,6 +57,7 @@ fun App(navigator: Navigator) = rememberViewModelStoreNavEntryDecorator(), ), entryProvider = allFeatureNavigation.registerAll(), + sceneStrategies = listOf(bottomSheetStrategy), ) } } diff --git a/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt index d486ffd..14bd5c5 100644 --- a/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt +++ b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt @@ -1,19 +1,31 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + package dev.yaseyo.onboarding +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.navigation3.runtime.EntryProviderScope import dev.yaseyo.navigation.AppRoute +import dev.yaseyo.navigation.BottomSheetSceneStrategy import dev.yaseyo.navigation.FeatureNavigation import dev.yaseyo.onboarding.navigation.OnboardingRoutes import dev.yaseyo.onboarding.ui.auth.AuthScreen +import dev.yaseyo.onboarding.ui.auth.SignInSheetScreen +import dev.yaseyo.onboarding.ui.auth.SignUpSheetScreen import dev.yaseyo.onboarding.ui.profilesetup.ProfileSetupScreen internal class OnboardingFeatureNavigation : FeatureNavigation { override val navEntryProvider: EntryProviderScope.() -> Unit = { - entry { + entry { AuthScreen() } entry { ProfileSetupScreen() } + entry(metadata = BottomSheetSceneStrategy.bottomSheet()) { + SignInSheetScreen() + } + entry(metadata = BottomSheetSceneStrategy.bottomSheet()) { + SignUpSheetScreen() + } } } diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ResolveStartDestinationUseCase.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ResolveStartDestinationUseCase.kt index e3c83af..143c2ad 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ResolveStartDestinationUseCase.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ResolveStartDestinationUseCase.kt @@ -29,7 +29,7 @@ class ResolveStartDestinationUseCase( return when (appState) { AppState.FullySetup -> Home - AppState.RequiresLogin -> OnboardingRoutes.Auth + AppState.RequiresLogin -> OnboardingRoutes.Auth.Landing AppState.RequiresProfileSetup -> OnboardingRoutes.ProfileSetup } diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheet.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheet.kt index 86c9727..12d320f 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheet.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheet.kt @@ -130,8 +130,6 @@ internal fun SignInSheetContent( } } -// --- Private field components --- - @Composable private fun SheetEmailField( state: TextFieldState, diff --git a/libs/navigation/build.gradle.kts b/libs/navigation/build.gradle.kts index fe2a55d..b1755d2 100644 --- a/libs/navigation/build.gradle.kts +++ b/libs/navigation/build.gradle.kts @@ -1,9 +1,10 @@ plugins { id("yaseyo.kmp.library") + alias(libs.plugins.composeCompiler) } kotlin { - androidLibrary { + android { namespace = "dev.yaseyo.navigation" } @@ -19,8 +20,10 @@ kotlin { androidMain { dependencies { api(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) implementation(libs.koin.core) implementation(libs.koin.android) + implementation(libs.compose.material3) } } } diff --git a/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/BottomSheetSceneStrategy.kt b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/BottomSheetSceneStrategy.kt new file mode 100644 index 0000000..bcaa264 --- /dev/null +++ b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/BottomSheetSceneStrategy.kt @@ -0,0 +1,84 @@ +package dev.yaseyo.navigation + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.rememberLifecycleOwner +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavMetadataKey +import androidx.navigation3.runtime.get +import androidx.navigation3.runtime.metadata +import androidx.navigation3.scene.OverlayScene +import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.SceneStrategy +import androidx.navigation3.scene.SceneStrategyScope +import dev.yaseyo.navigation.BottomSheetSceneStrategy.Companion.bottomSheet + +/** An [OverlayScene] that renders an [entry] within a [ModalBottomSheet]. */ +@OptIn(ExperimentalMaterial3Api::class) +internal data class BottomSheetScene( + override val key: T, + override val previousEntries: List>, + override val overlaidEntries: List>, + private val entry: NavEntry, + private val modalBottomSheetProperties: ModalBottomSheetProperties, + private val onBack: () -> Unit, +) : OverlayScene { + override val entries: List> = listOf(entry) + + override val content: @Composable () -> Unit = { + val lifecycleOwner = rememberLifecycleOwner() + ModalBottomSheet( + onDismissRequest = onBack, + properties = modalBottomSheetProperties, + ) { + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + entry.Content() + } + } + } +} + +/** + * A [SceneStrategy] that displays entries that have added [bottomSheet] to their [NavEntry.metadata] + * within a [ModalBottomSheet] instance. + * + * This strategy should always be added before any non-overlay scene strategies. + */ +@OptIn(ExperimentalMaterial3Api::class) +class BottomSheetSceneStrategy : SceneStrategy { + override fun SceneStrategyScope.calculateScene(entries: List>): Scene? { + val lastEntry = entries.lastOrNull() ?: return null + val bottomSheetProperties = lastEntry.metadata[BottomSheetKey] ?: return null + return bottomSheetProperties.let { properties -> + @Suppress("UNCHECKED_CAST") + BottomSheetScene( + key = lastEntry.contentKey as T, + previousEntries = entries.dropLast(1), + overlaidEntries = entries.dropLast(1), + entry = lastEntry, + modalBottomSheetProperties = properties, + onBack = onBack, + ) + } + } + + companion object { + /** + * Function to be called on the [NavEntry.metadata] to mark this entry as something that + * should be displayed within a [ModalBottomSheet]. + * + * @param modalBottomSheetProperties properties that should be passed to the containing + * [ModalBottomSheet]. + */ + fun bottomSheet(modalBottomSheetProperties: ModalBottomSheetProperties = ModalBottomSheetProperties()) = + metadata { + put(BottomSheetKey, modalBottomSheetProperties) + } + + object BottomSheetKey : NavMetadataKey + } +} From d570b92821c8b5d08b56413252736852f5c27445 Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 15 May 2026 14:47:54 +0900 Subject: [PATCH 21/24] Rename AuthScreen to AuthLandingScreen and update bottom sheet styling - Rename `AuthScreen` to `AuthLandingScreen` across Android, iOS, and common modules - Update `BottomSheetSceneStrategy` to use `YaseyoTheme`, elevated background color, and skip partial expansion - Add scroll support and IME padding to `SignUpSheet` and `SignInSheet` - Replace "Log in" button with a clickable annotated string in `AuthLandingScreen` - Add `:libs:design` dependency to `:libs:navigation` --- .../onboarding/OnboardingFeatureNavigation.kt | 4 +- .../{AuthScreen.kt => AuthLandingScreen.kt} | 43 +++++++++++-------- .../yaseyo/onboarding/ui/auth/SignInSheet.kt | 12 +++--- .../yaseyo/onboarding/ui/auth/SignUpSheet.kt | 5 +++ .../yaseyo/onboarding/AuthViewControllers.kt | 4 +- iosApp/iosApp/AppCoordinator.swift | 2 +- libs/navigation/build.gradle.kts | 1 + .../navigation/BottomSheetSceneStrategy.kt | 19 +++++--- 8 files changed, 55 insertions(+), 35 deletions(-) rename feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/{AuthScreen.kt => AuthLandingScreen.kt} (88%) diff --git a/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt index 14bd5c5..45e0b4f 100644 --- a/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt +++ b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt @@ -8,7 +8,7 @@ import dev.yaseyo.navigation.AppRoute import dev.yaseyo.navigation.BottomSheetSceneStrategy import dev.yaseyo.navigation.FeatureNavigation import dev.yaseyo.onboarding.navigation.OnboardingRoutes -import dev.yaseyo.onboarding.ui.auth.AuthScreen +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 @@ -16,7 +16,7 @@ import dev.yaseyo.onboarding.ui.profilesetup.ProfileSetupScreen internal class OnboardingFeatureNavigation : FeatureNavigation { override val navEntryProvider: EntryProviderScope.() -> Unit = { entry { - AuthScreen() + AuthLandingScreen() } entry { ProfileSetupScreen() diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthScreen.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthLandingScreen.kt similarity index 88% rename from feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthScreen.kt rename to feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthLandingScreen.kt index 4290e24..daab7fb 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthScreen.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthLandingScreen.kt @@ -1,6 +1,5 @@ package dev.yaseyo.onboarding.ui.auth -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -15,9 +14,7 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults @@ -27,7 +24,10 @@ 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.LinkAnnotation +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -37,7 +37,7 @@ import dev.yaseyo.design.YaseyoTheme import org.koin.compose.viewmodel.koinViewModel @Composable -internal fun AuthScreen(viewModel: AuthViewModel = koinViewModel()) { +internal fun AuthLandingScreen(viewModel: AuthViewModel = koinViewModel()) { val snackBarHostState = LocalSnackBarHostState.current AuthContent(eventHandler = viewModel) @@ -47,7 +47,6 @@ internal fun AuthScreen(viewModel: AuthViewModel = koinViewModel()) { } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun AuthContent( eventHandler: AuthEventHandler, @@ -156,7 +155,7 @@ private fun AuthOptions( ) { Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Button( onClick = onSignUpClick, @@ -172,19 +171,25 @@ private fun AuthOptions( fontSize = 16.sp, ) } - OutlinedButton( - onClick = onSignInClick, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.large, - border = BorderStroke(1.dp, YaseyoTheme.colors.borderDefault), - ) { - Text( - text = "Log in", - modifier = Modifier.padding(vertical = 8.dp), - fontSize = 16.sp, - color = YaseyoTheme.colors.contentPrimary, - ) - } + Text( + text = buildAnnotatedString { + append("Already have an account? ") + val linkStart = length + append("Sign in instead.") + addLink( + clickable = LinkAnnotation.Clickable( + tag = "sign_in", + linkInteractionListener = { onSignInClick() }, + ), + start = linkStart, + end = length, + ) + }, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + fontSize = 16.sp, + color = YaseyoTheme.colors.contentPrimary, + textAlign = TextAlign.Center, + ) } } diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheet.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheet.kt index 12d320f..6c00ab8 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheet.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheet.kt @@ -4,13 +4,16 @@ 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.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.OutputTransformation import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff @@ -69,6 +72,8 @@ internal fun SignInSheetContent( Column( modifier = modifier .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .imePadding() .padding(horizontal = 24.dp) .padding(bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -105,9 +110,7 @@ internal fun SignInSheetContent( ) }, enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), + modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.large, colors = ButtonDefaults.buttonColors( containerColor = YaseyoTheme.colors.accentDefault, @@ -141,7 +144,7 @@ private fun SheetEmailField( ) { Text( text = "Email", - fontSize = 13.sp, + fontSize = 12.sp, fontWeight = FontWeight.Medium, color = YaseyoTheme.colors.contentSecondary, ) @@ -165,7 +168,6 @@ private fun SheetEmailField( } } -// isPasswordVisible is UI-local — kept inside this composable since nothing outside it needs to know. @Composable private fun SheetPasswordField( state: TextFieldState, diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignUpSheet.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignUpSheet.kt index 6b7580c..306c8d1 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignUpSheet.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignUpSheet.kt @@ -3,13 +3,16 @@ 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.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.OutputTransformation import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff @@ -66,6 +69,8 @@ internal fun SignUpSheetContent( Column( modifier = modifier .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .imePadding() .padding(horizontal = 24.dp) .padding(bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(16.dp), 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 1e685c4..2d133b2 100644 --- a/feature/onboarding/src/iosMain/kotlin/dev/yaseyo/onboarding/AuthViewControllers.kt +++ b/feature/onboarding/src/iosMain/kotlin/dev/yaseyo/onboarding/AuthViewControllers.kt @@ -1,6 +1,6 @@ package dev.yaseyo.onboarding import dev.yaseyo.design.createYaseyoUiViewController -import dev.yaseyo.onboarding.ui.auth.AuthScreen +import dev.yaseyo.onboarding.ui.auth.AuthLandingScreen -fun authScreenViewController() = createYaseyoUiViewController { AuthScreen() } +fun authLandingScreenViewController() = createYaseyoUiViewController { AuthLandingScreen() } diff --git a/iosApp/iosApp/AppCoordinator.swift b/iosApp/iosApp/AppCoordinator.swift index 09b838b..1c8ac96 100644 --- a/iosApp/iosApp/AppCoordinator.swift +++ b/iosApp/iosApp/AppCoordinator.swift @@ -29,7 +29,7 @@ class AppCoordinator: ObservableObject { let startRoute = try? await resolveStartDestinationUseCase.execute() let vc = startRoute.flatMap { route in makeViewController(for: route) - } ?? authScreenViewController() + } ?? authLandingScreenViewController() navigationController?.setViewControllers([vc], animated: false) } diff --git a/libs/navigation/build.gradle.kts b/libs/navigation/build.gradle.kts index b1755d2..d474acb 100644 --- a/libs/navigation/build.gradle.kts +++ b/libs/navigation/build.gradle.kts @@ -19,6 +19,7 @@ kotlin { androidMain { dependencies { + implementation(projects.libs.design) api(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) implementation(libs.koin.core) 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 bcaa264..f2f8066 100644 --- a/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/BottomSheetSceneStrategy.kt +++ b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/BottomSheetSceneStrategy.kt @@ -3,6 +3,7 @@ package dev.yaseyo.navigation import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.lifecycle.compose.LocalLifecycleOwner @@ -15,6 +16,7 @@ import androidx.navigation3.scene.OverlayScene import androidx.navigation3.scene.Scene import androidx.navigation3.scene.SceneStrategy import androidx.navigation3.scene.SceneStrategyScope +import dev.yaseyo.design.YaseyoTheme import dev.yaseyo.navigation.BottomSheetSceneStrategy.Companion.bottomSheet /** An [OverlayScene] that renders an [entry] within a [ModalBottomSheet]. */ @@ -31,12 +33,17 @@ internal data class BottomSheetScene( override val content: @Composable () -> Unit = { val lifecycleOwner = rememberLifecycleOwner() - ModalBottomSheet( - onDismissRequest = onBack, - properties = modalBottomSheetProperties, - ) { - CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { - entry.Content() + + YaseyoTheme { + ModalBottomSheet( + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + onDismissRequest = onBack, + properties = modalBottomSheetProperties, + containerColor = YaseyoTheme.colors.backgroundElevated, + ) { + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + entry.Content() + } } } } From 50ad48184fba4674794eebc651c551f0f5e328c0 Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 15 May 2026 15:11:27 +0900 Subject: [PATCH 22/24] Implement modal navigation and update onboarding routes for iOS --- .../yaseyo/onboarding/navigation/OnboardingRoutes.kt | 5 +++-- .../ui/auth/{SignInSheet.kt => SignInSheetScreen.kt} | 0 .../ui/auth/{SignUpSheet.kt => SignUpSheetScreen.kt} | 0 .../dev/yaseyo/onboarding/AuthViewControllers.kt | 6 ++++++ iosApp/iosApp/AppCoordinator.swift | 11 +++++++++-- .../dev/yaseyo/design/YaseyoUiViewController.kt | 1 + libs/navigation/build.gradle.kts | 1 + .../kotlin/dev/yaseyo/navigation/ModalRoute.kt | 3 +++ 8 files changed, 23 insertions(+), 4 deletions(-) rename feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/{SignInSheet.kt => SignInSheetScreen.kt} (100%) rename feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/{SignUpSheet.kt => SignUpSheetScreen.kt} (100%) create mode 100644 libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/ModalRoute.kt 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 2db6f9b..8b092b4 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,14 +1,15 @@ package dev.yaseyo.onboarding.navigation import dev.yaseyo.navigation.AppRoute +import dev.yaseyo.navigation.ModalRoute sealed interface OnboardingRoutes : AppRoute { data object Auth : OnboardingRoutes { data object Landing : OnboardingRoutes - data object SignIn : OnboardingRoutes + data object SignIn : OnboardingRoutes, ModalRoute - data object SignUp : OnboardingRoutes + data object SignUp : OnboardingRoutes, ModalRoute } data object ProfileSetup : OnboardingRoutes diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheet.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheetScreen.kt similarity index 100% rename from feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheet.kt rename to feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheetScreen.kt diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignUpSheet.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignUpSheetScreen.kt similarity index 100% rename from feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignUpSheet.kt rename to feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignUpSheetScreen.kt 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 2d133b2..77694ad 100644 --- a/feature/onboarding/src/iosMain/kotlin/dev/yaseyo/onboarding/AuthViewControllers.kt +++ b/feature/onboarding/src/iosMain/kotlin/dev/yaseyo/onboarding/AuthViewControllers.kt @@ -2,5 +2,11 @@ package dev.yaseyo.onboarding 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 fun authLandingScreenViewController() = createYaseyoUiViewController { AuthLandingScreen() } + +fun signInScreenViewController() = createYaseyoUiViewController { SignInSheetScreen() } + +fun signUpScreenViewController() = createYaseyoUiViewController { SignUpSheetScreen() } diff --git a/iosApp/iosApp/AppCoordinator.swift b/iosApp/iosApp/AppCoordinator.swift index 1c8ac96..79333b8 100644 --- a/iosApp/iosApp/AppCoordinator.swift +++ b/iosApp/iosApp/AppCoordinator.swift @@ -20,7 +20,12 @@ class AppCoordinator: ObservableObject { navigationController?.popViewController(animated: true) case .goTo(let navigationEvent): guard let vc = makeViewController(for: navigationEvent.route) else { return } - navigationController?.pushViewController(vc, animated: true) + if (navigationEvent.route is ModalRoute) { + vc.modalPresentationStyle = .pageSheet + navigationController?.present(vc, animated: true) + } else { + navigationController?.pushViewController(vc, animated: true) + } } } } @@ -34,7 +39,9 @@ class AppCoordinator: ObservableObject { } private func makeViewController(for route: any AppRoute) -> UIViewController? { - if route is OnboardingRoutesAuth { return authScreenViewController() } + 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 return nil diff --git a/libs/design/src/iosMain/kotlin/dev/yaseyo/design/YaseyoUiViewController.kt b/libs/design/src/iosMain/kotlin/dev/yaseyo/design/YaseyoUiViewController.kt index fde9ff2..c9e29ee 100644 --- a/libs/design/src/iosMain/kotlin/dev/yaseyo/design/YaseyoUiViewController.kt +++ b/libs/design/src/iosMain/kotlin/dev/yaseyo/design/YaseyoUiViewController.kt @@ -13,6 +13,7 @@ fun createYaseyoUiViewController(screen: @Composable () -> Unit) = ComposeUIViewController { YaseyoTheme { Scaffold( + containerColor = YaseyoTheme.colors.backgroundBase, snackbarHost = { SnackbarHost(hostState = LocalSnackBarHostState.current) }, diff --git a/libs/navigation/build.gradle.kts b/libs/navigation/build.gradle.kts index d474acb..2b57938 100644 --- a/libs/navigation/build.gradle.kts +++ b/libs/navigation/build.gradle.kts @@ -14,6 +14,7 @@ kotlin { implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.coroutines.core) implementation(libs.koin.core) + implementation(libs.compose.runtime) } } diff --git a/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/ModalRoute.kt b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/ModalRoute.kt new file mode 100644 index 0000000..35e49cd --- /dev/null +++ b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/ModalRoute.kt @@ -0,0 +1,3 @@ +package dev.yaseyo.navigation + +interface ModalRoute From 515062b98f69b5bc25564a535a78363b8e1dd7c4 Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 15 May 2026 15:31:50 +0900 Subject: [PATCH 23/24] Update JVM target to 17 and improve bottom sheet inset handling and focus management --- composeApp/build.gradle.kts | 6 +++--- .../onboarding/ui/auth/SignInSheetScreen.kt | 14 +++++++++++++- .../onboarding/ui/auth/SignUpSheetScreen.kt | 16 +++++++++++++--- .../navigation/BottomSheetSceneStrategy.kt | 10 ++++++++++ 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index d3329ae..7fc4d3e 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -11,7 +11,7 @@ plugins { kotlin { androidTarget { compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) + jvmTarget.set(JvmTarget.JVM_17) } } @@ -79,8 +79,8 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } } 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 6c00ab8..29ba7ea 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheetScreen.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheetScreen.kt @@ -35,6 +35,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -138,6 +140,10 @@ private fun SheetEmailField( state: TextFieldState, modifier: Modifier = Modifier, ) { + val focusRequester = remember { + FocusRequester() + } + Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp), @@ -149,7 +155,9 @@ private fun SheetEmailField( color = YaseyoTheme.colors.contentSecondary, ) TextField( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), state = state, placeholder = { Text(text = "yamadataro@yaseyo.com", color = YaseyoTheme.colors.contentTertiary) @@ -166,6 +174,10 @@ private fun SheetEmailField( ), ) } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } } @Composable 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 306c8d1..f5cd9d4 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignUpSheetScreen.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignUpSheetScreen.kt @@ -32,6 +32,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -119,13 +121,15 @@ internal fun SignUpSheetContent( } } -// --- Private field components --- - @Composable private fun SheetEmailField( state: TextFieldState, modifier: Modifier = Modifier, ) { + val focusRequester = remember { + FocusRequester() + } + Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp), @@ -137,7 +141,9 @@ private fun SheetEmailField( color = YaseyoTheme.colors.contentSecondary, ) TextField( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), state = state, placeholder = { Text(text = "yamadataro@yaseyo.com", color = YaseyoTheme.colors.contentTertiary) @@ -154,6 +160,10 @@ private fun SheetEmailField( ), ) } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } } @Composable 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 f2f8066..96d5e2d 100644 --- a/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/BottomSheetSceneStrategy.kt +++ b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/BottomSheetSceneStrategy.kt @@ -1,11 +1,17 @@ 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 import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.rememberLifecycleOwner import androidx.navigation3.runtime.NavEntry @@ -36,10 +42,14 @@ internal data class BottomSheetScene( 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() From bf9521ee51d2602ffa7d67b4d9ae2017d746c0c7 Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 15 May 2026 15:46:52 +0900 Subject: [PATCH 24/24] Enable grabber for iOS modal sheets and update accent colors in design module --- iosApp/iosApp/AppCoordinator.swift | 3 +++ .../src/commonMain/kotlin/dev/yaseyo/design/AppColors.kt | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/iosApp/iosApp/AppCoordinator.swift b/iosApp/iosApp/AppCoordinator.swift index 79333b8..b7c8bc5 100644 --- a/iosApp/iosApp/AppCoordinator.swift +++ b/iosApp/iosApp/AppCoordinator.swift @@ -22,6 +22,9 @@ class AppCoordinator: ObservableObject { guard let vc = makeViewController(for: navigationEvent.route) else { return } if (navigationEvent.route is ModalRoute) { vc.modalPresentationStyle = .pageSheet + if let sheet = vc.sheetPresentationController { + sheet.prefersGrabberVisible = true + } navigationController?.present(vc, animated: true) } else { navigationController?.pushViewController(vc, animated: true) diff --git a/libs/design/src/commonMain/kotlin/dev/yaseyo/design/AppColors.kt b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/AppColors.kt index 6eed80a..6af52e3 100644 --- a/libs/design/src/commonMain/kotlin/dev/yaseyo/design/AppColors.kt +++ b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/AppColors.kt @@ -66,10 +66,10 @@ val DarkAppColors = AppColors( contentSecondary = Color(0xFFC0C9C2), contentTertiary = Color(0xFF8A938C), contentDisabled = Color(0xFF404943), - contentOnAccent = Color(0xFF00382A), - accentDefault = Color(0xFF89D5B5), - accentHover = Color(0xFFA5E8C9), - accentSubtle = Color(0xFF005140), + contentOnAccent = Color(0xFFE1E3DF), + accentDefault = Color(0xFF1A6B50), + accentHover = Color(0xFF226B52), + accentSubtle = Color(0xFF003D2E), accentMuted = Color(0xFF404943), borderSubtle = Color(0xFF2E3130), borderDefault = Color(0xFF404943),