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/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 34e0785..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) } } @@ -19,6 +19,7 @@ kotlin { androidMain.dependencies { // Libs implementation(projects.libs.di) + implementation(projects.libs.design) // Dependencies implementation(libs.androidx.startup.runtime) @@ -78,8 +79,8 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } } diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt index b678bda..a8c0c93 100644 --- a/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt @@ -3,39 +3,65 @@ 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.runtime.remember import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay +import dev.yaseyo.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 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 allFeatureNavigation = getKoin().getAll() +fun App(navigator: Navigator) = + YaseyoTheme { + val appRouter = koinInject() + val allFeatureNavigation = getKoin().getAll() + val bottomSheetStrategy = remember { BottomSheetSceneStrategy() } - MaterialTheme { - Scaffold { padding -> - NavDisplay( - backStack = navigator.backStack, - modifier = Modifier.padding(padding), - onBack = { navigator.goBack() }, - entryDecorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator(), - ), - entryProvider = allFeatureNavigation.registerAll(), - ) + LaunchedEffect(appRouter) { + appRouter.events.collect { event -> + when (event) { + is NavigationEvent.GoTo -> navigator.goTo(event.route) + is NavigationEvent.GoBack -> navigator.goBack() + } + } + } + + 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(), + sceneStrategies = listOf(bottomSheetStrategy), + ) + } } } -} private fun List.registerAll(): (AppRoute) -> NavEntry = entryProvider { diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt index 166fcca..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.api.ResolveAppStateUseCase -import dev.yaseyo.onboarding.api.model.AppState -import dev.yaseyo.onboarding.api.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/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/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureNavigation.kt b/feature/onboarding/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureNavigation.kt deleted file mode 100644 index 69e3fbf..0000000 --- a/feature/onboarding/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureNavigation.kt +++ /dev/null @@ -1,19 +0,0 @@ -package dev.yaseyo.onboarding.android - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.navigation3.runtime.EntryProviderScope -import dev.yaseyo.navigation.AppRoute -import dev.yaseyo.navigation.FeatureNavigation -import dev.yaseyo.onboarding.api.navigation.OnboardingRoutes - -internal class OnboardingFeatureNavigation : FeatureNavigation { - override val navEntryProvider: EntryProviderScope.() -> Unit = { - entry { - Box(modifier = Modifier.fillMaxSize().background(Color.Red)) - } - } -} diff --git a/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/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/di/OnboardingModule.kt b/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/di/OnboardingModule.kt deleted file mode 100644 index 2c0d265..0000000 --- a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/di/OnboardingModule.kt +++ /dev/null @@ -1,9 +0,0 @@ -package dev.yaseyo.onboarding.api.di - -import dev.yaseyo.onboarding.api.ResolveAppStateUseCase -import org.koin.core.module.dsl.factoryOf -import org.koin.dsl.module - -val onboardingModule = module { - factoryOf(::ResolveAppStateUseCase) -} diff --git a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/navigation/OnboardingRoutes.kt b/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/navigation/OnboardingRoutes.kt deleted file mode 100644 index 2baac56..0000000 --- a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/navigation/OnboardingRoutes.kt +++ /dev/null @@ -1,9 +0,0 @@ -package dev.yaseyo.onboarding.api.navigation - -import dev.yaseyo.navigation.AppRoute - -sealed interface OnboardingRoutes : AppRoute { - data object Auth : OnboardingRoutes - - data object ProfileSetup : OnboardingRoutes -} diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index 41fe8ea..08d597e 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -1,15 +1,74 @@ plugins { id("yaseyo.kmp.library") + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) } kotlin { - androidLibrary { + android { 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) + implementation(libs.koin.core.viewmodel) + 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) + api(projects.libs.navigation) + implementation(projects.libs.design) + + implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.compose.uiToolingPreview) + implementation(libs.compose.material.icons.extended) + implementation(libs.compose.navigationevent) + } + } + + commonTest { + dependencies { + implementation(libs.kotlin.test) + } + } + + androidMain { + dependencies { + implementation(libs.koin.android) + implementation(libs.koin.compose.nav3) + implementation(libs.androidx.ui.tooling) + } + } + + getByName("androidDeviceTest") { + dependencies { + implementation(libs.androidx.runner) + implementation(libs.androidx.core) + implementation(libs.androidx.testExt.junit) + } + } + + iosMain { + dependencies { + } } } } diff --git a/feature/onboarding/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/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt new file mode 100644 index 0000000..45e0b4f --- /dev/null +++ b/feature/onboarding/src/androidMain/kotlin/dev/yaseyo/onboarding/OnboardingFeatureNavigation.kt @@ -0,0 +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.AuthLandingScreen +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 { + AuthLandingScreen() + } + entry { + ProfileSetupScreen() + } + entry(metadata = BottomSheetSceneStrategy.bottomSheet()) { + SignInSheetScreen() + } + entry(metadata = BottomSheetSceneStrategy.bottomSheet()) { + SignUpSheetScreen() + } + } +} diff --git a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/ResolveAppStateUseCase.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ResolveStartDestinationUseCase.kt similarity index 50% rename from feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/ResolveAppStateUseCase.kt rename to feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ResolveStartDestinationUseCase.kt index 06ea145..143c2ad 100644 --- a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/ResolveAppStateUseCase.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ResolveStartDestinationUseCase.kt @@ -1,16 +1,19 @@ -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.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.Landing + + 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 new file mode 100644 index 0000000..5802678 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/di/OnboardingModule.kt @@ -0,0 +1,18 @@ +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.auth.AuthViewModel +import dev.yaseyo.onboarding.ui.profilesetup.ProfileSetupViewModel +import org.koin.core.module.dsl.factoryOf +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module + +val onboardingModule = module { + factoryOf(::ResolveStartDestinationUseCase) + viewModelOf(::AuthViewModel) + + single { OnboardingApiImpl(client = get()) } + viewModelOf(::ProfileSetupViewModel) +} 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/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/navigation/OnboardingRoutes.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/navigation/OnboardingRoutes.kt new file mode 100644 index 0000000..8b092b4 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/navigation/OnboardingRoutes.kt @@ -0,0 +1,16 @@ +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, ModalRoute + + data object SignUp : OnboardingRoutes, ModalRoute + } + + data object ProfileSetup : OnboardingRoutes +} 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/auth/AuthEventHandler.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthEventHandler.kt new file mode 100644 index 0000000..5b50acd --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthEventHandler.kt @@ -0,0 +1,19 @@ +package dev.yaseyo.onboarding.ui.auth + +interface AuthEventHandler { + fun onSignInClicked() + + fun onSignUpClicked() + + fun onForgotPasswordClicked() + + fun signInWithEmailAndPassword( + email: String, + password: String, + ) + + fun signUpWithEmailAndPassword( + email: String, + password: String, + ) +} diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthLandingScreen.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthLandingScreen.kt new file mode 100644 index 0000000..daab7fb --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthLandingScreen.kt @@ -0,0 +1,289 @@ +package dev.yaseyo.onboarding.ui.auth + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +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.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.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 +import dev.yaseyo.design.LocalSnackBarHostState +import dev.yaseyo.design.YaseyoPreview +import dev.yaseyo.design.YaseyoTheme +import org.koin.compose.viewmodel.koinViewModel + +@Composable +internal fun AuthLandingScreen(viewModel: AuthViewModel = koinViewModel()) { + val snackBarHostState = LocalSnackBarHostState.current + + AuthContent(eventHandler = viewModel) + + LaunchedEffect(viewModel) { + viewModel.action.collect { snackBarHostState.showSnackbar(it) } + } +} + +@Composable +private fun AuthContent( + eventHandler: AuthEventHandler, + modifier: Modifier = Modifier, +) { + AuthLandingContent(eventHandler = eventHandler, modifier = modifier) +} + +@Composable +internal fun AuthLandingContent( + eventHandler: AuthEventHandler, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 24.dp, vertical = 48.dp), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AuthHeader() + AuthOptions( + onSignInClick = eventHandler::onSignInClicked, + onSignUpClick = eventHandler::onSignUpClicked, + ) + TermsText() + } +} + +@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 = submitLabel, + modifier = Modifier.padding(vertical = 8.dp), + fontSize = 16.sp, + ) + } + } +} + +@Composable +private fun AuthHeader(modifier: Modifier = Modifier) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + 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 AuthOptions( + onSignInClick: () -> Unit, + onSignUpClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + 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, + ) + } + 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, + ) + } +} + +@Composable +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, + state: TextFieldState, + placeholder: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = label, + 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 AppLogo(modifier: Modifier = Modifier) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + Text(text = "🌿", fontSize = 36.sp) + } +} + +internal val previewAuthUiState = AuthUiState() + +internal val previewAuthEventHandler = object : AuthEventHandler { + override fun onSignInClicked() {} + + override fun onSignUpClicked() {} + + override fun onForgotPasswordClicked() {} + + override fun signInWithEmailAndPassword( + email: String, + password: String, + ) {} + + override fun signUpWithEmailAndPassword( + email: String, + password: String, + ) {} +} + +@Preview(showBackground = true) +@Composable +private fun AuthLandingLightPreview() { + YaseyoPreview(darkTheme = false) { + AuthLandingContent(eventHandler = previewAuthEventHandler) + } +} + +@Preview(showBackground = true) +@Composable +private fun AuthLandingDarkPreview() { + YaseyoPreview(darkTheme = true) { + AuthLandingContent(eventHandler = previewAuthEventHandler) + } +} + +@Preview(showBackground = true) +@Composable +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 new file mode 100644 index 0000000..906508b --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthUiState.kt @@ -0,0 +1,5 @@ +package dev.yaseyo.onboarding.ui.auth + +data class AuthUiState( + 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 new file mode 100644 index 0000000..da3fa64 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/AuthViewModel.kt @@ -0,0 +1,94 @@ +package dev.yaseyo.onboarding.ui.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.yaseyo.auth.api.AuthRepository +import dev.yaseyo.navigation.AppRouter +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 + +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.BUFFERED) + val action = _action.receiveAsFlow() + + override fun onSignInClicked() { + router.navigate(OnboardingRoutes.Auth.SignIn) + } + + override fun onSignUpClicked() { + router.navigate(OnboardingRoutes.Auth.SignUp) + } + + override fun onForgotPasswordClicked() { /* TODO */ } + + override fun signInWithEmailAndPassword( + email: String, + password: String, + ) { + _uiState.update { + it.copy(isLoading = true) + } + + viewModelScope.launch { + val signInResult = authRepository.signInWithEmailAndPassword( + email = email, + password = password, + ) + + signInResult.fold( + onSuccess = { + router.navigate(OnboardingRoutes.ProfileSetup) + }, + onFailure = { + _action.send("Something went wrong") + }, + ) + + _uiState.update { + it.copy(isLoading = false) + } + } + } + + 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") + }, + ) + + _uiState.update { + it.copy(isLoading = false) + } + } + } +} 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 new file mode 100644 index 0000000..29ba7ea --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignInSheetScreen.kt @@ -0,0 +1,258 @@ +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.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 +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.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 +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() + .verticalScroll(rememberScrollState()) + .imePadding() + .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(), + 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, + ) + } + } + } +} + +@Composable +private fun SheetEmailField( + state: TextFieldState, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { + FocusRequester() + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Email", + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = YaseyoTheme.colors.contentSecondary, + ) + TextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + 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, + ), + ) + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@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/SignUpSheetScreen.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignUpSheetScreen.kt new file mode 100644 index 0000000..f5cd9d4 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/auth/SignUpSheetScreen.kt @@ -0,0 +1,242 @@ +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 +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.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 +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() + .verticalScroll(rememberScrollState()) + .imePadding() + .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, + ) + } + } + } +} + +@Composable +private fun SheetEmailField( + state: TextFieldState, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { + FocusRequester() + } + + 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() + .focusRequester(focusRequester), + 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, + ), + ) + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@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/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..60bf42d --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupEventHandler.kt @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..8a47f2b --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupScreen.kt @@ -0,0 +1,209 @@ +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.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 +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.widgets.AboutYouStep +import dev.yaseyo.onboarding.ui.profilesetup.widgets.NameStep +import dev.yaseyo.onboarding.ui.profilesetup.widgets.OnboardingProgressBar +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(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 = colors.contentPrimary, + ) + } + Text( + text = "Yaseyo", + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + 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, + ) + 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, + ) + } + } + + 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 = colors.accentDefault, + disabledContainerColor = colors.accentSubtle, + ), + enabled = when (state.currentStepIndex) { + 0 -> state.name.isNotBlank() + 1 -> state.sex != null + else -> true + }, + ) { + Text( + text = "CONTINUE →", + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.sp, + color = colors.contentOnAccent, + ) + } + } + } +} + +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 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() {} +} + +@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/ProfileSetupUiState.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupUiState.kt new file mode 100644 index 0000000..0767674 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupUiState.kt @@ -0,0 +1,45 @@ +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 + +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..d70abbf --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/ProfileSetupViewModel.kt @@ -0,0 +1,75 @@ +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 dev.yaseyo.user.api.Sex +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 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 + 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/widgets/AboutYouStep.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/AboutYouStep.kt new file mode 100644 index 0000000..a9d1947 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/AboutYouStep.kt @@ -0,0 +1,145 @@ +package dev.yaseyo.onboarding.ui.profilesetup.widgets + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.yaseyo.design.YaseyoTheme +import dev.yaseyo.user.api.Sex + +@Composable +internal fun AboutYouStep( + sex: Sex?, + dobMonth: Int, + dobDay: Int, + dobYear: Int, + title: String, + subtitle: String?, + onSexChanged: (Sex) -> Unit, + onMonthChanged: (Int) -> Unit, + onDayChanged: (Int) -> Unit, + onYearChanged: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val colors = YaseyoTheme.colors + + Column(modifier = modifier) { + Text( + text = title, + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + color = colors.contentPrimary, + ) + + if (subtitle != null) { + Spacer(Modifier.height(6.dp)) + Text( + text = subtitle, + fontSize = 15.sp, + color = colors.contentSecondary, + ) + } + + Spacer(Modifier.height(24.dp)) + + SectionLabel(text = "SELECT BIOLOGICAL SEX") + + Spacer(Modifier.height(12.dp)) + + SexSelectionRow(selected = sex, onSelected = onSexChanged) + + Spacer(Modifier.height(28.dp)) + + SectionLabel(text = "DATE OF BIRTH") + + Spacer(Modifier.height(12.dp)) + + WheelDatePicker( + month = dobMonth, + day = dobDay, + year = dobYear, + onMonthChanged = onMonthChanged, + onDayChanged = onDayChanged, + onYearChanged = onYearChanged, + ) + } +} + +@Composable +private fun SectionLabel( + text: String, + modifier: Modifier = Modifier, +) { + val colors = YaseyoTheme.colors + Text( + text = text, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + color = colors.contentTertiary, + modifier = modifier, + ) +} + +@Preview(showBackground = true) +@Composable +private fun AboutYouStepNoneSelectedPreview() { + YaseyoTheme(darkTheme = false) { + AboutYouStep( + sex = null, + dobMonth = 2, + dobDay = 14, + dobYear = 1994, + title = "Tell us about yourself", + subtitle = "This helps us calculate your calorie needs accurately.", + onSexChanged = {}, + onMonthChanged = {}, + onDayChanged = {}, + onYearChanged = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun AboutYouStepFemalePreview() { + YaseyoTheme(darkTheme = false) { + AboutYouStep( + sex = Sex.FEMALE, + dobMonth = 2, + dobDay = 14, + dobYear = 1994, + title = "Tell us about yourself", + subtitle = "This helps us calculate your calorie needs accurately.", + onSexChanged = {}, + onMonthChanged = {}, + onDayChanged = {}, + onYearChanged = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun AboutYouStepDarkPreview() { + YaseyoTheme(darkTheme = true) { + AboutYouStep( + sex = Sex.MALE, + dobMonth = 6, + dobDay = 1, + dobYear = 1990, + title = "Tell us about yourself", + subtitle = "This helps us calculate your calorie needs accurately.", + onSexChanged = {}, + onMonthChanged = {}, + onDayChanged = {}, + onYearChanged = {}, + ) + } +} diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/NameStep.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/NameStep.kt new file mode 100644 index 0000000..58f41b0 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/NameStep.kt @@ -0,0 +1,117 @@ +package dev.yaseyo.onboarding.ui.profilesetup.widgets + +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.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 + +@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 = colors.contentPrimary, + lineHeight = 34.sp, + ) + + if (subtitle != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = subtitle, + fontSize = 15.sp, + color = colors.contentSecondary, + ) + } + + Spacer(Modifier.height(32.dp)) + + OutlinedTextField( + value = name, + onValueChange = onNameChanged, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = "Your name", + color = colors.contentTertiary, + fontSize = 17.sp, + ) + }, + textStyle = TextStyle( + fontSize = 17.sp, + color = colors.contentPrimary, + ), + shape = RoundedCornerShape(16.dp), + colors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = colors.borderDefault, + focusedBorderColor = colors.accentDefault, + unfocusedTextColor = colors.contentPrimary, + focusedTextColor = colors.contentPrimary, + ), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + ) + } +} + +@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 = {}, + ) + } +} 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 new file mode 100644 index 0000000..1cb6329 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/OnboardingProgressBar.kt @@ -0,0 +1,82 @@ +package dev.yaseyo.onboarding.ui.profilesetup.widgets + +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.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 + +@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 = colors.accentDefault, + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + modifier = Modifier.weight(1f), + ) + Text( + text = "$percentage%", + color = colors.contentSecondary, + fontSize = 13.sp, + ) + } + Spacer(Modifier.height(6.dp)) + LinearProgressIndicator( + progress = { currentStep.toFloat() / totalSteps }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp), + color = colors.accentDefault, + trackColor = colors.backgroundSubtle, + strokeCap = StrokeCap.Round, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun OnboardingProgressBarStep1Preview() { + YaseyoTheme(darkTheme = false) { + OnboardingProgressBar(currentStep = 1, totalSteps = 6) + } +} + +@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/widgets/SexCard.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/SexCard.kt new file mode 100644 index 0000000..90e7a14 --- /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) 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 + .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..74c526b --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/WheelDatePicker.kt @@ -0,0 +1,106 @@ +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.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(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..64a8469 --- /dev/null +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ui/profilesetup/widgets/WheelPicker.kt @@ -0,0 +1,102 @@ +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.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) colors.accentDefault else colors.contentTertiary, + ) + } + } + } + + // Thin lines framing the selected center slot + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .offset(y = ItemHeight), + color = colors.borderDefault, + ) + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .offset(y = ItemHeight * 2), + color = colors.borderDefault, + ) + } +} diff --git a/feature/onboarding/src/iosMain/kotlin/dev/yaseyo/onboarding/AuthViewControllers.kt b/feature/onboarding/src/iosMain/kotlin/dev/yaseyo/onboarding/AuthViewControllers.kt new file mode 100644 index 0000000..77694ad --- /dev/null +++ b/feature/onboarding/src/iosMain/kotlin/dev/yaseyo/onboarding/AuthViewControllers.kt @@ -0,0 +1,12 @@ +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/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 6bed243..267aaf9 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" @@ -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" @@ -23,14 +23,16 @@ 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" +composeNavigationEvent = "1.0.1" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -51,6 +53,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" } @@ -60,6 +63,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" } @@ -79,6 +83,8 @@ 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" } +compose-navigationevent = { module = "org.jetbrains.androidx.navigationevent:navigationevent-compose", version.ref = "composeNavigationEvent" } [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 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; diff --git a/iosApp/iosApp/AppCoordinator.swift b/iosApp/iosApp/AppCoordinator.swift new file mode 100644 index 0000000..b7c8bc5 --- /dev/null +++ b/iosApp/iosApp/AppCoordinator.swift @@ -0,0 +1,52 @@ +// +// 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 } + 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) + } + } + } + } + + func resolveInitialDestination() async { + let startRoute = try? await resolveStartDestinationUseCase.execute() + let vc = startRoute.flatMap { route in + makeViewController(for: route) + } ?? authLandingScreenViewController() + navigationController?.setViewControllers([vc], animated: false) + } + + private func makeViewController(for route: any AppRoute) -> UIViewController? { + 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/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index ea1066c..b456fe8 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -1,31 +1,31 @@ import SwiftUI +import Di struct ContentView: View { - @State private var showContent = false + @ObservedObject var coordinator: AppCoordinator + var body: some View { - VStack { - Button("Click me!") { - withAnimation { - showContent = !showContent - } - } + RootNavigationView(coordinator: coordinator) + .ignoresSafeArea() + .task { await coordinator.resolveInitialDestination() } + .task { await coordinator.subscribeToNavigationEvents() } + } +} - if showContent { - VStack(spacing: 16) { - Image(systemName: "swift") - .font(.system(size: 200)) - .foregroundColor(.accentColor) - } - .transition(.move(edge: .top).combined(with: .opacity)) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding() +private struct RootNavigationView: UIViewControllerRepresentable { + let coordinator: AppCoordinator + + func makeUIViewController(context: Context) -> UINavigationController { + let navController = UINavigationController() + coordinator.navigationController = navController + return navController } + + func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {} } 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/design/build.gradle.kts b/libs/design/build.gradle.kts index 564cc9d..93b9dee 100644 --- a/libs/design/build.gradle.kts +++ b/libs/design/build.gradle.kts @@ -1,10 +1,11 @@ plugins { id("yaseyo.kmp.library") + alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) } kotlin { - androidLibrary { + android { namespace = "dev.yaseyo.design" } @@ -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/commonMain/kotlin/dev/yaseyo/design/AppColors.kt b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/AppColors.kt index d93be2b..6af52e3 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(0xFFE1E3DF), + accentDefault = Color(0xFF1A6B50), + accentHover = Color(0xFF226B52), + accentSubtle = Color(0xFF003D2E), + 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 new file mode 100644 index 0000000..a901803 --- /dev/null +++ b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt @@ -0,0 +1,25 @@ +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 + +@Composable +fun YaseyoPreview( + darkTheme: Boolean = false, + composable: @Composable () -> Unit, +) { + YaseyoTheme(darkTheme = darkTheme) { + val colors = YaseyoTheme.colors + Scaffold( + containerColor = colors.backgroundBase, + ) { + Box(modifier = Modifier.padding(it).fillMaxSize()) { + composable() + } + } + } +} 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 70% rename from libs/design/src/androidMain/kotlin/dev/yaseyo/design/YaseyoTheme.kt rename to libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoTheme.kt index 0819429..fa523f2 100644 --- a/libs/design/src/androidMain/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, ) } diff --git a/libs/design/src/iosMain/kotlin/dev/yaseyo/design/YaseyoUiViewController.kt b/libs/design/src/iosMain/kotlin/dev/yaseyo/design/YaseyoUiViewController.kt new file mode 100644 index 0000000..c9e29ee --- /dev/null +++ b/libs/design/src/iosMain/kotlin/dev/yaseyo/design/YaseyoUiViewController.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.material3.SnackbarHost +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.window.ComposeUIViewController + +fun createYaseyoUiViewController(screen: @Composable () -> Unit) = + ComposeUIViewController { + YaseyoTheme { + Scaffold( + containerColor = YaseyoTheme.colors.backgroundBase, + snackbarHost = { + SnackbarHost(hostState = LocalSnackBarHostState.current) + }, + ) { + Box(modifier = Modifier.padding(it).fillMaxSize()) { + screen() + } + } + } + } diff --git a/libs/di/build.gradle.kts b/libs/di/build.gradle.kts index 1f92ceb..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) @@ -48,8 +49,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..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.api.di.onboardingModule +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 5f764c6..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,6 +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() diff --git a/libs/navigation/build.gradle.kts b/libs/navigation/build.gradle.kts index 780653e..2b57938 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" } @@ -11,14 +12,20 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.koin.core) + implementation(libs.compose.runtime) } } androidMain { dependencies { + implementation(projects.libs.design) 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..96d5e2d --- /dev/null +++ b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/BottomSheetSceneStrategy.kt @@ -0,0 +1,101 @@ +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 +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.design.YaseyoTheme +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() + + 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() + } + } + } + } +} + +/** + * 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 + } +} 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/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 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() } +} 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")