diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAccordion.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAccordion.kt index 1a2f7994..2130a344 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAccordion.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAccordion.kt @@ -12,6 +12,7 @@ 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.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -176,6 +177,7 @@ private fun PrezelAccordionPreview() { modifier = Modifier.drawDashBorder(), size = CheckboxSize.REGULAR, onCheckedChange = { state = !state }, + extraTouchPadding = PaddingValues(), ) }, trailingContent = { diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelCheckbox.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelCheckbox.kt index a9b31962..487a2faf 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelCheckbox.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelCheckbox.kt @@ -1,22 +1,27 @@ package com.team.prezel.core.designsystem.component -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.size -import androidx.compose.foundation.selection.toggleable import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.team.prezel.core.designsystem.R +import com.team.prezel.core.designsystem.component.base.PrezelTouchArea import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.preview.PreviewSection @@ -34,48 +39,47 @@ fun PrezelCheckbox( checked: Boolean, modifier: Modifier = Modifier, size: CheckboxSize = CheckboxSize.REGULAR, + extraTouchPadding: PaddingValues = PaddingValues(all = PrezelTheme.spacing.V8), onCheckedChange: (Boolean) -> Unit, ) { - val checkboxSize = when (size) { - CheckboxSize.REGULAR -> 24.dp - CheckboxSize.LARGE -> 32.dp - } - - val iconRes = - if (checked) { - PrezelIcons.CheckCircleFilled - } else { - PrezelIcons.CheckCircleOutlined - } - - val iconColor = - if (checked) { - PrezelTheme.colors.feedbackGoodRegular - } else { - PrezelTheme.colors.iconDisabled - } - - Box( - modifier = modifier - .padding(all = PrezelTheme.spacing.V8) - .toggleable( - value = checked, - interactionSource = null, - indication = null, - role = Role.Checkbox, - onValueChange = onCheckedChange, - ), - contentAlignment = Alignment.Center, + PrezelTouchArea( + modifier = modifier.semantics { + role = Role.Checkbox + toggleableState = ToggleableState(checked) + }, + onClick = { onCheckedChange(!checked) }, + isUseRipple = false, + extraTouchPadding = extraTouchPadding, ) { Icon( - painter = painterResource(id = iconRes), + painter = checkboxIconRes(checked = checked), contentDescription = stringResource(R.string.core_designsystem_checkbox_desc), - modifier = Modifier.size(checkboxSize), - tint = iconColor, + modifier = Modifier.size(size = checkboxSize(size = size)), + tint = checkboxIconTintColor(checked = checked), ) } } +private fun checkboxSize(size: CheckboxSize): Dp = + when (size) { + CheckboxSize.REGULAR -> 24.dp + CheckboxSize.LARGE -> 32.dp + } + +@Composable +private fun checkboxIconRes(checked: Boolean): Painter { + val resId = if (checked) PrezelIcons.CheckCircleFilled else PrezelIcons.CheckCircleOutlined + return painterResource(id = resId) +} + +@Composable +private fun checkboxIconTintColor(checked: Boolean): Color = + if (checked) { + PrezelTheme.colors.feedbackGoodRegular + } else { + PrezelTheme.colors.iconDisabled + } + @BasicPreview @Composable private fun PrezelCheckboxPreview() { diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/TopAppBar.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/TopAppBar.kt index e8309fd8..5163b057 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/TopAppBar.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/TopAppBar.kt @@ -1,6 +1,7 @@ package com.team.prezel.core.designsystem.component import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -27,6 +28,7 @@ fun PrezelTopAppBar( leadingIcon: @Composable () -> Unit = {}, trailingIcons: @Composable RowScope.() -> Unit = {}, scrollBehavior: TopAppBarScrollBehavior? = null, + windowInsets: WindowInsets = WindowInsets(), ) { TopAppBar( title = { @@ -38,6 +40,7 @@ fun PrezelTopAppBar( actions = trailingIcons, colors = prezelTopAppBarColors(), scrollBehavior = scrollBehavior, + windowInsets = windowInsets, modifier = modifier.testTag("PrezelTopAppBar"), ) } diff --git a/Prezel/feature/login/impl/build.gradle.kts b/Prezel/feature/login/impl/build.gradle.kts index 56e519c8..5a113b2e 100644 --- a/Prezel/feature/login/impl/build.gradle.kts +++ b/Prezel/feature/login/impl/build.gradle.kts @@ -1,9 +1,20 @@ +import com.team.prezel.buildlogic.convention.external.localProperty + plugins { alias(libs.plugins.prezel.android.feature.impl) } android { namespace = "com.team.prezel.feature.login.impl" + + buildFeatures { + buildConfig = true + } + + defaultConfig { + buildConfigField("String", "PRIVACY_POLICY_URL", "\"${localProperty("privacy.policy.url").get()}\"") + buildConfigField("String", "TERMS_OF_SERVICE_URL", "\"${localProperty("terms.of.service.url").get()}\"") + } } dependencies { diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/LoginScreen.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt similarity index 88% rename from Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/LoginScreen.kt rename to Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt index 692c020f..67a8a4c0 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/LoginScreen.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.login.impl +package com.team.prezel.feature.login.impl.landing import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope @@ -25,8 +25,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.ui.LocalNavAnimatedContentScope import com.team.prezel.core.auth.AuthManager import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea @@ -40,11 +42,11 @@ import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.LocalSnackbarHostState import com.team.prezel.feature.login.api.AUTH_LOGO_SHARED_ELEMENT_KEY -import com.team.prezel.feature.login.impl.model.LoginUiMessage -import com.team.prezel.feature.login.impl.viewModel.LoginUiEffect -import com.team.prezel.feature.login.impl.viewModel.LoginUiIntent -import com.team.prezel.feature.login.impl.viewModel.LoginUiState -import com.team.prezel.feature.login.impl.viewModel.LoginViewModel +import com.team.prezel.feature.login.impl.R +import com.team.prezel.feature.login.impl.landing.contract.LoginUiEffect +import com.team.prezel.feature.login.impl.landing.contract.LoginUiIntent +import com.team.prezel.feature.login.impl.landing.contract.LoginUiState +import com.team.prezel.feature.login.impl.landing.model.LoginUiMessage import com.team.prezel.core.designsystem.R as DSR private const val AUTH_SHARED_ELEMENT_TRANSITION_DURATION = 300 @@ -52,21 +54,26 @@ private const val AUTH_SHARED_ELEMENT_TRANSITION_DELAY = 400 @Composable internal fun SharedTransitionScope.LoginScreen( - animatedVisibilityScope: AnimatedVisibilityScope, authManager: AuthManager, - navigateToHome: () -> Unit, + navigateToTerms: () -> Unit, modifier: Modifier = Modifier, viewModel: LoginViewModel = hiltViewModel(), ) { val context = LocalContext.current val resources = LocalResources.current - val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = LocalSnackbarHostState.current + val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { viewModel.uiEffect.collect { effect -> when (effect) { - LoginUiEffect.NavigateToHome -> navigateToHome() + is LoginUiEffect.LaunchLogin -> { + authManager.login(context = context, provider = effect.provider).also { result -> + viewModel.onIntent(LoginUiIntent.OnLoginResult(result = result)) + } + } + + LoginUiEffect.NavigateToTerms -> navigateToTerms() is LoginUiEffect.ShowMessage -> { val resId = when (effect.message) { @@ -74,16 +81,7 @@ internal fun SharedTransitionScope.LoginScreen( LoginUiMessage.LoginFailedRateLimited -> R.string.feature_login_impl_kakao_rate_limited LoginUiMessage.LoginFailedUnknown -> R.string.feature_login_impl_kakao_failure } - snackbarHostState.showPrezelSnackbar( - message = resources.getString(resId), - actionLabel = resources.getString(R.string.feature_login_impl_snackbar_confirm), - ) - } - - is LoginUiEffect.LaunchLogin -> { - authManager.login(context = context, provider = effect.provider).also { result -> - viewModel.onIntent(LoginUiIntent.OnLoginResult(result = result)) - } + snackbarHostState.showPrezelSnackbar(message = resources.getString(resId)) } } } @@ -91,7 +89,7 @@ internal fun SharedTransitionScope.LoginScreen( LoginScreen( uiState = uiState, - animatedVisibilityScope = animatedVisibilityScope, + animatedVisibilityScope = LocalNavAnimatedContentScope.current, onLogin = { viewModel.onIntent(LoginUiIntent.OnClickLogin(provider = AuthProvider.KAKAO)) }, modifier = modifier, ) @@ -153,6 +151,7 @@ private fun LoginFooter( modifier: Modifier = Modifier, ) { var isButtonVisible by remember { mutableStateOf(false) } + val startWithKakaoLabel = stringResource(R.string.feature_login_impl_start_with_kakao) val kakaoButtonConfig = PrezelButtonDefaults.getDefault( isIconOnly = false, type = ButtonType.FILLED, @@ -182,7 +181,7 @@ private fun LoginFooter( PrezelButtonArea { CustomButton( iconResId = PrezelIcons.Kakao, - label = "카카오로 시작하기", + label = startWithKakaoLabel, enabled = enabled, onClick = onLogin, config = kakaoButtonConfig, diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/viewModel/LoginViewModel.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt similarity index 57% rename from Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/viewModel/LoginViewModel.kt rename to Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt index fe1400f5..11cf9fb5 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/viewModel/LoginViewModel.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt @@ -1,9 +1,14 @@ -package com.team.prezel.feature.login.impl.viewModel +package com.team.prezel.feature.login.impl.landing import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.auth.model.AuthResult -import com.team.prezel.feature.login.impl.model.LoginUiMessage +import com.team.prezel.feature.login.impl.BuildConfig +import com.team.prezel.feature.login.impl.landing.contract.LoginUiEffect +import com.team.prezel.feature.login.impl.landing.contract.LoginUiIntent +import com.team.prezel.feature.login.impl.landing.contract.LoginUiState +import com.team.prezel.feature.login.impl.landing.model.LoginUiMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -15,12 +20,12 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class LoginViewModel +internal class LoginViewModel @Inject constructor() : ViewModel() { private val _uiState = MutableStateFlow(LoginUiState()) val uiState: StateFlow = _uiState - val currentState: LoginUiState + private val currentState: LoginUiState get() = uiState.value private val _uiEffect = Channel() @@ -28,7 +33,7 @@ class LoginViewModel fun onIntent(intent: LoginUiIntent) { when (intent) { - is LoginUiIntent.OnClickLogin -> handleClickLogin() + is LoginUiIntent.OnClickLogin -> handleClickLogin(provider = intent.provider) is LoginUiIntent.OnLoginResult -> handleLoginResult(result = intent.result) } } @@ -37,14 +42,19 @@ class LoginViewModel _uiState.update(reducer) } - private fun handleClickLogin() { + private fun handleClickLogin(provider: AuthProvider) { if (currentState.isLoading) return viewModelScope.launch { update { copy(isLoading = true) } -// _uiEffect.send(LoginUiEffect.LaunchLogin(provider = provider)) - _uiEffect.send(LoginUiEffect.NavigateToHome) + // todo: MVP 개발 완료 후 해당 조건 제거 + if (BuildConfig.DEBUG) { + update { copy(isLoading = false) } + _uiEffect.send(LoginUiEffect.NavigateToTerms) + } else { + _uiEffect.send(LoginUiEffect.LaunchLogin(provider = provider)) + } } } @@ -53,22 +63,14 @@ class LoginViewModel update { copy(isLoading = false) } when (result) { - AuthResult.Success -> { - _uiEffect.send(LoginUiEffect.NavigateToHome) - } - - AuthResult.Cancelled -> { - _uiEffect.send(LoginUiEffect.ShowMessage(LoginUiMessage.LoginCancelled)) - } - - is AuthResult.Failure -> { - _uiEffect.send(LoginUiEffect.ShowMessage(result.toLoginUiMessage())) - } + AuthResult.Success -> _uiEffect.send(LoginUiEffect.NavigateToTerms) + AuthResult.Cancelled -> _uiEffect.send(LoginUiEffect.ShowMessage(LoginUiMessage.LoginCancelled)) + is AuthResult.Failure -> _uiEffect.send(LoginUiEffect.ShowMessage(result.toUiMessage())) } } } - private fun AuthResult.Failure.toLoginUiMessage(): LoginUiMessage = + private fun AuthResult.Failure.toUiMessage(): LoginUiMessage = when (this) { AuthResult.Failure.RateLimited -> LoginUiMessage.LoginFailedRateLimited AuthResult.Failure.Unknown -> LoginUiMessage.LoginFailedUnknown diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/viewModel/LoginUiEffect.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt similarity index 51% rename from Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/viewModel/LoginUiEffect.kt rename to Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt index 571217c2..2176088e 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/viewModel/LoginUiEffect.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt @@ -1,14 +1,14 @@ -package com.team.prezel.feature.login.impl.viewModel +package com.team.prezel.feature.login.impl.landing.contract import com.team.prezel.core.auth.model.AuthProvider -import com.team.prezel.feature.login.impl.model.LoginUiMessage +import com.team.prezel.feature.login.impl.landing.model.LoginUiMessage -sealed interface LoginUiEffect { +internal sealed interface LoginUiEffect { data class LaunchLogin( val provider: AuthProvider, ) : LoginUiEffect - data object NavigateToHome : LoginUiEffect + data object NavigateToTerms : LoginUiEffect data class ShowMessage( val message: LoginUiMessage, diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/viewModel/LoginUiIntent.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt similarity index 73% rename from Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/viewModel/LoginUiIntent.kt rename to Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt index 2009a45f..17c4d475 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/viewModel/LoginUiIntent.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt @@ -1,9 +1,9 @@ -package com.team.prezel.feature.login.impl.viewModel +package com.team.prezel.feature.login.impl.landing.contract import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.auth.model.AuthResult -sealed interface LoginUiIntent { +internal sealed interface LoginUiIntent { data class OnClickLogin( val provider: AuthProvider, ) : LoginUiIntent diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiState.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiState.kt new file mode 100644 index 00000000..fbc8edc2 --- /dev/null +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiState.kt @@ -0,0 +1,8 @@ +package com.team.prezel.feature.login.impl.landing.contract + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class LoginUiState( + val isLoading: Boolean = false, +) diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/model/LoginUiMessage.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/model/LoginUiMessage.kt new file mode 100644 index 00000000..f9b4149c --- /dev/null +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/model/LoginUiMessage.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.login.impl.landing.model + +internal enum class LoginUiMessage { + LoginCancelled, + LoginFailedRateLimited, + LoginFailedUnknown, +} diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/model/LoginUiMessage.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/model/LoginUiMessage.kt deleted file mode 100644 index 7e491652..00000000 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/model/LoginUiMessage.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.team.prezel.feature.login.impl.model - -enum class LoginUiMessage { - LoginFailedUnknown, - LoginFailedRateLimited, - LoginCancelled, -} diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt index 1176cb98..170e2228 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt @@ -2,13 +2,13 @@ package com.team.prezel.feature.login.impl.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey -import androidx.navigation3.ui.LocalNavAnimatedContentScope import com.team.prezel.core.auth.AuthManager import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.core.navigation.LocalSharedTransitionScope import com.team.prezel.feature.home.api.HomeNavKey import com.team.prezel.feature.login.api.LoginNavKey -import com.team.prezel.feature.login.impl.LoginScreen +import com.team.prezel.feature.login.impl.landing.LoginScreen +import com.team.prezel.feature.login.impl.terms.TermsScreen import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -21,14 +21,26 @@ internal fun EntryProviderScope.featureLoginEntryBuilder(authManager: Au with(LocalSharedTransitionScope.current) { LoginScreen( - animatedVisibilityScope = LocalNavAnimatedContentScope.current, authManager = authManager, - navigateToHome = { - navigator.replaceRoot(HomeNavKey) + navigateToTerms = { + navigator.navigate(LoginTermsNavKey) }, ) } } + + entry { + val navigator = LocalNavigator.current + + TermsScreen( + navigateBack = { + navigator.goBack() + }, + navigateToHome = { + navigator.replaceRoot(HomeNavKey) + }, + ) + } } @Module diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginTermsNavKey.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginTermsNavKey.kt new file mode 100644 index 00000000..443cf480 --- /dev/null +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginTermsNavKey.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.login.impl.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +internal data object LoginTermsNavKey : NavKey diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/TermsScreen.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/TermsScreen.kt new file mode 100644 index 00000000..043f7ee1 --- /dev/null +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/TermsScreen.kt @@ -0,0 +1,318 @@ +package com.team.prezel.feature.login.impl.terms + +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.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.core.designsystem.component.CheckboxSize +import com.team.prezel.core.designsystem.component.PrezelAccordion +import com.team.prezel.core.designsystem.component.PrezelCheckbox +import com.team.prezel.core.designsystem.component.PrezelDividerType +import com.team.prezel.core.designsystem.component.PrezelHorizontalDivider +import com.team.prezel.core.designsystem.component.PrezelTopAppBar +import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea +import com.team.prezel.core.designsystem.component.actions.button.PrezelHyperlinkButton +import com.team.prezel.core.designsystem.component.list.PrezelList +import com.team.prezel.core.designsystem.component.list.PrezelListSize +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.login.impl.BuildConfig +import com.team.prezel.feature.login.impl.R +import com.team.prezel.feature.login.impl.terms.component.TermsDetailModal +import com.team.prezel.feature.login.impl.terms.contract.TermsUiEffect +import com.team.prezel.feature.login.impl.terms.contract.TermsUiIntent +import com.team.prezel.feature.login.impl.terms.contract.TermsUiState + +@Composable +internal fun TermsScreen( + navigateBack: () -> Unit, + navigateToHome: () -> Unit, + modifier: Modifier = Modifier, + viewModel: TermsViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var activeDetailUrl by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + viewModel.uiEffect.collect { effect -> + when (effect) { + TermsUiEffect.NavigateToHome -> navigateToHome() + } + } + } + + activeDetailUrl?.let { url -> + TermsDetailModal( + url = url, + onDismiss = { activeDetailUrl = null }, + ) + } + + TermsScreenScreen( + uiState = uiState, + onBack = navigateBack, + onToggleAll = { viewModel.onIntent(TermsUiIntent.ToggleAll) }, + onToggleTermsOfService = { viewModel.onIntent(TermsUiIntent.ToggleTermsOfService) }, + onTogglePrivacyPolicy = { viewModel.onIntent(TermsUiIntent.TogglePrivacyPolicy) }, + onToggleMarketingConsent = { viewModel.onIntent(TermsUiIntent.ToggleMarketingConsent) }, + onClickTermsOfServiceDetail = { activeDetailUrl = BuildConfig.TERMS_OF_SERVICE_URL }, + onClickPrivacyPolicyDetail = { activeDetailUrl = BuildConfig.PRIVACY_POLICY_URL }, + onContinue = { viewModel.onIntent(TermsUiIntent.ClickContinue) }, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TermsScreenScreen( + uiState: TermsUiState, + onBack: () -> Unit, + onToggleAll: () -> Unit, + onToggleTermsOfService: () -> Unit, + onTogglePrivacyPolicy: () -> Unit, + onToggleMarketingConsent: () -> Unit, + onClickTermsOfServiceDetail: () -> Unit, + onClickPrivacyPolicyDetail: () -> Unit, + onContinue: () -> Unit, + modifier: Modifier = Modifier, +) { + val resources = LocalResources.current + + Column(modifier = modifier.fillMaxSize()) { + TermsScreenTopAppBar(onBack = onBack) + + TermsAgreementContent( + modifier = Modifier.weight(1f), + uiState = uiState, + onToggleAll = onToggleAll, + onToggleTermsOfService = onToggleTermsOfService, + onClickTermsOfServiceDetail = onClickTermsOfServiceDetail, + onTogglePrivacyPolicy = onTogglePrivacyPolicy, + onClickPrivacyPolicyDetail = onClickPrivacyPolicyDetail, + onToggleMarketingConsent = onToggleMarketingConsent, + ) + + PrezelButtonArea { + MainButton( + label = resources.getString(R.string.feature_login_impl_terms_continue_button_text), + enabled = uiState.isRequiredChecked, + onClick = onContinue, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TermsScreenTopAppBar( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + PrezelTopAppBar( + modifier = modifier, + title = { + Text(text = stringResource(R.string.feature_login_impl_terms_title)) + }, + leadingIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(PrezelIcons.ArrowLeft), + contentDescription = stringResource(R.string.feature_login_impl_back_icon_description), + ) + } + }, + ) +} + +@Composable +private fun TermsAgreementContent( + uiState: TermsUiState, + onToggleAll: () -> Unit, + onToggleTermsOfService: () -> Unit, + onClickTermsOfServiceDetail: () -> Unit, + onTogglePrivacyPolicy: () -> Unit, + onClickPrivacyPolicyDetail: () -> Unit, + onToggleMarketingConsent: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .padding( + horizontal = PrezelTheme.spacing.V20, + vertical = PrezelTheme.spacing.V16, + ), + ) { + TermsSelectAllRow( + isAllChecked = uiState.isAllChecked, + onToggleAll = onToggleAll, + ) + + PrezelHorizontalDivider(type = PrezelDividerType.THICK) + + TermsAgreementSection { + TermsAgreementRow( + checked = uiState.isTermsOfServiceChecked, + title = stringResource(R.string.feature_login_impl_terms_of_service), + summary = stringResource(R.string.feature_login_impl_terms_of_service_summary), + isShowDetailButton = true, + onCheckedChange = { onToggleTermsOfService() }, + onClickDetail = onClickTermsOfServiceDetail, + ) + TermsAgreementRow( + checked = uiState.isPrivacyPolicyChecked, + title = stringResource(R.string.feature_login_impl_privacy_policy), + summary = stringResource(R.string.feature_login_impl_privacy_policy_summary), + isShowDetailButton = true, + onCheckedChange = { onTogglePrivacyPolicy() }, + onClickDetail = onClickPrivacyPolicyDetail, + ) + TermsAgreementRow( + checked = uiState.isMarketingConsentChecked, + title = stringResource(R.string.feature_login_impl_marketing_consent), + summary = stringResource(R.string.feature_login_impl_marketing_consent_summary), + isShowDetailButton = false, + onCheckedChange = { onToggleMarketingConsent() }, + onClickDetail = {}, + ) + } + } +} + +@Composable +private fun TermsSelectAllRow( + isAllChecked: Boolean, + onToggleAll: () -> Unit, + modifier: Modifier = Modifier, +) { + PrezelList( + modifier = modifier, + title = stringResource(R.string.feature_login_impl_terms_all), + size = PrezelListSize.REGULAR, + nested = true, + leadingContent = { + PrezelCheckbox( + checked = isAllChecked, + size = CheckboxSize.REGULAR, + onCheckedChange = { onToggleAll() }, + extraTouchPadding = PaddingValues( + start = PrezelTheme.spacing.V8, + top = PrezelTheme.spacing.V8, + bottom = PrezelTheme.spacing.V8, + ), + ) + }, + ) +} + +@Composable +private fun TermsAgreementSection( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(start = PrezelTheme.spacing.V14) + .padding(vertical = PrezelTheme.spacing.V12), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), + content = content, + ) +} + +@Composable +private fun TermsAgreementRow( + checked: Boolean, + title: String, + summary: String, + isShowDetailButton: Boolean, + onCheckedChange: (Boolean) -> Unit, + onClickDetail: () -> Unit, + modifier: Modifier = Modifier, +) { + PrezelAccordion( + modifier = modifier, + title = title, + nested = true, + leadingContent = { + PrezelCheckbox( + checked = checked, + size = CheckboxSize.REGULAR, + onCheckedChange = onCheckedChange, + extraTouchPadding = PaddingValues(), + ) + }, + trailingContent = { + if (isShowDetailButton) { + PrezelHyperlinkButton( + text = stringResource(R.string.feature_login_impl_terms_detail_button_text), + onClick = onClickDetail, + ) + } + }, + ) { + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V8)) + Box( + modifier = Modifier + .fillMaxWidth() + .background(PrezelTheme.colors.bgMedium) + .padding(all = PrezelTheme.spacing.V12), + ) { + Text( + text = summary, + style = PrezelTheme.typography.caption2Regular, + color = PrezelTheme.colors.textLarge, + ) + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +private fun TermsScreenPreview() { + var uiState by remember { mutableStateOf(TermsUiState()) } + + PrezelTheme { + TermsScreenScreen( + uiState = uiState, + onBack = {}, + onToggleAll = { + val checked = !uiState.isAllChecked + uiState = uiState.copy( + isTermsOfServiceChecked = checked, + isPrivacyPolicyChecked = checked, + isMarketingConsentChecked = checked, + ) + }, + onToggleTermsOfService = { uiState = uiState.copy(isTermsOfServiceChecked = !uiState.isTermsOfServiceChecked) }, + onTogglePrivacyPolicy = { uiState = uiState.copy(isPrivacyPolicyChecked = !uiState.isPrivacyPolicyChecked) }, + onToggleMarketingConsent = { uiState = uiState.copy(isMarketingConsentChecked = !uiState.isMarketingConsentChecked) }, + onClickTermsOfServiceDetail = {}, + onClickPrivacyPolicyDetail = {}, + onContinue = {}, + ) + } +} diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/TermsViewModel.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/TermsViewModel.kt new file mode 100644 index 00000000..0d838b3b --- /dev/null +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/TermsViewModel.kt @@ -0,0 +1,74 @@ +package com.team.prezel.feature.login.impl.terms + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.team.prezel.feature.login.impl.terms.contract.TermsUiEffect +import com.team.prezel.feature.login.impl.terms.contract.TermsUiIntent +import com.team.prezel.feature.login.impl.terms.contract.TermsUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class TermsViewModel + @Inject + constructor() : ViewModel() { + private val _uiState = MutableStateFlow(TermsUiState()) + val uiState: StateFlow = _uiState + private val currentState: TermsUiState + get() = uiState.value + + private val _uiEffect = Channel() + val uiEffect: Flow = _uiEffect.receiveAsFlow() + + fun onIntent(intent: TermsUiIntent) { + when (intent) { + TermsUiIntent.ToggleAll -> toggleAll() + TermsUiIntent.ToggleTermsOfService -> toggleTermsOfService() + TermsUiIntent.TogglePrivacyPolicy -> togglePrivacyPolicy() + TermsUiIntent.ToggleMarketingConsent -> toggleMarketingConsent() + TermsUiIntent.ClickContinue -> handleClickContinue() + } + } + + private fun update(reducer: TermsUiState.() -> TermsUiState) { + _uiState.update(reducer) + } + + private fun toggleAll() { + val newChecked = !currentState.isAllChecked + + update { + copy( + isTermsOfServiceChecked = newChecked, + isPrivacyPolicyChecked = newChecked, + isMarketingConsentChecked = newChecked, + ) + } + } + + private fun toggleTermsOfService() { + update { copy(isTermsOfServiceChecked = !isTermsOfServiceChecked) } + } + + private fun togglePrivacyPolicy() { + update { copy(isPrivacyPolicyChecked = !isPrivacyPolicyChecked) } + } + + private fun toggleMarketingConsent() { + update { copy(isMarketingConsentChecked = !isMarketingConsentChecked) } + } + + private fun handleClickContinue() { + if (!currentState.isRequiredChecked) return + viewModelScope.launch { + _uiEffect.send(TermsUiEffect.NavigateToHome) + } + } + } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/component/TermsDetailModal.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/component/TermsDetailModal.kt new file mode 100644 index 00000000..87c2b7db --- /dev/null +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/component/TermsDetailModal.kt @@ -0,0 +1,114 @@ +package com.team.prezel.feature.login.impl.terms.component + +import android.annotation.SuppressLint +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.zIndex +import com.team.prezel.core.designsystem.component.PrezelTopAppBar +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.login.impl.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun TermsDetailModal( + url: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = onDismiss) + + Column( + modifier = modifier + .background(PrezelTheme.colors.bgRegular) + .zIndex(1f) + .fillMaxSize(), + ) { + PrezelTopAppBar( + trailingIcons = { + IconButton(onClick = onDismiss) { + Icon( + painter = painterResource(PrezelIcons.Cancel), + contentDescription = stringResource(R.string.feature_login_impl_cancel_icon_description), + ) + } + }, + ) + NotionWebView(url = url, modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun NotionWebView( + url: String, + modifier: Modifier = Modifier, +) { + val webView = rememberWebView(url) + + AndroidView( + modifier = modifier, + factory = { webView }, + ) +} + +private const val NOTION_STYLE_PATCH = """ + (function() { + var style = document.createElement('style'); + style.innerHTML = ` + html, body, * { + overflow-y: auto !important; + height: auto !important; + } + `; + document.head.appendChild(style); + })(); +""" + +@SuppressLint("SetJavaScriptEnabled") +@Composable +private fun rememberWebView(url: String): WebView { + val context = LocalContext.current + + val webView = remember(url) { + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + + webViewClient = object : WebViewClient() { + override fun onPageFinished( + view: WebView?, + loadedUrl: String?, + ) { + val targetView = view ?: return + targetView.evaluateJavascript(NOTION_STYLE_PATCH.trimIndent(), null) + } + } + + loadUrl(url) + } + } + + DisposableEffect(url) { + onDispose { + webView.stopLoading() + webView.destroy() + } + } + + return webView +} diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiEffect.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiEffect.kt new file mode 100644 index 00000000..69e4755e --- /dev/null +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiEffect.kt @@ -0,0 +1,5 @@ +package com.team.prezel.feature.login.impl.terms.contract + +internal sealed interface TermsUiEffect { + data object NavigateToHome : TermsUiEffect +} diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiIntent.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiIntent.kt new file mode 100644 index 00000000..d4c0264c --- /dev/null +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiIntent.kt @@ -0,0 +1,13 @@ +package com.team.prezel.feature.login.impl.terms.contract + +internal sealed interface TermsUiIntent { + data object ToggleAll : TermsUiIntent + + data object ToggleTermsOfService : TermsUiIntent + + data object TogglePrivacyPolicy : TermsUiIntent + + data object ToggleMarketingConsent : TermsUiIntent + + data object ClickContinue : TermsUiIntent +} diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiState.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiState.kt new file mode 100644 index 00000000..d9bfad75 --- /dev/null +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiState.kt @@ -0,0 +1,14 @@ +package com.team.prezel.feature.login.impl.terms.contract + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class TermsUiState( + val isTermsOfServiceChecked: Boolean = false, + val isPrivacyPolicyChecked: Boolean = false, + val isMarketingConsentChecked: Boolean = false, +) { + val isRequiredChecked: Boolean = isTermsOfServiceChecked && isPrivacyPolicyChecked + + val isAllChecked: Boolean = isTermsOfServiceChecked && isPrivacyPolicyChecked && isMarketingConsentChecked +} diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/viewModel/LoginUiState.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/viewModel/LoginUiState.kt deleted file mode 100644 index 3c2ab3ea..00000000 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/viewModel/LoginUiState.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.team.prezel.feature.login.impl.viewModel - -import androidx.compose.runtime.Immutable - -@Immutable -data class LoginUiState( - val isLoading: Boolean = false, -) diff --git a/Prezel/feature/login/impl/src/main/res/values/strings.xml b/Prezel/feature/login/impl/src/main/res/values/strings.xml index 021ab975..cab90d27 100644 --- a/Prezel/feature/login/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/login/impl/src/main/res/values/strings.xml @@ -1,7 +1,28 @@ + 카카오 로그인에 문제가 발생했어요.\n잠시 후 다시 시도해 주세요. 로그인 시도가 너무 많아요.\n잠시 후 다시 시도해 주세요. 로그인이 취소되었어요. - 확인 + 카카오로 시작하기 + 뒤로가기 + 닫기 + + + 약관동의 + 전체 동의 + (필수) 이용약관 + (필수) 개인정보 정책 + (선택) 데이터 활용 동의 + 자세히 보기 + 다음 + + 본 약관은 서비스 이용과 관련한 기본적인 권리·의무 및 책임사항을 규정합니다. + + 서비스 제공을 위해 개인정보를 수집 · 이용합니다.\n + 발표 연습을 위한 음성 녹음 및 분석 데이터 처리 내용이 포함됩니다.\n\n + 수집 항목 : 계정 정보, 음성 녹음 파일, 음성 분석 결과, 발표 대본, 서비스 이용 기록 등\n\n + 수집 목적 : 발표 분석, 개인 맞춤 피드백 제공, 연습 기록 관리 + + 서비스 품질 향상 및 기능 개선을 위해 비식별 처리된 분석 데이터를 활용할 수 있습니다.