diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 540cef7..c4d5417 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + { dependencies { add("implementation", libs.findLibrary("hilt-android").get()) + add("implementation", libs.findLibrary("kotlinx-collections-immutable").get()) add("ksp", libs.findLibrary("hilt-compiler").get()) add("implementation", platform(libs.findLibrary("androidx-compose-bom").get())) add("implementation", libs.findLibrary("androidx-compose-ui-tooling-preview").get()) diff --git a/core/auth/build.gradle.kts b/core/auth/build.gradle.kts deleted file mode 100644 index 212e84b..0000000 --- a/core/auth/build.gradle.kts +++ /dev/null @@ -1,56 +0,0 @@ -import java.util.Properties - -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.hilt) - alias(libs.plugins.ksp) -} - -val localProperty = - Properties().apply { - rootProject - .file("local.properties") - .takeIf { it.exists() } - ?.inputStream() - ?.use { load(it) } - } - -android { - namespace = "com.kikidan.auth" - compileSdk = 37 - - defaultConfig { - minSdk = 26 - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - - val kakaoKey = localProperty.getProperty("KAKAO_NATIVE_APP_KEY", "") - val googleClientId = localProperty.getProperty("GOOGLE_WEB_CLIENT_ID", "") - - buildConfigField("String", "KAKAO_NATIVE_APP_KEY", "\"$kakaoKey\"") - buildConfigField("String", "GOOGLE_WEB_CLIENT_ID", "\"$googleClientId\"") - manifestPlaceholders["kakaoNativeAppKey"] = kakaoKey - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - buildFeatures { - buildConfig = true - } -} - -dependencies { - implementation(libs.androidx.core.ktx) - implementation(libs.hilt.android) - ksp(libs.hilt.compiler) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kakao.user) - implementation(libs.androidx.credentials) - implementation(libs.androidx.credentials.play.services.auth) - implementation(libs.googleid) - implementation(projects.core.domain) - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(libs.androidx.junit) -} diff --git a/core/auth/consumer-rules.pro b/core/auth/consumer-rules.pro deleted file mode 100644 index 6c8db4a..0000000 --- a/core/auth/consumer-rules.pro +++ /dev/null @@ -1,2 +0,0 @@ -# Kakao SDK: keep SDK model classes used in callbacks --keep class com.kakao.sdk.** { *; } diff --git a/core/auth/proguard-rules.pro b/core/auth/proguard-rules.pro deleted file mode 100644 index b19b309..0000000 --- a/core/auth/proguard-rules.pro +++ /dev/null @@ -1,6 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html diff --git a/core/auth/src/main/AndroidManifest.xml b/core/auth/src/main/AndroidManifest.xml deleted file mode 100644 index a91ebe4..0000000 --- a/core/auth/src/main/AndroidManifest.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/core/auth/src/main/java/com/kikidan/auth/OAuthException.kt b/core/auth/src/main/java/com/kikidan/auth/OAuthException.kt deleted file mode 100644 index 79eb59b..0000000 --- a/core/auth/src/main/java/com/kikidan/auth/OAuthException.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.kikidan.auth - -import com.kikidan.domain.model.auth.OAuthProviderType - -/** - * OAuth 로그인 흐름에서 발생하는 예외. - * - * ⚠️ [Cancelled]는 [kotlin.coroutines.cancellation.CancellationException]을 상속하지 않는다. - * 상속하면 코루틴 취소로 취급되어 runCatching/try-catch를 조용히 통과해 - * ViewModel이 "사용자가 취소했다"를 인지하지 못한 채 아무 일도 안 일어난다. - */ -sealed class OAuthException( - val provider: OAuthProviderType?, - message: String?, - cause: Throwable?, -) : Exception(message, cause) { - class Cancelled( - provider: OAuthProviderType?, - cause: Throwable? = null, - ) : OAuthException(provider, "User cancelled OAuth login", cause) - - class NoCredential( - provider: OAuthProviderType?, - cause: Throwable? = null, - ) : OAuthException(provider, "No credential available", cause) - - class Network( - provider: OAuthProviderType?, - cause: Throwable? = null, - ) : OAuthException(provider, "Network error during OAuth", cause) - - class Unknown( - provider: OAuthProviderType?, - cause: Throwable? = null, - ) : OAuthException(provider, "Unknown OAuth error", cause) -} diff --git a/core/auth/src/main/java/com/kikidan/auth/OAuthTokenProvider.kt b/core/auth/src/main/java/com/kikidan/auth/OAuthTokenProvider.kt deleted file mode 100644 index c4ec5d0..0000000 --- a/core/auth/src/main/java/com/kikidan/auth/OAuthTokenProvider.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.kikidan.auth - -import android.app.Activity -import com.kikidan.domain.model.auth.OAuthCredential -import com.kikidan.domain.model.auth.OAuthProviderType - -interface OAuthTokenProvider { - val type: OAuthProviderType - - suspend fun authorize(activity: Activity): OAuthCredential - - suspend fun signOut() - - suspend fun unlink() -} diff --git a/core/auth/src/main/java/com/kikidan/auth/OAuthTokenProviderRegistry.kt b/core/auth/src/main/java/com/kikidan/auth/OAuthTokenProviderRegistry.kt deleted file mode 100644 index b96be39..0000000 --- a/core/auth/src/main/java/com/kikidan/auth/OAuthTokenProviderRegistry.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.kikidan.auth - -import android.content.Context -import com.kakao.sdk.common.KakaoSdk -import com.kikidan.domain.model.auth.OAuthProviderType -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class OAuthTokenProviderRegistry - @Inject - constructor( - private val providers: Map, - ) { - /** - * 프로바이더 SDK를 초기화한다. **Application.onCreate()에서 반드시 호출해야 한다.** - * @param context Application 컨텍스트. - */ - fun init(context: Context) { - KakaoSdk.init(context, BuildConfig.KAKAO_NATIVE_APP_KEY) - } - - operator fun get(type: OAuthProviderType): OAuthTokenProvider = - providers[type] ?: error("No OAuthTokenProvider registered for $type") - } diff --git a/core/auth/src/main/java/com/kikidan/auth/di/AuthModule.kt b/core/auth/src/main/java/com/kikidan/auth/di/AuthModule.kt deleted file mode 100644 index 3806233..0000000 --- a/core/auth/src/main/java/com/kikidan/auth/di/AuthModule.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.kikidan.auth.di - -import android.content.Context -import androidx.credentials.CredentialManager -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -object AuthModule { - @Provides - @Singleton - fun provideCredentialManager( - @ApplicationContext context: Context, - ): CredentialManager = CredentialManager.create(context) -} diff --git a/core/auth/src/main/java/com/kikidan/auth/di/OAuthModule.kt b/core/auth/src/main/java/com/kikidan/auth/di/OAuthModule.kt deleted file mode 100644 index 599c7b5..0000000 --- a/core/auth/src/main/java/com/kikidan/auth/di/OAuthModule.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.kikidan.auth.di - -import com.kikidan.auth.OAuthTokenProvider -import com.kikidan.auth.google.GoogleOAuthTokenProvider -import com.kikidan.auth.kakao.KakaoOAuthTokenProvider -import com.kikidan.domain.model.auth.OAuthProviderType -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import dagger.multibindings.IntoMap - -@Module -@InstallIn(SingletonComponent::class) -abstract class OAuthModule { - @Binds - @IntoMap - @OAuthProviderKey(OAuthProviderType.KAKAO) - abstract fun bindKakao(impl: KakaoOAuthTokenProvider): OAuthTokenProvider - - @Binds - @IntoMap - @OAuthProviderKey(OAuthProviderType.GOOGLE) - abstract fun bindGoogle(impl: GoogleOAuthTokenProvider): OAuthTokenProvider -} diff --git a/core/auth/src/main/java/com/kikidan/auth/di/OAuthProviderKey.kt b/core/auth/src/main/java/com/kikidan/auth/di/OAuthProviderKey.kt deleted file mode 100644 index 91463ea..0000000 --- a/core/auth/src/main/java/com/kikidan/auth/di/OAuthProviderKey.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.kikidan.auth.di - -import com.kikidan.domain.model.auth.OAuthProviderType -import dagger.MapKey - -@MapKey -@Retention(AnnotationRetention.RUNTIME) -annotation class OAuthProviderKey( - val value: OAuthProviderType, -) diff --git a/core/auth/src/main/java/com/kikidan/auth/google/GoogleErrorMapper.kt b/core/auth/src/main/java/com/kikidan/auth/google/GoogleErrorMapper.kt deleted file mode 100644 index ac3a786..0000000 --- a/core/auth/src/main/java/com/kikidan/auth/google/GoogleErrorMapper.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.kikidan.auth.google - -import androidx.credentials.exceptions.GetCredentialCancellationException -import androidx.credentials.exceptions.GetCredentialInterruptedException -import androidx.credentials.exceptions.NoCredentialException -import com.kikidan.auth.OAuthException -import com.kikidan.domain.model.auth.OAuthProviderType.GOOGLE - -internal fun Throwable.toOAuthException(): OAuthException = - when (this) { - is GetCredentialCancellationException -> OAuthException.Cancelled(GOOGLE, this) - is NoCredentialException -> OAuthException.NoCredential(GOOGLE, this) - is GetCredentialInterruptedException -> OAuthException.Network(GOOGLE, this) - else -> OAuthException.Unknown(GOOGLE, this) - } diff --git a/core/auth/src/main/java/com/kikidan/auth/google/GoogleOAuthTokenProvider.kt b/core/auth/src/main/java/com/kikidan/auth/google/GoogleOAuthTokenProvider.kt deleted file mode 100644 index 3767288..0000000 --- a/core/auth/src/main/java/com/kikidan/auth/google/GoogleOAuthTokenProvider.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.kikidan.auth.google - -import android.app.Activity -import androidx.credentials.ClearCredentialStateRequest -import androidx.credentials.CredentialManager -import androidx.credentials.CustomCredential -import androidx.credentials.GetCredentialRequest -import com.google.android.libraries.identity.googleid.GetGoogleIdOption -import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential -import com.kikidan.auth.BuildConfig -import com.kikidan.auth.OAuthException -import com.kikidan.auth.OAuthTokenProvider -import com.kikidan.domain.model.auth.OAuthCredential -import com.kikidan.domain.model.auth.OAuthProviderType -import com.kikidan.domain.model.auth.OAuthToken -import kotlinx.coroutines.CancellationException -import javax.inject.Inject - -class GoogleOAuthTokenProvider - @Inject - constructor( - private val credentialManager: CredentialManager, - ) : OAuthTokenProvider { - override val type: OAuthProviderType = OAuthProviderType.GOOGLE - - override suspend fun authorize(activity: Activity): OAuthCredential { - val option = - GetGoogleIdOption - .Builder() - .setServerClientId(BuildConfig.GOOGLE_WEB_CLIENT_ID) - .setFilterByAuthorizedAccounts(false) - .build() - val request = - GetCredentialRequest - .Builder() - .addCredentialOption(option) - .build() - val idToken = - try { - val result = credentialManager.getCredential(activity, request) - val credential = result.credential - if (credential !is CustomCredential || - credential.type != GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL - ) { - throw OAuthException.Unknown( - OAuthProviderType.GOOGLE, - IllegalStateException("Unexpected credential type: ${credential.type}"), - ) - } - GoogleIdTokenCredential.createFrom(credential.data).idToken - } catch (e: CancellationException) { - throw e - } catch (e: OAuthException) { - throw e - } catch (e: Exception) { - throw e.toOAuthException() - } - return OAuthCredential(OAuthProviderType.GOOGLE, OAuthToken(idToken)) - } - - override suspend fun signOut() { - try { - credentialManager.clearCredentialState(ClearCredentialStateRequest()) - } catch (_: Exception) { - // Best-effort 로컬 자격증명 상태 초기화 — 실패해도 진행 - } - } - - // Credential Manager에는 revoke API가 없어 로컬 상태 초기화만 수행한다. - // 실제 Google 연결 해제는 백엔드가 처리 - override suspend fun unlink() { - signOut() - } - } diff --git a/core/auth/src/main/java/com/kikidan/auth/kakao/KakaoErrorMapper.kt b/core/auth/src/main/java/com/kikidan/auth/kakao/KakaoErrorMapper.kt deleted file mode 100644 index 10703d5..0000000 --- a/core/auth/src/main/java/com/kikidan/auth/kakao/KakaoErrorMapper.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.kikidan.auth.kakao - -import com.kakao.sdk.common.model.ClientError -import com.kakao.sdk.common.model.ClientErrorCause -import com.kikidan.auth.OAuthException -import com.kikidan.domain.model.auth.OAuthProviderType.KAKAO -import java.io.IOException - -internal fun Throwable.toOAuthException(): OAuthException = - when (this) { - is ClientError if reason == ClientErrorCause.Cancelled -> OAuthException.Cancelled(KAKAO, this) - is IOException -> OAuthException.Network(KAKAO, this) - else -> OAuthException.Unknown(KAKAO, this) - } diff --git a/core/auth/src/main/java/com/kikidan/auth/kakao/KakaoOAuthTokenProvider.kt b/core/auth/src/main/java/com/kikidan/auth/kakao/KakaoOAuthTokenProvider.kt deleted file mode 100644 index 76a8fb9..0000000 --- a/core/auth/src/main/java/com/kikidan/auth/kakao/KakaoOAuthTokenProvider.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.kikidan.auth.kakao - -import android.app.Activity -import com.kakao.sdk.user.UserApiClient -import com.kikidan.auth.OAuthException -import com.kikidan.auth.OAuthTokenProvider -import com.kikidan.domain.model.auth.OAuthCredential -import com.kikidan.domain.model.auth.OAuthProviderType -import com.kikidan.domain.model.auth.OAuthToken -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.suspendCancellableCoroutine -import javax.inject.Inject -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -class KakaoOAuthTokenProvider - @Inject - constructor() : OAuthTokenProvider { - override val type: OAuthProviderType = OAuthProviderType.KAKAO - - override suspend fun authorize(activity: Activity): OAuthCredential { - val accessToken = - if (UserApiClient.instance.isKakaoTalkLoginAvailable(activity)) { - try { - loginWithKakaoTalk(activity) - } catch (e: OAuthException.Cancelled) { - // 사용자가 직접 취소 → 카카오계정 폴백 금지 - throw e - } catch (e: CancellationException) { - // 코루틴 취소(화면 이탈 등) → 폴백하면 취소했는데 웹 로그인이 뜬다. 반드시 전파. - throw e - } catch (e: Exception) { - // 그 외 실패(앱 미설치 등) → 카카오계정 웹 로그인 폴백 - loginWithKakaoAccount(activity) - } - } else { - loginWithKakaoAccount(activity) - } - return OAuthCredential(OAuthProviderType.KAKAO, OAuthToken(accessToken)) - } - - private suspend fun loginWithKakaoTalk(activity: Activity): String = - suspendCancellableCoroutine { cont -> - UserApiClient.instance.loginWithKakaoTalk(activity) { kakaoToken, error -> - when { - error != null -> cont.resumeWithException(error.toOAuthException()) - kakaoToken != null -> cont.resume(kakaoToken.accessToken) - else -> cont.resumeWithException(OAuthException.Unknown(OAuthProviderType.KAKAO, null)) - } - } - } - - private suspend fun loginWithKakaoAccount(activity: Activity): String = - suspendCancellableCoroutine { cont -> - UserApiClient.instance.loginWithKakaoAccount(activity) { kakaoToken, error -> - when { - error != null -> cont.resumeWithException(error.toOAuthException()) - kakaoToken != null -> cont.resume(kakaoToken.accessToken) - else -> cont.resumeWithException(OAuthException.Unknown(OAuthProviderType.KAKAO, null)) - } - } - } - - override suspend fun signOut(): Unit = - suspendCancellableCoroutine { cont -> - UserApiClient.instance.logout { _ -> - // logout은 에러가 있어도 로컬 세션은 정리됨 — 항상 성공 처리 - cont.resume(Unit) - } - } - - override suspend fun unlink(): Unit = - suspendCancellableCoroutine { cont -> - UserApiClient.instance.unlink { error -> - if (error != null) { - cont.resumeWithException(error.toOAuthException()) - } else { - cont.resume(Unit) - } - } - } - } diff --git a/core/designsystem/src/main/java/com/kikidan/designsystem/component/TodakunProgressBar.kt b/core/designsystem/src/main/java/com/kikidan/designsystem/component/TodakunProgressBar.kt index 7f5eb90..624539b 100644 --- a/core/designsystem/src/main/java/com/kikidan/designsystem/component/TodakunProgressBar.kt +++ b/core/designsystem/src/main/java/com/kikidan/designsystem/component/TodakunProgressBar.kt @@ -2,6 +2,7 @@ package com.kikidan.designsystem.component import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -46,7 +48,11 @@ fun TodakunProgressBar( modifier = Modifier .size(width = 8.dp, height = 16.dp) - .clickable(onClick = onBackClick), + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onBackClick, + ), ) Spacer(modifier = Modifier.width(24.dp)) } diff --git a/core/designsystem/src/main/java/com/kikidan/designsystem/component/TodakunSelectField.kt b/core/designsystem/src/main/java/com/kikidan/designsystem/component/TodakunSelectField.kt index 18f8d65..6410174 100644 --- a/core/designsystem/src/main/java/com/kikidan/designsystem/component/TodakunSelectField.kt +++ b/core/designsystem/src/main/java/com/kikidan/designsystem/component/TodakunSelectField.kt @@ -4,7 +4,9 @@ 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.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 @@ -32,55 +34,69 @@ fun TodakunSelectField( placeholder: String = "", expanded: Boolean = false, onClear: () -> Unit = {}, + isError: Boolean = false, + errorMessage: String = "", ) { val isFilled = value.isNotEmpty() || expanded val showClearButton = !expanded && value.isNotEmpty() - Row( - modifier = - modifier - .fillMaxWidth() - .height(48.dp) - .clip(RoundedCornerShape(12.dp)) - .background(TodakunColor.gray25) - .then( - if (expanded) { - Modifier.border(1.dp, TodakunColor.gray975, RoundedCornerShape(12.dp)) - } else { - Modifier - }, - ).clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), + Column( + modifier = modifier, ) { - Text( - text = value.ifEmpty { if (expanded) "" else placeholder }, - style = if (isFilled) TodakunTypography.body2Medium else TodakunTypography.body2Regular, - color = if (isFilled) TodakunColor.gray975 else TodakunColor.gray600, - modifier = Modifier.weight(1f), - maxLines = 1, - ) - if (showClearButton) { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (isError) TodakunColor.red50 else TodakunColor.gray25) + .then( + if (expanded) { + Modifier.border(1.dp, TodakunColor.gray975, RoundedCornerShape(12.dp)) + } else { + Modifier + }, + ).clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = value.ifEmpty { if (expanded) "" else placeholder }, + style = if (isFilled) TodakunTypography.body2Medium else TodakunTypography.body2Regular, + color = if (isFilled) TodakunColor.gray975 else TodakunColor.gray600, + modifier = Modifier.weight(1f), + maxLines = 1, + ) + if (showClearButton) { + Icon( + painter = painterResource(id = R.drawable.ic_circle_x_fill), + contentDescription = null, + tint = TodakunColor.gray300, + modifier = + Modifier + .size(20.dp) + .clickable(onClick = onClear), + ) + } Icon( - painter = painterResource(id = R.drawable.ic_circle_x_fill), + painter = painterResource(id = R.drawable.ic_chevron_small_bottom), contentDescription = null, - tint = TodakunColor.gray300, + tint = TodakunColor.gray600, modifier = Modifier .size(20.dp) - .clickable(onClick = onClear), + .rotate(if (expanded) 180f else 0f), + ) + } + if (isError) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = errorMessage, + style = TodakunTypography.caption1Regular, + color = TodakunColor.red500, ) } - Icon( - painter = painterResource(id = R.drawable.ic_chevron_small_bottom), - contentDescription = null, - tint = TodakunColor.gray600, - modifier = - Modifier - .size(20.dp) - .rotate(if (expanded) 180f else 0f), - ) } } diff --git a/core/designsystem/src/main/java/com/kikidan/designsystem/component/TodakunTooltip.kt b/core/designsystem/src/main/java/com/kikidan/designsystem/component/TodakunTooltip.kt index 48217a0..7bc1e56 100644 --- a/core/designsystem/src/main/java/com/kikidan/designsystem/component/TodakunTooltip.kt +++ b/core/designsystem/src/main/java/com/kikidan/designsystem/component/TodakunTooltip.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TooltipState @@ -80,6 +81,53 @@ fun TodakunTooltip( ) } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TodakunWhiteTooltip( + text: String, + modifier: Modifier = Modifier, + state: TooltipState = rememberTooltipState(), + anchor: @Composable () -> Unit, +) { + val density = LocalDensity.current + val screenMaxWidth = + with(density) { + LocalWindowInfo.current.containerSize.width + .toDp() - 40.dp + } + val maxWidth = minOf(TodakunTooltipDefaults.MaxWidth, screenMaxWidth) + + TooltipBox( + positionProvider = + TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Below, + ), + tooltip = { + PlainTooltip( + caretShape = TooltipDefaults.caretShape(TodakunTooltipDefaults.CaretSize), + maxWidth = maxWidth, + shape = RoundedCornerShape(12.dp), + containerColor = TodakunColor.whiteOpacity90, + contentColor = TodakunColor.black, + ) { + Text( + text = text, + style = TodakunTypography.body3Medium, + textAlign = TextAlign.Center, + modifier = + Modifier.padding( + horizontal = TodakunTooltipDefaults.ExtraHorizontalPadding, + vertical = TodakunTooltipDefaults.ExtraVerticalPadding, + ), + ) + } + }, + state = state, + modifier = modifier, + content = anchor, + ) +} + private object TodakunTooltipDefaults { val CaretSize = DpSize(width = 8.dp, height = 8.dp) @@ -94,6 +142,8 @@ private object TodakunTooltipDefaults { */ val ExtraHorizontalPadding = 8.dp val ExtraVerticalPadding = 2.dp + + val MaxWidth = 200.dp } private class TodakunTooltipPositionProvider( @@ -112,7 +162,6 @@ private class TodakunTooltipPositionProvider( val above = anchorBounds.top - popupContentSize.height - spacing val y = if (above >= 0) above else anchorBounds.bottom + spacing - return IntOffset(x, y) } } @@ -152,3 +201,21 @@ private fun TodakunTooltipLongPreview() { } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true) +@Composable +private fun TodakunWhiteTooltipLongPreview() { + TodakunTheme { + TodakunWhiteTooltip( + text = "안녕하세요 안녕하세요 반갑습니다. 저는 1 안녕하세요반갑습니다", + state = rememberTooltipState(initialIsVisible = true, isPersistent = true), + ) { + Text( + text = "앵커", + modifier = Modifier.padding(48.dp), + style = TodakunTypography.body3Medium, + ) + } + } +} diff --git a/core/domain/src/main/java/com/kikidan/domain/model/onboarding/AgePolicy.kt b/core/domain/src/main/java/com/kikidan/domain/model/onboarding/AgePolicy.kt new file mode 100644 index 0000000..25a47c7 --- /dev/null +++ b/core/domain/src/main/java/com/kikidan/domain/model/onboarding/AgePolicy.kt @@ -0,0 +1,6 @@ +package com.kikidan.domain.model.onboarding + +import java.time.LocalDate +import java.time.Period + +fun LocalDate.isUnderAge(today: LocalDate = LocalDate.now()): Boolean = Period.between(this, today).years < 14 diff --git a/core/domain/src/main/java/com/kikidan/domain/model/onboarding/OnboardingTerm.kt b/core/domain/src/main/java/com/kikidan/domain/model/onboarding/OnboardingTerm.kt index 08546b8..7d7f573 100644 --- a/core/domain/src/main/java/com/kikidan/domain/model/onboarding/OnboardingTerm.kt +++ b/core/domain/src/main/java/com/kikidan/domain/model/onboarding/OnboardingTerm.kt @@ -2,9 +2,22 @@ package com.kikidan.domain.model.onboarding enum class OnboardingTerm( val required: Boolean, + val link: String, ) { - SERVICE(required = true), - PRIVACY(required = true), - AI_DATA_TRANSFER(required = true), - MARKETING(required = false), + SERVICE( + required = true, + link = "https://ash-topaz-463.notion.site/3b081c6748468045a408eb20d27e2342?source=copy_link", + ), + PRIVACY( + required = true, + link = "https://ash-topaz-463.notion.site/3b081c67484680aca6e5ec1d463c670d?source=copy_link", + ), + AI_DATA_TRANSFER( + required = true, + link = "https://ash-topaz-463.notion.site/AI-3b281c67484680aaad1bf2c384d016e1?source=copy_link", + ), + MARKETING( + required = false, + link = "https://ash-topaz-463.notion.site/3b281c67484680e1812acb94654fdc31", + ), } diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingRoute.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingRoute.kt new file mode 100644 index 0000000..3345449 --- /dev/null +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingRoute.kt @@ -0,0 +1,252 @@ +package com.kikidan.onboarding + +import android.Manifest +import android.os.Build +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.kikidan.designsystem.component.dialog.TodakunDialog +import com.kikidan.designsystem.component.wheelpicker.BirthDateState +import com.kikidan.designsystem.component.wheelpicker.BirthDateWheelPicker +import com.kikidan.designsystem.component.wheelpicker.SajuBirthTimeWheelPicker +import com.kikidan.domain.model.auth.OnboardingToken +import com.kikidan.domain.model.user.BirthTime +import com.kikidan.onboarding.model.OnboardingDialog +import com.kikidan.onboarding.model.OnboardingSheet +import com.kikidan.onboarding.model.OnboardingSideEffect +import com.kikidan.onboarding.model.OnboardingStep +import com.kikidan.onboarding.screen.BirthInfoScreen +import com.kikidan.onboarding.screen.CompleteScreen +import com.kikidan.onboarding.screen.ExtraQuestionScreen +import com.kikidan.onboarding.screen.NameScreen +import com.kikidan.onboarding.screen.TermsScreen +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect +import java.time.LocalDate + +@Composable +fun OnboardingRoute( + onFinish: () -> Unit, + onExit: () -> Unit, + onboardingToken: OnboardingToken, + modifier: Modifier = Modifier, + viewModel: OnboardingViewModel = hiltViewModel(), +) { + val state by viewModel.collectAsState() + var permissionHandled by rememberSaveable { + mutableStateOf(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) + } + val permissionLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { + // 권한을 허락하지 않더라도 앱 진입 + permissionHandled = true + } + + viewModel.collectSideEffect { sideEffect -> + when (sideEffect) { + OnboardingSideEffect.NavigateToHome -> { + onFinish() + } + + OnboardingSideEffect.Exit -> { + onExit() + } + + OnboardingSideEffect.PermissionRequest -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + + else -> { /* TODO 에러 처리 스낵바 또는 다이얼로그 */ } + } + } + + BackHandler { + viewModel.onBackClick() + } + + when (state.step) { + OnboardingStep.TERMS -> { + TermsScreen( + termsAgreement = state.termsAgreement, + canProceed = state.canProceed, + onTermChange = viewModel::onTermChange, + onAllTermsChange = viewModel::onAllTermsChange, + onNextClick = viewModel::onNextClick, + onBackClick = viewModel::onBackClick, + modifier = modifier, + ) + } + + OnboardingStep.NAME -> { + NameScreen( + username = state.username, + canProceed = state.canProceed, + onNameChange = viewModel::onNameChange, + onNextClick = viewModel::onNextClick, + onBackClick = viewModel::onBackClick, + modifier = modifier, + ) + } + + OnboardingStep.BIRTH_INFO -> { + BirthInfoScreen( + gender = state.gender, + calendarType = state.calendarType, + birthDate = state.birthDate, + birthTime = state.birthTime, + openedSheet = state.sheet, + canProceed = state.canProceed, + isBirthDateError = state.isUnderAge, + onGenderSelect = viewModel::onGenderSelect, + onCalendarTypeSelect = viewModel::onCalendarTypeSelect, + onSheetOpen = viewModel::onSheetOpen, + onBirthDateClear = viewModel::onBirthDateClear, + onBirthTimeClear = viewModel::onBirthTimeClear, + onBirthTimeUnknownChange = viewModel::onBirthTimeUnknownChange, + onNextClick = viewModel::onNextClick, + onBackClick = viewModel::onBackClick, + modifier = modifier, + ) + } + + OnboardingStep.EXTRA_QUESTION -> { + ExtraQuestionScreen( + lifeStage = state.lifeStage, + relationshipStatus = state.relationshipStatus, + canProceed = state.canProceed, + onLifeStageSelect = viewModel::onLifeStageSelect, + onRelationshipStatusSelect = viewModel::onRelationshipStatusSelect, + onNextClick = { + viewModel.onCompleteConfirmed(onboardingToken) + }, + onBackClick = viewModel::onBackClick, + modifier = modifier, + ) + } + + OnboardingStep.COMPLETE -> { + if (permissionHandled) { + CompleteScreen( + modifier = modifier, + ) + } + } + } + + OnboardingSheetHost( + sheet = state.sheet, + birthDate = state.birthDate, + onBirthDateChange = viewModel::onBirthDateChange, + onBirthTimeChange = viewModel::onBirthTimeChange, + onDismiss = viewModel::onSheetDismiss, + ) + + OnboardingDialogHost( + dialog = state.dialog, + onConfirmExit = viewModel::onExitConfirmed, + onDismiss = viewModel::onDialogDismiss, + ) +} + +@Composable +private fun OnboardingSheetHost( + sheet: OnboardingSheet?, + birthDate: LocalDate?, + onBirthDateChange: (LocalDate) -> Unit, + onBirthTimeChange: (BirthTime) -> Unit, + onDismiss: () -> Unit, +) { + when (sheet) { + OnboardingSheet.BIRTH_DATE -> { + // 휠을 굴리는 동안의 값은 시트 안에서만 유효하고, 저장할 때 비로소 상태에 반영한다. + var draft by remember(birthDate) { + mutableStateOf( + BirthDateState.of( + birthDate ?: OnboardingRouteDefaults.DefaultBirthDate, + OnboardingRouteDefaults.BirthDateYearRange, + ), + ) + } + BirthDateWheelPicker( + birthDateState = draft, + onBirthDateChange = { draft = it }, + onSaveClick = { + onBirthDateChange(LocalDate.of(draft.year, draft.month, draft.day)) + onDismiss() + }, + onDismissRequest = onDismiss, + ) + } + + OnboardingSheet.BIRTH_TIME -> { + // SajuBirthTimeWheelPicker는 label 문자열만 돌려주므로 표시 이름으로 역매핑한다. + var draft by remember { mutableStateOf(null) } + SajuBirthTimeWheelPicker( + onSajuBirthTimeChange = { label -> draft = birthTimeFromLabel(label) }, + onSaveClick = { + (draft ?: OnboardingRouteDefaults.DefaultBirthTime).let(onBirthTimeChange) + onDismiss() + }, + onDismissRequest = onDismiss, + ) + } + + null -> { + Unit + } + } +} + +@Composable +private fun OnboardingDialogHost( + dialog: OnboardingDialog?, + onConfirmExit: () -> Unit, + onDismiss: () -> Unit, +) { + when (dialog) { + OnboardingDialog.EXIT_CONFIRM -> { + TodakunDialog( + title = stringResource(id = R.string.onboarding_terms_exit_title), + description = stringResource(id = R.string.onboarding_terms_exit_description), + confirmText = stringResource(id = R.string.onboarding_terms_exit_confirm), + dismissText = stringResource(id = R.string.onboarding_terms_exit_dismiss), + onConfirm = onConfirmExit, + onDismiss = onDismiss, + ) + } + + null -> { + Unit + } + } +} + +private object OnboardingRouteDefaults { + val DefaultBirthDate: LocalDate = LocalDate.of(1999, 2, 13) + val BirthDateYearRange = 1900..2025 + + /** 휠피커가 처음 보여주는 항목(index 2 = 인시). 사용자가 굴리지 않고 저장하면 이 값이 선택된 것으로 본다. */ + val DefaultBirthTime = BirthTime.IN +} + +/** + * `"자시 (子時): 23:30 ~ 01:29"` 형태의 휠피커 label을 지시로 되돌린다. + * 시각 범위 표기가 바뀌어도 앞머리 지지 이름만 유지되면 계속 동작한다. + */ +private fun birthTimeFromLabel(label: String): BirthTime? { + val trimmed = label.trimStart() + return BirthTime.entries.firstOrNull { trimmed.startsWith(it.displayName) } +} diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt index 81d30ce..ae3c1e8 100644 --- a/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt @@ -46,11 +46,6 @@ class OnboardingViewModel } } - fun moveToStep(step: OnboardingStep) = - intent { - reduce { state.copy(step = step) } - } - fun onDialogDismiss() = intent { reduce { state.copy(dialog = null) } diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/component/OnboardingScaffold.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/component/OnboardingScaffold.kt new file mode 100644 index 0000000..b5979f7 --- /dev/null +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/component/OnboardingScaffold.kt @@ -0,0 +1,142 @@ +package com.kikidan.onboarding.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import com.kikidan.designsystem.R +import com.kikidan.designsystem.component.TodakunProgressBar +import com.kikidan.designsystem.component.button.PrimaryButton +import com.kikidan.designsystem.component.button.TodakunButtonSize +import com.kikidan.designsystem.theme.TodakunColor +import com.kikidan.designsystem.theme.TodakunTypography + +@Composable +internal fun OnboardingScaffold( + progress: Float?, + title: AnnotatedString, + ctaText: String, + ctaEnabled: Boolean, + onCtaClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + progressLabel: String? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Scaffold( + modifier = modifier, + containerColor = TodakunColor.white, + topBar = { + if (progress != null) { + TodakunProgressBar( + progress = progress, + onBackClick = onBackClick, + modifier = Modifier.statusBarsPadding(), + ) + } else if (progressLabel != null) { + TermsHeader( + label = progressLabel, + onBackClick = onBackClick, + modifier = Modifier.statusBarsPadding(), + ) + } + }, + bottomBar = { + Column( + modifier = + Modifier + .navigationBarsPadding() + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalArrangement = Arrangement.Center, + ) { + PrimaryButton( + text = ctaText, + onClick = onCtaClick, + size = TodakunButtonSize.Large, + enabled = ctaEnabled, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + ) { innerPadding -> + Column( + modifier = + Modifier + .padding(innerPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + ) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = title, + style = TodakunTypography.heading2SemiBold, + color = TodakunColor.black, + ) + Spacer(modifier = Modifier.height(40.dp)) + content() + } + } +} + +@Composable +private fun TermsHeader( + label: String, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_back), + contentDescription = stringResource(id = R.string.header_back_content_description), + tint = TodakunColor.gray400, + modifier = + Modifier + .size(width = 8.dp, height = 16.dp) + .clickable( + onClick = onBackClick, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ), + ) + + Spacer(modifier = Modifier.width(24.dp)) + Text( + text = label, + style = TodakunTypography.body2Medium, + color = TodakunColor.gray500, + ) + } +} diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingState.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingState.kt index d424650..80003b1 100644 --- a/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingState.kt +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingState.kt @@ -5,6 +5,7 @@ import com.kikidan.domain.model.auth.Job import com.kikidan.domain.model.auth.RelationshipStatus import com.kikidan.domain.model.auth.SignupSubmission import com.kikidan.domain.model.onboarding.UserName +import com.kikidan.domain.model.onboarding.isUnderAge import com.kikidan.domain.model.user.Birth import com.kikidan.domain.model.user.BirthTime import com.kikidan.domain.model.user.DateType @@ -35,6 +36,8 @@ data class OnboardingState( val dialog: OnboardingDialog? = null, val isSubmitting: Boolean = false, ) { + val isUnderAge: Boolean = birthDate?.isUnderAge() ?: false + val canProceed: Boolean get() = when (step) { @@ -50,7 +53,8 @@ data class OnboardingState( gender != null && calendarType != null && birthDate != null && - birthTime != null + birthTime != null && + !isUnderAge } OnboardingStep.EXTRA_QUESTION -> { diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingStep.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingStep.kt index ae02fde..b9b7c65 100644 --- a/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingStep.kt +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingStep.kt @@ -13,7 +13,6 @@ enum class OnboardingStep( NAME(1f / 3f), BIRTH_INFO(2f / 3f), EXTRA_QUESTION(1f), - COMPLETE(null), ; diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/model/TermsAgreementUiModel.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/TermsAgreementUiModel.kt index edb0355..17f6249 100644 --- a/feature/onboarding/src/main/java/com/kikidan/onboarding/model/TermsAgreementUiModel.kt +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/TermsAgreementUiModel.kt @@ -1,9 +1,14 @@ package com.kikidan.onboarding.model import com.kikidan.domain.model.onboarding.OnboardingTerm +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.PersistentSet +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.collections.immutable.toPersistentSet data class TermsAgreementUiModel( - val selected: Set = emptySet(), + val selected: PersistentSet = persistentSetOf(), ) { val allRequiredSelected: Boolean get() = OnboardingTerm.entries.none { it.required && it !in selected } @@ -12,10 +17,10 @@ data class TermsAgreementUiModel( get() = selected.size == OnboardingTerm.entries.size fun toggle(term: OnboardingTerm): TermsAgreementUiModel = - copy(selected = if (isAgreed(term)) selected - term else selected + term) + copy(selected = if (isAgreed(term)) selected.removing(term) else selected.adding(term)) fun withAll(value: Boolean): TermsAgreementUiModel = - copy(selected = if (value) OnboardingTerm.entries.toSet() else emptySet()) + copy(selected = if (value) OnboardingTerm.entries.toPersistentSet() else persistentSetOf()) fun isAgreed(onboardingTerm: OnboardingTerm) = onboardingTerm in selected } diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/screen/BirthInfoScreen.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/screen/BirthInfoScreen.kt new file mode 100644 index 0000000..531cbc9 --- /dev/null +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/screen/BirthInfoScreen.kt @@ -0,0 +1,212 @@ +package com.kikidan.onboarding.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.kikidan.designsystem.component.TodakunCheckbox +import com.kikidan.designsystem.component.TodakunSelectBox +import com.kikidan.designsystem.component.TodakunSelectField +import com.kikidan.designsystem.theme.TodakunColor +import com.kikidan.designsystem.theme.TodakunTheme +import com.kikidan.designsystem.theme.TodakunTypography +import com.kikidan.domain.model.user.BirthTime +import com.kikidan.domain.model.user.DateType +import com.kikidan.domain.model.user.Gender +import com.kikidan.onboarding.R +import com.kikidan.onboarding.component.OnboardingScaffold +import com.kikidan.onboarding.model.OnboardingSheet +import com.kikidan.onboarding.model.OnboardingStep +import java.time.LocalDate +import com.kikidan.designsystem.R as DesignSystemR + +@Composable +internal fun BirthInfoScreen( + gender: Gender?, + calendarType: DateType?, + birthDate: LocalDate?, + birthTime: BirthTime?, + isBirthDateError: Boolean, + openedSheet: OnboardingSheet?, + canProceed: Boolean, + onGenderSelect: (Gender) -> Unit, + onCalendarTypeSelect: (DateType) -> Unit, + onSheetOpen: (OnboardingSheet) -> Unit, + onBirthDateClear: () -> Unit, + onBirthTimeClear: () -> Unit, + onBirthTimeUnknownChange: (Boolean) -> Unit, + onNextClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val title = + buildAnnotatedString { + append(stringResource(R.string.onboarding_birth_title1)) + withStyle(style = SpanStyle(color = TodakunColor.primary700)) { + append(stringResource(R.string.onboarding_birth_title_primary)) + } + append(stringResource(R.string.onboarding_birth_title2)) + } + OnboardingScaffold( + progress = OnboardingStep.BIRTH_INFO.progress, + title = title, + ctaText = stringResource(id = R.string.onboarding_next), + ctaEnabled = canProceed, + onCtaClick = onNextClick, + onBackClick = onBackClick, + modifier = modifier, + ) { + Column(verticalArrangement = Arrangement.spacedBy(40.dp)) { + LabeledField(label = stringResource(id = R.string.onboarding_birth_gender_label)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TodakunSelectBox( + text = stringResource(id = R.string.onboarding_birth_gender_male), + selected = gender == Gender.MALE, + onClick = { onGenderSelect(Gender.MALE) }, + modifier = Modifier.weight(1f), + ) + TodakunSelectBox( + text = stringResource(id = R.string.onboarding_birth_gender_female), + selected = gender == Gender.FEMALE, + onClick = { onGenderSelect(Gender.FEMALE) }, + modifier = Modifier.weight(1f), + ) + } + } + + LabeledField(label = stringResource(id = R.string.onboarding_birth_calendar_label)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TodakunSelectBox( + text = stringResource(id = R.string.onboarding_birth_calendar_solar), + selected = calendarType == DateType.SOLAR, + onClick = { onCalendarTypeSelect(DateType.SOLAR) }, + modifier = Modifier.weight(1f), + ) + TodakunSelectBox( + text = stringResource(id = R.string.onboarding_birth_calendar_lunar), + selected = calendarType == DateType.LUNAR, + onClick = { onCalendarTypeSelect(DateType.LUNAR) }, + modifier = Modifier.weight(1f), + ) + } + } + + LabeledField(label = stringResource(id = R.string.onboarding_birth_date_label)) { + TodakunSelectField( + value = birthDate.formatted(), + onClick = { onSheetOpen(OnboardingSheet.BIRTH_DATE) }, + placeholder = stringResource(id = R.string.onboarding_birth_date_placeholder), + expanded = openedSheet == OnboardingSheet.BIRTH_DATE, + onClear = onBirthDateClear, + isError = isBirthDateError, + errorMessage = stringResource(R.string.onboarding_birth_date_under_age_errror), + ) + } + + LabeledField(label = stringResource(id = R.string.onboarding_birth_time_label)) { + TodakunSelectField( + value = birthTime.formatted(), + onClick = { onSheetOpen(OnboardingSheet.BIRTH_TIME) }, + placeholder = stringResource(id = R.string.onboarding_birth_time_placeholder), + expanded = openedSheet == OnboardingSheet.BIRTH_TIME, + onClear = onBirthTimeClear, + ) + Spacer(modifier = Modifier.height(12.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TodakunCheckbox( + checked = birthTime == BirthTime.UNKNOWN, + onCheckedChange = onBirthTimeUnknownChange, + ) + Text( + text = stringResource(id = R.string.onboarding_birth_time_unknown), + style = TodakunTypography.body3Medium, + color = TodakunColor.gray975, + ) + } + if (birthTime == BirthTime.UNKNOWN) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(id = R.string.onboarding_birth_time_unknown_hint), + style = TodakunTypography.caption1Regular, + color = TodakunColor.sky600, + ) + } + } + } + } +} + +@Composable +private fun LabeledField( + label: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = label, + style = TodakunTypography.body1Bold, + color = TodakunColor.black, + ) + Spacer(modifier = Modifier.height(16.dp)) + content() + } +} + +@Composable +private fun LocalDate?.formatted(): String = + this + ?.let { + stringResource(id = R.string.onboarding_birth_date_format, it.year, it.monthValue, it.dayOfMonth) + }.orEmpty() + +/** + * 지시(地支)의 표시 문자열은 휠피커가 쓰는 `core:designsystem` 문자열 배열이 원본이다. + * `BirthTime`은 12지시 뒤에 `UNKNOWN`이 이어지는 순서라 앞 12개 ordinal이 그 배열 순서와 1:1로 대응한다. + * `UNKNOWN`(ordinal 12)이나 미선택(null)은 배열 범위를 벗어나 빈 문자열로 떨어진다. + */ +@Composable +private fun BirthTime?.formatted(): String { + val labels = stringArrayResource(id = DesignSystemR.array.wheel_picker_saju_birth_times) + return this?.let { labels.getOrNull(it.ordinal) }.orEmpty() +} + +@Preview(showBackground = true, widthDp = 393, heightDp = 852) +@Composable +private fun BirthInfoScreenPreview() { + TodakunTheme { + BirthInfoScreen( + gender = Gender.FEMALE, + calendarType = DateType.SOLAR, + birthDate = LocalDate.of(1999, 2, 13), + birthTime = BirthTime.JA, + openedSheet = null, + canProceed = true, + onGenderSelect = {}, + onCalendarTypeSelect = {}, + onSheetOpen = {}, + onBirthDateClear = {}, + onBirthTimeClear = {}, + onBirthTimeUnknownChange = {}, + onNextClick = {}, + onBackClick = {}, + isBirthDateError = false, + ) + } +} diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/screen/CompleteScreen.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/screen/CompleteScreen.kt new file mode 100644 index 0000000..385d9fa --- /dev/null +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/screen/CompleteScreen.kt @@ -0,0 +1,77 @@ +package com.kikidan.onboarding.screen + +import androidx.compose.foundation.Image +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.kikidan.designsystem.theme.TodakunColor +import com.kikidan.designsystem.theme.TodakunTheme +import com.kikidan.designsystem.theme.TodakunTypography +import com.kikidan.onboarding.R + +@Composable +internal fun CompleteScreen(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .fillMaxSize() + .background(Color(0xFF010018)) + .systemBarsPadding(), + contentAlignment = Alignment.Center, + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.onboarding_complete_welcome), + style = TodakunTypography.body2Medium, + color = TodakunColor.whiteOpacity60, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(28.dp)) + + Image( + painter = painterResource(id = R.drawable.img_onboarding_complete_character), + contentDescription = null, + modifier = Modifier.size(378.dp), + contentScale = ContentScale.Fit, + ) + + Spacer(modifier = Modifier.height(14.dp)) + + Text( + text = stringResource(R.string.onboarding_complete_analyzing), + style = TodakunTypography.heading4Bold, + color = TodakunColor.white, + textAlign = TextAlign.Center, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun CompleteScreenPreview() { + TodakunTheme { + CompleteScreen() + } +} diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/screen/ExtraQuestionScreen.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/screen/ExtraQuestionScreen.kt new file mode 100644 index 0000000..8d0acd6 --- /dev/null +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/screen/ExtraQuestionScreen.kt @@ -0,0 +1,138 @@ +package com.kikidan.onboarding.screen + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +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.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.kikidan.designsystem.component.TodakunChip +import com.kikidan.designsystem.theme.TodakunColor +import com.kikidan.designsystem.theme.TodakunTheme +import com.kikidan.designsystem.theme.TodakunTypography +import com.kikidan.domain.model.auth.Job +import com.kikidan.domain.model.auth.RelationshipStatus +import com.kikidan.onboarding.R +import com.kikidan.onboarding.component.OnboardingScaffold +import com.kikidan.onboarding.model.OnboardingStep + +@Composable +internal fun ExtraQuestionScreen( + lifeStage: Job?, + relationshipStatus: RelationshipStatus?, + canProceed: Boolean, + onLifeStageSelect: (Job) -> Unit, + onRelationshipStatusSelect: (RelationshipStatus) -> Unit, + onNextClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + OnboardingScaffold( + progress = OnboardingStep.EXTRA_QUESTION.progress, + title = + buildAnnotatedString { + append(stringResource(R.string.onboarding_extra_title)) + }, + ctaText = stringResource(id = R.string.onboarding_start), + ctaEnabled = canProceed, + onCtaClick = onNextClick, + onBackClick = onBackClick, + modifier = modifier, + ) { + Column(verticalArrangement = Arrangement.spacedBy(52.dp)) { + ChipQuestion( + question = stringResource(id = R.string.onboarding_extra_life_stage_question), + options = Job.entries, + selected = lifeStage, + labelResOf = Job::labelRes, + onSelect = onLifeStageSelect, + ) + ChipQuestion( + question = stringResource(id = R.string.onboarding_extra_relationship_question), + options = RelationshipStatus.entries, + selected = relationshipStatus, + labelResOf = RelationshipStatus::labelRes, + onSelect = onRelationshipStatusSelect, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ChipQuestion( + question: String, + options: List, + selected: T?, + labelResOf: (T) -> Int, + onSelect: (T) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = question, + style = TodakunTypography.body1Bold, + color = TodakunColor.black, + ) + Spacer(modifier = Modifier.height(16.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEach { option -> + TodakunChip( + label = stringResource(id = labelResOf(option)), + selected = option == selected, + onClick = { onSelect(option) }, + ) + } + } + } +} + +@get:StringRes +private val Job.labelRes: Int + get() = + when (this) { + Job.STUDENT -> R.string.onboarding_extra_life_stage_student + Job.JOBSEEKER -> R.string.onboarding_extra_life_stage_job_seeker + Job.WORKER -> R.string.onboarding_extra_life_stage_office_worker + Job.FREELANCER -> R.string.onboarding_extra_life_stage_self_employed + Job.HOMEMAKER -> R.string.onboarding_extra_life_stage_homemaker + Job.LEAVER -> R.string.onboarding_extra_life_stage_on_leave_or_retired + } + +@get:StringRes +private val RelationshipStatus.labelRes: Int + get() = + when (this) { + RelationshipStatus.SOLO -> R.string.onboarding_extra_relationship_single + RelationshipStatus.DATING -> R.string.onboarding_extra_relationship_in_relationship + RelationshipStatus.MARRY -> R.string.onboarding_extra_relationship_married + RelationshipStatus.REMARRY -> R.string.onboarding_extra_relationship_remarried + } + +@Preview(showBackground = true, widthDp = 393, heightDp = 852) +@Composable +private fun ExtraQuestionScreenPreview() { + TodakunTheme { + ExtraQuestionScreen( + lifeStage = Job.STUDENT, + relationshipStatus = RelationshipStatus.SOLO, + canProceed = true, + onLifeStageSelect = {}, + onRelationshipStatusSelect = {}, + onNextClick = {}, + onBackClick = {}, + ) + } +} diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/screen/NameScreen.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/screen/NameScreen.kt new file mode 100644 index 0000000..a22bd52 --- /dev/null +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/screen/NameScreen.kt @@ -0,0 +1,108 @@ +package com.kikidan.onboarding.screen + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.kikidan.designsystem.component.TodakunTextField +import com.kikidan.designsystem.theme.TodakunColor +import com.kikidan.designsystem.theme.TodakunTheme +import com.kikidan.designsystem.theme.TodakunTypography +import com.kikidan.domain.model.onboarding.UserName +import com.kikidan.onboarding.R +import com.kikidan.onboarding.component.OnboardingScaffold +import com.kikidan.onboarding.model.OnboardingStep + +@Composable +internal fun NameScreen( + username: UserName, + canProceed: Boolean, + onNameChange: (String) -> Unit, + onNextClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val title = + buildAnnotatedString { + append(stringResource(R.string.onboarding_name_title1)) + withStyle(SpanStyle(color = TodakunColor.primary700)) { + append(stringResource(R.string.onboarding_name_title_primary)) + } + append(stringResource(R.string.onboarding_name_title2)) + } + + val input = + when (username) { + is UserName.Valid -> username.name + is UserName.Invalid.Empty -> "" + is UserName.Invalid.TooLong -> username.input + is UserName.Invalid.ContainsSpecialCharacter -> username.input + } + + val isError = username is UserName.Invalid && username !is UserName.Invalid.Empty + val errorMessage = if (isError) username.errorMessage() else "" + + OnboardingScaffold( + progress = OnboardingStep.NAME.progress, + title = title, + ctaText = stringResource(id = R.string.onboarding_next), + ctaEnabled = canProceed, + onCtaClick = onNextClick, + onBackClick = onBackClick, + modifier = modifier.imePadding(), + ) { + Text( + text = stringResource(id = R.string.onboarding_name_label), + style = TodakunTypography.body1Bold, + color = TodakunColor.black, + ) + Spacer(modifier = Modifier.height(16.dp)) + TodakunTextField( + value = input, + onValueChange = onNameChange, + modifier = Modifier.fillMaxWidth(), + placeholder = stringResource(id = R.string.onboarding_name_placeholder), + isError = isError, + errorMessage = errorMessage, + ) + } +} + +@Composable +private fun UserName.Invalid.errorMessage(): String = + when (this) { + is UserName.Invalid.Empty -> { + stringResource(id = R.string.onboarding_name_error_empty) + } + + is UserName.Invalid.TooLong -> { + stringResource(id = R.string.onboarding_name_error_too_long, UserName.MAX_LENGTH) + } + + is UserName.Invalid.ContainsSpecialCharacter -> { + stringResource(id = R.string.onboarding_name_error_special_character) + } + } + +@Preview(showBackground = true, widthDp = 393, heightDp = 852) +@Composable +private fun NameScreenPreview() { + TodakunTheme { + NameScreen( + username = UserName.Invalid.ContainsSpecialCharacter("토닥이##"), + canProceed = false, + onNameChange = {}, + onNextClick = {}, + onBackClick = {}, + ) + } +} diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/screen/TermsScreen.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/screen/TermsScreen.kt new file mode 100644 index 0000000..d9c6209 --- /dev/null +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/screen/TermsScreen.kt @@ -0,0 +1,157 @@ +package com.kikidan.onboarding.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.kikidan.designsystem.component.TodakunCheckbox +import com.kikidan.designsystem.component.TodakunDivider +import com.kikidan.designsystem.theme.TodakunColor +import com.kikidan.designsystem.theme.TodakunTheme +import com.kikidan.designsystem.theme.TodakunTypography +import com.kikidan.domain.model.onboarding.OnboardingTerm +import com.kikidan.onboarding.R +import com.kikidan.onboarding.component.OnboardingScaffold +import com.kikidan.onboarding.model.OnboardingStep +import com.kikidan.onboarding.model.TermsAgreementUiModel + +@Composable +internal fun TermsScreen( + termsAgreement: TermsAgreementUiModel, + canProceed: Boolean, + onTermChange: (OnboardingTerm) -> Unit, + onAllTermsChange: (Boolean) -> Unit, + onNextClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val uriHandler = LocalUriHandler.current + OnboardingScaffold( + progress = OnboardingStep.TERMS.progress, + progressLabel = stringResource(id = R.string.onboarding_terms_progress_label), + title = + buildAnnotatedString { + append(stringResource(id = R.string.onboarding_terms_title)) + }, + ctaText = stringResource(id = R.string.onboarding_start), + ctaEnabled = canProceed, + onCtaClick = onNextClick, + onBackClick = onBackClick, + modifier = modifier, + ) { + Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { + OnboardingTerm.entries.forEach { term -> + TermRow( + label = term.toResourceString(), + checked = termsAgreement.isAgreed(term), + textStyleEmphasized = false, + onCheckedChange = { onTermChange(term) }, + onMoreDetailClick = { uriHandler.openUri(term.link) }, + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + TodakunDivider(modifier = Modifier.fillMaxWidth()) + Spacer(modifier = Modifier.height(32.dp)) + + TermRow( + label = stringResource(id = R.string.onboarding_terms_agree_all), + checked = termsAgreement.allSelected, + textStyleEmphasized = true, + onCheckedChange = onAllTermsChange, + ) + } +} + +@Composable +private fun TermRow( + label: String, + checked: Boolean, + textStyleEmphasized: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + onMoreDetailClick: () -> Unit = {}, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .toggleable( + value = checked, + onValueChange = onCheckedChange, + role = Role.Checkbox, + interactionSource = null, + indication = null, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + TodakunCheckbox( + checked = checked, + onCheckedChange = onCheckedChange, + ) + Text( + modifier = Modifier.weight(1f), + text = label, + style = if (textStyleEmphasized) TodakunTypography.body2SemiBold else TodakunTypography.body3Regular, + color = TodakunColor.black, + ) + Icon( + modifier = + Modifier + .padding(horizontal = 7.dp) + .clickable( + onClick = onMoreDetailClick, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ), + painter = painterResource(id = R.drawable.ic_arrow_right), + contentDescription = null, + tint = TodakunColor.gray300, + ) + } +} + +@Composable +private fun OnboardingTerm.toResourceString(): String = + when (this) { + OnboardingTerm.SERVICE -> stringResource(R.string.onboarding_terms_service) + OnboardingTerm.MARKETING -> stringResource(R.string.onboarding_terms_marketing) + OnboardingTerm.AI_DATA_TRANSFER -> stringResource(R.string.onboarding_terms_ai_data_transfer) + OnboardingTerm.PRIVACY -> stringResource(R.string.onboarding_terms_privacy) + } + +@Preview(showBackground = true, widthDp = 393, heightDp = 852) +@Composable +private fun TermsScreenPreview() { + TodakunTheme { + TermsScreen( + termsAgreement = TermsAgreementUiModel(), + canProceed = false, + onTermChange = { _ -> }, + onAllTermsChange = {}, + onNextClick = {}, + onBackClick = {}, + ) + } +} diff --git a/feature/onboarding/src/main/res/drawable/ic_arrow_right.xml b/feature/onboarding/src/main/res/drawable/ic_arrow_right.xml new file mode 100644 index 0000000..7ec3e79 --- /dev/null +++ b/feature/onboarding/src/main/res/drawable/ic_arrow_right.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/feature/onboarding/src/main/res/drawable/img_onboarding_complete_character.png b/feature/onboarding/src/main/res/drawable/img_onboarding_complete_character.png new file mode 100644 index 0000000..84f39d9 Binary files /dev/null and b/feature/onboarding/src/main/res/drawable/img_onboarding_complete_character.png differ diff --git a/feature/onboarding/src/main/res/values/strings.xml b/feature/onboarding/src/main/res/values/strings.xml new file mode 100644 index 0000000..5bfc608 --- /dev/null +++ b/feature/onboarding/src/main/res/values/strings.xml @@ -0,0 +1,71 @@ + + + + 다음 + 시작하기 + 확인 + + + 약관 동의 + 토닥운 이용을 위해\n동의가 필요해요 + 전체 동의하기 + (필수) 서비스 이용약관 동의 + (필수) 개인정보 수집 및 이용 + (필수) AI 사주 분석을 위한 개인정보 국내 이전 동의 + (선택) 마케팅 정보 수신 + 지금 나가면 가입이 취소돼요 + 동의하신 약관 정보는 저장되지 않아요. + 나가기 + 계속하기 + + + 안녕하세요!\n + 이름 + 을 입력해 주세요. + + 이름 + 예) 홍길동 + 이름을 입력해 주세요. + 이름은 %1$d자까지 입력할 수 있어요. + 이름에 특수문자는 포함할 수 없어요. + + + 정확한 운세를 위해,\n + 태어난 정보 + 가 필요해요. + 성별 + 남성 + 여성 + 음/양력 + 양력 + 음력 + 생년월일 + 생년월일 선택 + %1$d년 %2$d월 %3$d일 + 토닥운은 만 14세부터 가입할 수 있어요 + 태어난 시간 + 태어난 시간 선택 + 시간 모름 + 정확한 시간을 모르면 정오 기준으로 운세가 계산돼요. + + + 당신에 대해 조금만 더\n알려주세요. + Q1 주로 어떤 일상을 보내고 계신가요? + 학생 + 취업 준비중 + 직장인 + 자영업 · 프리랜서 + 주부 + 휴직 · 은퇴 + Q2 현재 연애 상태를 알려주세요. + 솔로 + 연애중 + 기혼 + 재혼 + + + 계정이 생성되었어요! + 환영해요, 이제 시작해볼까요? + 환영해요!\n가입이 완료되었어요. + 토닥운이 사주 기반으로\n운세를 분석하고 있어요... + diff --git a/feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt b/feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt index a81229d..e892a1c 100644 --- a/feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt +++ b/feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt @@ -17,6 +17,9 @@ import com.kikidan.onboarding.model.OnboardingSideEffect import com.kikidan.onboarding.model.OnboardingState import com.kikidan.onboarding.model.OnboardingStep import com.kikidan.onboarding.model.TermsAgreementUiModel +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toPersistentHashSet +import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -80,7 +83,7 @@ class OnboardingViewModelTest { runTest { val terms = TermsAgreementUiModel( - setOf( + persistentSetOf( OnboardingTerm.SERVICE, OnboardingTerm.PRIVACY, OnboardingTerm.AI_DATA_TRANSFER, @@ -103,7 +106,7 @@ class OnboardingViewModelTest { viewModel().test(this, initialState = initial) { containerHost.onAllTermsChange(true) expectState { - copy(termsAgreement = TermsAgreementUiModel(OnboardingTerm.entries.toSet())) + copy(termsAgreement = TermsAgreementUiModel(OnboardingTerm.entries.toPersistentSet())) } containerHost.onAllTermsChange(false) @@ -214,7 +217,7 @@ class OnboardingViewModelTest { } @Test - fun `마지막 스텝에서 다음을 누르면 완료 단계로 넘어간다`() = + fun `마지막 스텝에서 다음을 누르면 완료 화면으로 전환된다`() = runTest { val initial = OnboardingState( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 68e162a..7f4fda7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ composeRules = "0.6.2" kakao = "2.24.0" androidx-credentials = "1.6.0" googleid = "1.2.0" +kotlinx-collections-immutable = "0.5.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -57,6 +58,7 @@ ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.r ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } +kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-collections-immutable" } javax-inject = { group = "javax.inject", name = "javax.inject", version = "1" } android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }