diff --git a/Prezel/app/build.gradle.kts b/Prezel/app/build.gradle.kts index 780cc16d..e99a933f 100644 --- a/Prezel/app/build.gradle.kts +++ b/Prezel/app/build.gradle.kts @@ -50,6 +50,8 @@ dependencies { implementation(projects.featureHistoryImpl) implementation(projects.featureMyApi) implementation(projects.featureMyImpl) + implementation(projects.featureSettingApi) + implementation(projects.featureSettingImpl) implementation(projects.featureProfileImpl) implementation(libs.androidx.core.ktx) diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/DoubleBackToExitHandler.kt b/Prezel/app/src/main/java/com/team/prezel/ui/DoubleBackToExitHandler.kt index 3aadb420..d663fd8e 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/DoubleBackToExitHandler.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/DoubleBackToExitHandler.kt @@ -45,9 +45,8 @@ internal fun DoubleBackToExitHandler(navigationState: NavigationState) { snackbarState.showPrezelSnackbar( id = SNACKBAR_IDENTIFIER, message = resources.getString(R.string.double_back_to_exit_snackbar_message), - actionLabel = resources.getString(R.string.double_back_to_exit_snackbar_action_label), - onAction = { backPressState = BackPressState.Idle }, onDismiss = { backPressState = BackPressState.Idle }, + useRaisedPosition = false, ) } diff --git a/Prezel/app/src/main/res/values/strings.xml b/Prezel/app/src/main/res/values/strings.xml index b8740117..dff017bb 100644 --- a/Prezel/app/src/main/res/values/strings.xml +++ b/Prezel/app/src/main/res/values/strings.xml @@ -5,6 +5,5 @@ 히스토리 프로필 - 한 번 더 누르면 앱을 종료합니다 - 닫기 + 한 번 더 누르면 앱을 종료합니다. diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 4049363a..dc432761 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -37,30 +37,24 @@ internal class AuthRepositoryImpl @Inject constructor( override suspend fun withdraw(reason: WithdrawReason): Result = runCatching { authRemoteDataSource.withdraw( - reasonCategory = reason.toCategory(), - reasonText = reason.toReasonText(), + reasonCategory = reason.toReasonCategory(), + reasonText = (reason as? WithdrawReason.Etc)?.text, ) clearLocalSession() }.mapDomainFailure() - private fun WithdrawReason.toCategory(): String = - when (this) { - WithdrawReason.NotUsedOften -> "NOT_USED_OFTEN" - WithdrawReason.NoLongerNeeded -> "NO_LONGER_NEEDED" - WithdrawReason.TooDifficultOrComplex -> "TOO_COMPLEX" - WithdrawReason.AnalysisResultInaccurate -> "INACCURATE_ANALYSIS" - WithdrawReason.TooManyErrors -> "MANY_ERRORS" - is WithdrawReason.Other -> "ETC" - } - - private fun WithdrawReason.toReasonText(): String = - when (this) { - is WithdrawReason.Other -> text - else -> "" - } - private suspend fun clearLocalSession() { authLocalDataSource.clearTokens() authSessionCache.clear() } + + private fun WithdrawReason.toReasonCategory(): String = + when (this) { + WithdrawReason.NotUsedOften -> "NOT_USED_OFTEN" + WithdrawReason.NoLongerNeeded -> "NO_LONGER_NEEDED" + WithdrawReason.TooComplex -> "TOO_COMPLEX" + WithdrawReason.InaccurateAnalysis -> "INACCURATE_ANALYSIS" + WithdrawReason.ManyErrors -> "MANY_ERRORS" + is WithdrawReason.Etc -> "ETC" + } } diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/actions/area/PrezelButtonArea.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/actions/area/PrezelButtonArea.kt index c77af2d0..8928b2e1 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/actions/area/PrezelButtonArea.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/actions/area/PrezelButtonArea.kt @@ -22,6 +22,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.team.prezel.core.designsystem.component.PrezelDividerType import com.team.prezel.core.designsystem.component.PrezelHorizontalDivider +import com.team.prezel.core.designsystem.component.actions.button.PrezelButton +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.core.designsystem.preview.PreviewSection import com.team.prezel.core.designsystem.theme.PrezelTheme @@ -40,16 +43,9 @@ fun PrezelButtonArea( showBackground: Boolean = false, isNested: Boolean = false, config: PrezelButtonAreaDefault = PrezelButtonAreaDefaults.getDefault(), - content: ButtonAreaScope.() -> Unit, + mainButton: @Composable (Modifier) -> Unit, + subButton: @Composable ((Modifier) -> Unit)? = null, ) { - val scope = DefaultButtonAreaScope().apply { - this.content() - } - - val mainButton = requireNotNull(scope.buttons.firstOrNull()) { - "PrezelButtonArea에는 최소 1개의 버튼이 필요합니다." - } - val subButton = scope.buttons.getOrNull(1) val contentModifier = if (isNested) Modifier else Modifier.padding(config.contentPadding) Column( @@ -247,21 +243,29 @@ private fun ButtonAreaPreviewSample( isVertical = variant.isVertical, isStrongStrength = variant.isStrongStrength, showBackground = showBackground, - ) { - MainButton( - iconResId = PrezelIcons.Blank, - label = "Label", - enabled = enabled, - onClick = {}, - ) - - SubButton( - iconResId = PrezelIcons.Blank, - label = "Label", - enabled = enabled, - onClick = {}, - ) - } + mainButton = { modifier -> + PrezelButton( + modifier = modifier, + text = "Label", + iconResId = PrezelIcons.Blank, + onClick = { }, + enabled = enabled, + type = ButtonType.FILLED, + hierarchy = ButtonHierarchy.PRIMARY, + ) + }, + subButton = { modifier -> + PrezelButton( + modifier = modifier, + text = "Label", + iconResId = PrezelIcons.Blank, + onClick = { }, + enabled = enabled, + type = ButtonType.FILLED, + hierarchy = ButtonHierarchy.SECONDARY, + ) + }, + ) } @Composable diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/PrezelDatePicker.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/PrezelDatePicker.kt index 0d07bb72..bf4bc363 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/PrezelDatePicker.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/datepicker/PrezelDatePicker.kt @@ -33,6 +33,9 @@ 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.PrezelButton +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType import com.team.prezel.core.designsystem.component.datepicker.config.DatePickerDefault import com.team.prezel.core.designsystem.component.datepicker.config.DatePickerDefaults import com.team.prezel.core.designsystem.component.datepicker.config.DatePickerMonth @@ -120,18 +123,20 @@ private fun DatePickerFooter( enabled: Boolean, onClick: () -> Unit, ) { - val buttonLabel = stringResource(R.string.core_designsystem_date_picker_confirm_btn) - PrezelButtonArea( isVertical = false, showBackground = true, - ) { - MainButton( - label = buttonLabel, - enabled = enabled, - onClick = onClick, - ) - } + mainButton = { modifier -> + PrezelButton( + modifier = modifier, + text = stringResource(R.string.core_designsystem_date_picker_confirm_btn), + onClick = onClick, + enabled = enabled, + type = ButtonType.FILLED, + hierarchy = ButtonHierarchy.PRIMARY, + ) + }, + ) } @Composable diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/feedback/dialog/PrezelDialog.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/feedback/dialog/PrezelDialog.kt index 6e65db45..26d93cd0 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/feedback/dialog/PrezelDialog.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/feedback/dialog/PrezelDialog.kt @@ -24,9 +24,9 @@ import com.team.prezel.core.designsystem.theme.PrezelTheme @Composable fun PrezelDialog( title: String, - description: String, onDismiss: () -> Unit, modifier: Modifier = Modifier, + description: String? = null, content: @Composable PrezelDialogScope.() -> Unit, ) { val scope = remember { PrezelDialogScope() } @@ -59,7 +59,7 @@ fun PrezelDialog( @Composable private fun DialogContent( title: String, - description: String, + description: String?, modifier: Modifier = Modifier, ) { Column( @@ -67,7 +67,9 @@ private fun DialogContent( ) { Text(text = title, style = PrezelTheme.typography.title2Bold, color = PrezelTheme.colors.textLarge) Spacer(modifier = Modifier.height(PrezelTheme.spacing.V12)) - Text(text = description, style = PrezelTheme.typography.body3Medium, color = PrezelTheme.colors.textRegular) + description?.let { text -> + Text(text = text, style = PrezelTheme.typography.body3Medium, color = PrezelTheme.colors.textRegular) + } } } diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/feedback/snackbar/SnackbarHost.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/feedback/snackbar/SnackbarHost.kt index 498c8bf1..c708229c 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/feedback/snackbar/SnackbarHost.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/feedback/snackbar/SnackbarHost.kt @@ -28,7 +28,7 @@ suspend fun SnackbarHostState.showPrezelSnackbar( @DrawableRes leadingIconResId: Int? = null, duration: SnackbarDuration = SnackbarDuration.Short, id: String? = null, - offsetY: Dp = 0.dp, + useRaisedPosition: Boolean = true, onDismiss: (() -> Unit)? = null, ) { val result = showSnackbar( @@ -38,7 +38,7 @@ suspend fun SnackbarHostState.showPrezelSnackbar( duration = duration, id = id, leadingIconResId = leadingIconResId, - offsetY = offsetY, + offsetY = if (useRaisedPosition) (-98).dp else 0.dp, ), ) diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/WithdrawReason.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/WithdrawReason.kt index 0505c618..796dc961 100644 --- a/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/WithdrawReason.kt +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/WithdrawReason.kt @@ -5,13 +5,13 @@ sealed interface WithdrawReason { data object NoLongerNeeded : WithdrawReason - data object TooDifficultOrComplex : WithdrawReason + data object TooComplex : WithdrawReason - data object AnalysisResultInaccurate : WithdrawReason + data object InaccurateAnalysis : WithdrawReason - data object TooManyErrors : WithdrawReason + data object ManyErrors : WithdrawReason - data class Other( + data class Etc( val text: String, ) : WithdrawReason } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt index 12fb38f0..b72c01b5 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt @@ -12,6 +12,6 @@ interface AuthRemoteDataSource { suspend fun withdraw( reasonCategory: String, - reasonText: String, + reasonText: String?, ) } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt index 5de6b66b..41ddf90a 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt @@ -24,11 +24,10 @@ internal class AuthRemoteDataSourceImpl @Inject constructor( override suspend fun withdraw( reasonCategory: String, - reasonText: String, + reasonText: String?, ) { authService - .withdraw( - request = WithdrawRequest(reasonCategory = reasonCategory, reasonText = reasonText), - ).requireSuccess() + .withdraw(request = WithdrawRequest(reasonCategory = reasonCategory, reasonText = reasonText)) + .requireSuccess() } } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/WithdrawRequest.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/WithdrawRequest.kt index 4f6fb74a..490c61e7 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/WithdrawRequest.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/WithdrawRequest.kt @@ -8,5 +8,5 @@ data class WithdrawRequest( @SerialName("reasonCategory") val reasonCategory: String, @SerialName("reasonText") - val reasonText: String, + val reasonText: String?, ) diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/util/NoRippleClickable.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/util/NoRippleClickable.kt new file mode 100644 index 00000000..30716e9b --- /dev/null +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/util/NoRippleClickable.kt @@ -0,0 +1,16 @@ +package com.team.prezel.core.ui.util + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed + +fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier = + composed { + clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onClick, + ) + } 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/LoginScreen.kt index 67f15b36..9fa7fc43 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/LoginScreen.kt @@ -31,6 +31,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.ui.LocalNavAnimatedContentScope import com.team.prezel.core.auth.AuthManager import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea +import com.team.prezel.core.designsystem.component.actions.button.PrezelButton import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy import com.team.prezel.core.designsystem.component.actions.button.config.ButtonSize import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType @@ -153,16 +154,6 @@ 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, - size = ButtonSize.REGULAR, - hierarchy = ButtonHierarchy.SECONDARY, - isRounded = false, - backgroundColor = Color(0xFFFEE500), - contentColor = PrezelTheme.colors.textLarge, - ) LaunchedEffect(Unit) { isButtonVisible = true @@ -180,15 +171,26 @@ private fun LoginFooter( ), exit = ExitTransition.None, ) { - PrezelButtonArea { - CustomButton( - iconResId = PrezelIcons.Kakao, - label = startWithKakaoLabel, - enabled = enabled, - onClick = onLogin, - config = kakaoButtonConfig, - ) - } + PrezelButtonArea( + mainButton = { modifier -> + PrezelButton( + modifier = modifier, + text = stringResource(R.string.feature_login_impl_start_with_kakao), + iconResId = PrezelIcons.Kakao, + enabled = enabled, + onClick = onLogin, + config = PrezelButtonDefaults.getDefault( + isIconOnly = false, + type = ButtonType.FILLED, + size = ButtonSize.REGULAR, + hierarchy = ButtonHierarchy.SECONDARY, + isRounded = false, + backgroundColor = Color(0xFFFEE500), + contentColor = PrezelTheme.colors.textLarge, + ), + ) + }, + ) } } 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 aeccef0f..d9660fff 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 @@ -27,7 +27,7 @@ internal fun EntryProviderScope.featureLoginEntryBuilder(authManager: Au navigator.replaceRoot(HomeNavKey) }, navigateToTerms = { - navigator.navigate(TermsNavKey) + navigator.navigate(TermsNavKey.List) }, navigateToCreateProfile = { navigator.navigate(ProfileNavKey.Create) diff --git a/Prezel/feature/my/impl/build.gradle.kts b/Prezel/feature/my/impl/build.gradle.kts index 2f8f09c5..3f8a2585 100644 --- a/Prezel/feature/my/impl/build.gradle.kts +++ b/Prezel/feature/my/impl/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { implementation(projects.coreModel) implementation(projects.featureMyApi) + implementation(projects.featureSettingApi) implementation(projects.featureProfileApi) implementation(libs.kotlinx.collections.immutable) diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/component/MyTopAppBar.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/component/MyTopAppBar.kt index 7b1fce04..7cc5603a 100644 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/component/MyTopAppBar.kt +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/component/MyTopAppBar.kt @@ -2,14 +2,14 @@ package com.team.prezel.feature.my.impl.component import androidx.compose.foundation.layout.fillMaxWidth 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.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.team.prezel.core.designsystem.component.PrezelTopAppBar -import com.team.prezel.core.designsystem.component.actions.button.PrezelIconButton -import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy -import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme @@ -22,12 +22,12 @@ internal fun MyTopAppBar(onClickSetting: () -> Unit) { modifier = Modifier.fillMaxWidth(), title = { Text(text = stringResource(R.string.feature_my_impl_title)) }, trailingIcons = { - PrezelIconButton( - iconResId = PrezelIcons.Setting, - type = ButtonType.GHOST, - hierarchy = ButtonHierarchy.SECONDARY, - onClick = onClickSetting, - ) + IconButton(onClick = onClickSetting) { + Icon( + painter = painterResource(PrezelIcons.Setting), + contentDescription = null, + ) + } }, ) } diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/navigation/MyEntryBuilder.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/navigation/MyEntryBuilder.kt index b128397a..69373811 100644 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/navigation/MyEntryBuilder.kt +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/navigation/MyEntryBuilder.kt @@ -6,6 +6,7 @@ import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.feature.my.api.MyNavKey import com.team.prezel.feature.my.impl.MyScreen import com.team.prezel.feature.profile.api.ProfileNavKey +import com.team.prezel.feature.setting.api.SettingNavKey import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -18,7 +19,7 @@ internal fun EntryProviderScope.featureMyEntryBuilder() { MyScreen( navigateToEditProfile = { navigator.navigate(ProfileNavKey.Edit) }, - navigateToSetting = { /* navigator.navigate(ProfileNavKey.Setting) */ }, + navigateToSetting = { navigator.navigate(SettingNavKey) }, navigateToBadge = { /* navigator.navigate(ProfileNavKey.Badge) */ }, ) } diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt index 8277ed87..67046e02 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt @@ -26,6 +26,9 @@ import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea +import com.team.prezel.core.designsystem.component.actions.button.PrezelButton +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme @@ -99,7 +102,6 @@ private fun ProfileScreen( modifier: Modifier = Modifier, ) { val contentState = uiState as? ProfileUiState.Content - val submitButtonText = stringResource(R.string.feature_profile_impl_submit_button_text) Column(modifier = modifier.fillMaxSize()) { ProfileScreenTopAppBar( @@ -121,13 +123,17 @@ private fun ProfileScreen( PrezelButtonArea( showBackground = true, modifier = Modifier.advancedImePadding(), - ) { - MainButton( - label = submitButtonText, - enabled = contentState?.submitButtonEnabled ?: false, - onClick = onClickSubmit, - ) - } + mainButton = { modifier -> + PrezelButton( + modifier = modifier, + text = stringResource(R.string.feature_profile_impl_submit_button_text), + onClick = onClickSubmit, + enabled = contentState?.submitButtonEnabled ?: false, + type = ButtonType.FILLED, + hierarchy = ButtonHierarchy.PRIMARY, + ) + }, + ) } } diff --git a/Prezel/feature/setting/api/build.gradle.kts b/Prezel/feature/setting/api/build.gradle.kts new file mode 100644 index 00000000..169ab6b4 --- /dev/null +++ b/Prezel/feature/setting/api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.prezel.android.feature.api) +} + +android { + namespace = "com.team.prezel.feature.setting.api" +} diff --git a/Prezel/feature/setting/api/consumer-rules.pro b/Prezel/feature/setting/api/consumer-rules.pro new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/Prezel/feature/setting/api/consumer-rules.pro @@ -0,0 +1 @@ + diff --git a/Prezel/feature/setting/api/proguard-rules.pro b/Prezel/feature/setting/api/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/Prezel/feature/setting/api/proguard-rules.pro @@ -0,0 +1,21 @@ +# 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 + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/Prezel/feature/setting/api/src/main/java/com/team/prezel/feature/setting/api/DeleteAccountNavKey.kt b/Prezel/feature/setting/api/src/main/java/com/team/prezel/feature/setting/api/DeleteAccountNavKey.kt new file mode 100644 index 00000000..609ee04a --- /dev/null +++ b/Prezel/feature/setting/api/src/main/java/com/team/prezel/feature/setting/api/DeleteAccountNavKey.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.setting.api + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +data object DeleteAccountNavKey : NavKey diff --git a/Prezel/feature/setting/api/src/main/java/com/team/prezel/feature/setting/api/SettingNavKey.kt b/Prezel/feature/setting/api/src/main/java/com/team/prezel/feature/setting/api/SettingNavKey.kt new file mode 100644 index 00000000..36805dbe --- /dev/null +++ b/Prezel/feature/setting/api/src/main/java/com/team/prezel/feature/setting/api/SettingNavKey.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.setting.api + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +data object SettingNavKey : NavKey diff --git a/Prezel/feature/setting/impl/build.gradle.kts b/Prezel/feature/setting/impl/build.gradle.kts new file mode 100644 index 00000000..961e8a91 --- /dev/null +++ b/Prezel/feature/setting/impl/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + alias(libs.plugins.prezel.android.feature.impl) +} + +android { + namespace = "com.team.prezel.feature.setting.impl" +} + +dependencies { + implementation(projects.coreDomain) + implementation(projects.coreModel) + + implementation(projects.featureSettingApi) + implementation(projects.featureSplashApi) + implementation(projects.featureTermsApi) + + implementation(libs.kotlinx.collections.immutable) +} diff --git a/Prezel/feature/setting/impl/consumer-rules.pro b/Prezel/feature/setting/impl/consumer-rules.pro new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/Prezel/feature/setting/impl/consumer-rules.pro @@ -0,0 +1 @@ + diff --git a/Prezel/feature/setting/impl/proguard-rules.pro b/Prezel/feature/setting/impl/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/Prezel/feature/setting/impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# 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 + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/DeleteAccountScreen.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/DeleteAccountScreen.kt new file mode 100644 index 00000000..eb34badf --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/DeleteAccountScreen.kt @@ -0,0 +1,188 @@ +package com.team.prezel.feature.setting.impl.delete + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.state.LocalSnackbarHostState +import com.team.prezel.feature.setting.impl.R +import com.team.prezel.feature.setting.impl.delete.component.DeleteAccountActionSection +import com.team.prezel.feature.setting.impl.delete.component.DeleteAccountConfirmDialog +import com.team.prezel.feature.setting.impl.delete.component.DeleteAccountNoticeStep +import com.team.prezel.feature.setting.impl.delete.component.DeleteAccountReasonStep +import com.team.prezel.feature.setting.impl.delete.component.DeleteAccountTopAppBar +import com.team.prezel.feature.setting.impl.delete.contract.DeleteAccountUiEffect +import com.team.prezel.feature.setting.impl.delete.contract.DeleteAccountUiIntent +import com.team.prezel.feature.setting.impl.delete.contract.DeleteAccountUiState +import com.team.prezel.feature.setting.impl.delete.model.DeleteAccountReasonOption +import com.team.prezel.feature.setting.impl.delete.model.DeleteAccountStep +import com.team.prezel.feature.setting.impl.delete.model.DeleteAccountUiMessage + +@Composable +internal fun DeleteAccountScreen( + navigateBack: () -> Unit, + navigateToSplash: () -> Unit, + modifier: Modifier = Modifier, + viewModel: DeleteAccountViewModel = hiltViewModel(), +) { + val snackbarHostState = LocalSnackbarHostState.current + val resources = LocalResources.current + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(viewModel) { + viewModel.uiEffect.collect { effect -> + when (effect) { + DeleteAccountUiEffect.NavigateToSplash -> { + navigateToSplash() + snackbarHostState.showPrezelSnackbar(message = resources.getString(R.string.feature_setting_impl_delete_account_withdraw_success)) + } + is DeleteAccountUiEffect.ShowMessage -> { + snackbarHostState.showPrezelSnackbar(message = resources.getString(effect.message.toMessageRes())) + } + } + } + } + + BackHandler { + navigateBack() + } + + DeleteAccountScreen( + uiState = uiState, + onClickClose = navigateBack, + onToggleNoticeChecked = { checked -> + viewModel.onIntent(DeleteAccountUiIntent.ToggleNoticeChecked(checked)) + }, + onClickNext = { viewModel.onIntent(DeleteAccountUiIntent.ClickNext) }, + onSelectReason = { reason -> viewModel.onIntent(DeleteAccountUiIntent.SelectReason(reason)) }, + onOtherReasonChanged = { value -> viewModel.onIntent(DeleteAccountUiIntent.ChangeOtherReason(value)) }, + onClickWithdraw = { viewModel.onIntent(DeleteAccountUiIntent.ClickWithdraw) }, + onDismissDialog = { viewModel.onIntent(DeleteAccountUiIntent.DismissDialog) }, + onConfirmWithdraw = { viewModel.onIntent(DeleteAccountUiIntent.ConfirmWithdraw) }, + modifier = modifier, + ) +} + +private fun DeleteAccountUiMessage.toMessageRes(): Int = + when (this) { + DeleteAccountUiMessage.WITHDRAW_FAILED -> R.string.feature_setting_impl_delete_account_withdraw_failed + } + +@Composable +private fun DeleteAccountScreen( + uiState: DeleteAccountUiState, + onClickClose: () -> Unit, + onToggleNoticeChecked: (Boolean) -> Unit, + onClickNext: () -> Unit, + onSelectReason: (DeleteAccountReasonOption) -> Unit, + onOtherReasonChanged: (String) -> Unit, + onClickWithdraw: () -> Unit, + onDismissDialog: () -> Unit, + onConfirmWithdraw: () -> Unit, + modifier: Modifier = Modifier, +) { + if (uiState.isConfirmDialogVisible) { + DeleteAccountConfirmDialog( + onDismissDialog = onDismissDialog, + onConfirmWithdraw = onConfirmWithdraw, + ) + } + + Column(modifier = modifier.fillMaxSize()) { + DeleteAccountTopAppBar(onClickClose = onClickClose) + + DeleteAccountContent( + uiState = uiState, + onToggleNoticeChecked = onToggleNoticeChecked, + onSelectReason = onSelectReason, + onOtherReasonChanged = onOtherReasonChanged, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) + + DeleteAccountActionSection( + step = uiState.step, + enabled = uiState.isPrimaryActionEnabled, + onClickNext = onClickNext, + onClickWithdraw = onClickWithdraw, + ) + } +} + +@Composable +private fun DeleteAccountContent( + uiState: DeleteAccountUiState, + onToggleNoticeChecked: (Boolean) -> Unit, + onSelectReason: (DeleteAccountReasonOption) -> Unit, + onOtherReasonChanged: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + when (uiState.step) { + DeleteAccountStep.NOTICE -> DeleteAccountNoticeStep( + isChecked = uiState.isNoticeChecked, + onCheckedChange = onToggleNoticeChecked, + ) + + DeleteAccountStep.REASON -> DeleteAccountReasonStep( + selectedReason = uiState.selectedReason, + otherReasonText = uiState.otherReasonText, + onSelectReason = onSelectReason, + onOtherReasonChanged = onOtherReasonChanged, + ) + } + } +} + +@BasicPreview +@Composable +private fun DeleteAccountNoticeStepScreenPreview() { + PrezelTheme { + DeleteAccountScreen( + uiState = DeleteAccountUiState( + step = DeleteAccountStep.NOTICE, + ), + onClickClose = {}, + onToggleNoticeChecked = {}, + onClickNext = {}, + onSelectReason = {}, + onOtherReasonChanged = {}, + onClickWithdraw = {}, + onDismissDialog = {}, + onConfirmWithdraw = {}, + ) + } +} + +@BasicPreview +@Composable +private fun DeleteAccountReasonStepScreenPreview() { + PrezelTheme { + DeleteAccountScreen( + uiState = DeleteAccountUiState( + step = DeleteAccountStep.REASON, + selectedReason = DeleteAccountReasonOption.Etc, + ), + onClickClose = {}, + onToggleNoticeChecked = {}, + onClickNext = {}, + onSelectReason = {}, + onOtherReasonChanged = {}, + onClickWithdraw = {}, + onDismissDialog = {}, + onConfirmWithdraw = {}, + ) + } +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/DeleteAccountViewModel.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/DeleteAccountViewModel.kt new file mode 100644 index 00000000..2adc9e2a --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/DeleteAccountViewModel.kt @@ -0,0 +1,92 @@ +package com.team.prezel.feature.setting.impl.delete + +import androidx.lifecycle.viewModelScope +import com.team.prezel.core.domain.usecase.auth.WithdrawUseCase +import com.team.prezel.core.model.auth.WithdrawReason +import com.team.prezel.core.ui.base.BaseViewModel +import com.team.prezel.feature.setting.impl.delete.contract.DeleteAccountUiEffect +import com.team.prezel.feature.setting.impl.delete.contract.DeleteAccountUiIntent +import com.team.prezel.feature.setting.impl.delete.contract.DeleteAccountUiState +import com.team.prezel.feature.setting.impl.delete.model.DeleteAccountReasonOption +import com.team.prezel.feature.setting.impl.delete.model.DeleteAccountStep +import com.team.prezel.feature.setting.impl.delete.model.DeleteAccountUiMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +internal class DeleteAccountViewModel @Inject constructor( + private val withdrawUseCase: WithdrawUseCase, +) : BaseViewModel(DeleteAccountUiState()) { + override fun onIntent(intent: DeleteAccountUiIntent) { + when (intent) { + DeleteAccountUiIntent.ClickNext -> moveToReasonStep() + DeleteAccountUiIntent.ClickWithdraw -> showConfirmDialog() + DeleteAccountUiIntent.ConfirmWithdraw -> withdraw() + DeleteAccountUiIntent.DismissDialog -> dismissDialog() + is DeleteAccountUiIntent.ChangeOtherReason -> updateState { copy(otherReasonText = intent.value) } + is DeleteAccountUiIntent.SelectReason -> selectReason(intent.reason) + is DeleteAccountUiIntent.ToggleNoticeChecked -> updateState { copy(isNoticeChecked = intent.checked) } + } + } + + private fun moveToReasonStep() { + if (currentState.isNextEnabled) { + updateState { copy(step = DeleteAccountStep.REASON) } + } + } + + private fun selectReason(reason: DeleteAccountReasonOption) { + updateState { + copy( + selectedReason = reason, + otherReasonText = if (reason == DeleteAccountReasonOption.Etc) otherReasonText else "", + ) + } + } + + private fun showConfirmDialog() { + if (currentState.isWithdrawEnabled) { + updateState { copy(isConfirmDialogVisible = true) } + } + } + + private fun dismissDialog() { + updateState { copy(isConfirmDialogVisible = false) } + } + + private fun withdraw() { + val reason = currentState.toWithdrawReason() ?: return + + updateState { + copy( + isConfirmDialogVisible = false, + isSubmitting = true, + ) + } + + viewModelScope.launch { + withdrawUseCase(reason) + .onSuccess { + sendEffect(DeleteAccountUiEffect.NavigateToSplash) + }.onFailure { exception -> + Timber.e(t = exception) + updateState { copy(isSubmitting = false) } + sendEffect(DeleteAccountUiEffect.ShowMessage(DeleteAccountUiMessage.WITHDRAW_FAILED)) + } + } + } + + private fun DeleteAccountUiState.toWithdrawReason(): WithdrawReason? = + when (selectedReason) { + DeleteAccountReasonOption.NotUsedOften -> WithdrawReason.NotUsedOften + DeleteAccountReasonOption.NoLongerNeeded -> WithdrawReason.NoLongerNeeded + DeleteAccountReasonOption.TooComplex -> WithdrawReason.TooComplex + DeleteAccountReasonOption.InaccurateAnalysis -> WithdrawReason.InaccurateAnalysis + DeleteAccountReasonOption.ManyErrors -> WithdrawReason.ManyErrors + DeleteAccountReasonOption.Etc -> WithdrawReason.Etc(text = otherReasonText.trim()) + + null -> null + } +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/component/DeleteAccountActionSection.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/component/DeleteAccountActionSection.kt new file mode 100644 index 00000000..c97d416d --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/component/DeleteAccountActionSection.kt @@ -0,0 +1,66 @@ +package com.team.prezel.feature.setting.impl.delete.component + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea +import com.team.prezel.core.designsystem.component.actions.button.PrezelTextButton +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.util.advancedImePadding +import com.team.prezel.feature.setting.impl.R +import com.team.prezel.feature.setting.impl.delete.model.DeleteAccountStep + +@Composable +internal fun DeleteAccountActionSection( + step: DeleteAccountStep, + enabled: Boolean, + onClickNext: () -> Unit, + onClickWithdraw: () -> Unit, + modifier: Modifier = Modifier, +) { + PrezelButtonArea( + isVertical = false, + isStrongStrength = true, + isNested = false, + showBackground = isShowBackground(), + mainButton = { modifier -> + PrezelTextButton( + modifier = modifier, + text = step.buttonText(), + enabled = enabled, + onClick = if (step == DeleteAccountStep.NOTICE) onClickNext else onClickWithdraw, + ) + }, + modifier = modifier.advancedImePadding(), + ) +} + +@Composable +private fun isShowBackground(): Boolean { + val density = LocalDensity.current + return WindowInsets.ime.getBottom(density) > 0 +} + +@Composable +private fun DeleteAccountStep.buttonText(): String = + when (this) { + DeleteAccountStep.NOTICE -> R.string.feature_setting_impl_delete_account_next + DeleteAccountStep.REASON -> R.string.feature_setting_impl_delete_account_withdraw + }.let { resId -> stringResource(resId) } + +@BasicPreview +@Composable +private fun DeleteAccountActionSectionNoticePreview() { + PrezelTheme { + DeleteAccountActionSection( + step = DeleteAccountStep.NOTICE, + enabled = true, + onClickNext = {}, + onClickWithdraw = {}, + ) + } +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/component/DeleteAccountConfirmDialog.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/component/DeleteAccountConfirmDialog.kt new file mode 100644 index 00000000..edb162da --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/component/DeleteAccountConfirmDialog.kt @@ -0,0 +1,44 @@ +package com.team.prezel.feature.setting.impl.delete.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.team.prezel.core.designsystem.component.feedback.dialog.PrezelDialog +import com.team.prezel.core.designsystem.component.feedback.dialog.PrezelDialogScope.ActionType +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.setting.impl.R + +@Composable +internal fun DeleteAccountConfirmDialog( + onDismissDialog: () -> Unit, + onConfirmWithdraw: () -> Unit, +) { + PrezelDialog( + title = stringResource(R.string.feature_setting_impl_delete_account_dialog_title), + onDismiss = onDismissDialog, + ) { + Action(label = stringResource(R.string.feature_setting_impl_delete_account_dialog_cancel)) { onDismissDialog() } + Action( + label = stringResource(R.string.feature_setting_impl_delete_account_dialog_confirm), + type = ActionType.BAD, + ) { + onConfirmWithdraw() + } + } +} + +@BasicPreview +@Composable +private fun DeleteAccountConfirmDialogPreview() { + PrezelTheme { + Box(modifier = Modifier.fillMaxSize()) { + DeleteAccountConfirmDialog( + onDismissDialog = {}, + onConfirmWithdraw = {}, + ) + } + } +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/component/DeleteAccountNoticeStep.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/component/DeleteAccountNoticeStep.kt new file mode 100644 index 00000000..56d0a152 --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/component/DeleteAccountNoticeStep.kt @@ -0,0 +1,330 @@ +package com.team.prezel.feature.setting.impl.delete.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Bullet +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.em +import com.team.prezel.core.designsystem.component.PrezelCheckbox +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.util.noRippleClickable +import com.team.prezel.feature.setting.impl.R + +private const val HIGHLIGHT_START_TAG = "" +private const val HIGHLIGHT_END_TAG = "" +private const val BOLD_START_TAG = "" +private const val BOLD_END_TAG = "" + +private val supportedTags = listOf( + HIGHLIGHT_START_TAG, + HIGHLIGHT_END_TAG, + BOLD_START_TAG, + BOLD_END_TAG, +) + +@Composable +internal fun DeleteAccountNoticeStep( + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = PrezelTheme.spacing.V20) + .padding(top = PrezelTheme.spacing.V16, bottom = PrezelTheme.spacing.V24), + ) { + DeleteAccountNoticeHeader() + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + + DeleteAccountNoticeDetailBox() + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V36)) + + DeletionAgreement(isChecked = isChecked, onCheckedChange = onCheckedChange) + } +} + +@Composable +private fun DeleteAccountNoticeHeader(modifier: Modifier = Modifier) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.feature_setting_impl_delete_account_notice_title), + style = PrezelTheme.typography.title2Bold, + color = PrezelTheme.colors.textLarge, + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V8)) + + DeleteAccountNoticeDescription() + } +} + +@Composable +private fun DeletionAgreement( + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .noRippleClickable { onCheckedChange(!isChecked) }, + verticalAlignment = Alignment.CenterVertically, + ) { + PrezelCheckbox( + checked = isChecked, + onCheckedChange = onCheckedChange, + extraTouchPadding = PaddingValues(), + ) + + Spacer(modifier = Modifier.width(PrezelTheme.spacing.V8)) + + Text( + text = stringResource(R.string.feature_setting_impl_delete_account_notice_check), + style = PrezelTheme.typography.body3Regular, + color = PrezelTheme.colors.textRegular, + ) + } +} + +@Composable +private fun DeleteAccountNoticeDescription(modifier: Modifier = Modifier) { + val highlightStyle = SpanStyle(color = PrezelTheme.colors.feedbackBadRegular) + val boldStyle = SpanStyle( + fontSize = PrezelTheme.typography.body2Bold.fontSize, + fontWeight = PrezelTheme.typography.body2Bold.fontWeight, + letterSpacing = PrezelTheme.typography.body2Bold.letterSpacing, + ) + + Text( + text = buildAnnotatedString { + appendTaggedText( + text = stringResource(R.string.feature_setting_impl_delete_account_notice_description), + highlightStyle = highlightStyle, + boldStyle = boldStyle, + ) + }, + modifier = modifier.fillMaxWidth(), + style = PrezelTheme.typography.body2Regular, + color = PrezelTheme.colors.textMedium, + ) +} + +@Composable +private fun DeleteAccountNoticeDetailBox(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .background( + color = PrezelTheme.colors.bgMedium, + shape = PrezelTheme.shapes.V8, + ).padding(horizontal = PrezelTheme.spacing.V8, vertical = PrezelTheme.spacing.V12), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + ) { + DeleteAccountNoticeDetailItem( + text = stringResource(R.string.feature_setting_impl_delete_account_notice_detail_1), + ) + DeleteAccountNoticeDetailItem( + text = stringResource(R.string.feature_setting_impl_delete_account_notice_detail_2), + ) + DeleteAccountNoticeDetailItem( + text = stringResource(R.string.feature_setting_impl_delete_account_notice_detail_3), + ) + DeleteAccountNoticeDetailItem( + text = stringResource(R.string.feature_setting_impl_delete_account_notice_detail_4), + ) + } +} + +@Composable +private fun DeleteAccountNoticeDetailItem( + text: String, + modifier: Modifier = Modifier, +) { + val highlightStyle = SpanStyle(color = PrezelTheme.colors.feedbackBadRegular) + val boldStyle = SpanStyle( + fontSize = PrezelTheme.typography.body3Regular.fontSize, + fontWeight = PrezelTheme.typography.body3Regular.fontWeight, + letterSpacing = PrezelTheme.typography.body3Regular.letterSpacing, + ) + + Text( + text = buildAnnotatedString { + withBulletList(bullet = Bullet.Default.copy(padding = 0.5.em)) { + withBulletListItem { + appendTaggedText( + text = text, + highlightStyle = highlightStyle, + boldStyle = boldStyle, + ) + } + } + }, + modifier = modifier.fillMaxWidth(), + style = PrezelTheme.typography.body3Regular, + color = PrezelTheme.colors.textMedium, + ) +} + +private fun AnnotatedString.Builder.appendTaggedText( + text: String, + highlightStyle: SpanStyle, + boldStyle: SpanStyle, +) { + if (!isValidTaggedText(text)) { + append(text) + return + } + + var currentIndex = 0 + var tagState = TagState() + + while (currentIndex < text.length) { + val nextTag = findNextTag(text = text, startIndex = currentIndex) + + if (nextTag == null) { + appendWithStyle( + text = text.substring(currentIndex), + tagState = tagState, + highlightStyle = highlightStyle, + boldStyle = boldStyle, + ) + return + } + + val (tag, tagIndex) = nextTag + appendWithCurrentStyle( + text = text, + currentIndex = currentIndex, + tagIndex = tagIndex, + tagState = tagState, + highlightStyle = highlightStyle, + boldStyle = boldStyle, + ) + + tagState = tagState.next(tag) ?: return + currentIndex = tagIndex + tag.length + } +} + +private fun findNextTag( + text: String, + startIndex: Int, +): Pair? = + supportedTags + .map { tag -> tag to text.indexOf(tag, startIndex = startIndex) } + .filter { (_, index) -> index >= 0 } + .minByOrNull { (_, index) -> index } + +private fun AnnotatedString.Builder.appendWithCurrentStyle( + text: String, + currentIndex: Int, + tagIndex: Int, + tagState: TagState, + highlightStyle: SpanStyle, + boldStyle: SpanStyle, +) { + appendWithStyle( + text = text.substring(currentIndex, tagIndex), + tagState = tagState, + highlightStyle = highlightStyle, + boldStyle = boldStyle, + ) +} + +private fun isValidTaggedText(text: String): Boolean { + var currentIndex = 0 + var tagState = TagState() + + while (currentIndex < text.length) { + val nextTag = findNextTag(text = text, startIndex = currentIndex) ?: break + val (tag, tagIndex) = nextTag + + tagState = tagState.next(tag) ?: return false + currentIndex = tagIndex + tag.length + } + + return !tagState.isHighlighting && !tagState.isBold +} + +private fun AnnotatedString.Builder.appendWithStyle( + text: String, + tagState: TagState, + highlightStyle: SpanStyle, + boldStyle: SpanStyle, +) { + if (text.isEmpty()) return + + val style = tagState.toSpanStyle( + highlightStyle = highlightStyle, + boldStyle = boldStyle, + ) + + if (style == null) { + append(text) + return + } + + withStyle(style) { + append(text) + } +} + +private data class TagState( + val isHighlighting: Boolean = false, + val isBold: Boolean = false, +) { + fun next(tag: String): TagState? = + when (tag) { + HIGHLIGHT_START_TAG -> if (isHighlighting) null else copy(isHighlighting = true) + HIGHLIGHT_END_TAG -> if (!isHighlighting || isBold) null else copy(isHighlighting = false) + BOLD_START_TAG -> if (isBold) null else copy(isBold = true) + BOLD_END_TAG -> if (!isBold) null else copy(isBold = false) + else -> null + } + + fun toSpanStyle( + highlightStyle: SpanStyle, + boldStyle: SpanStyle, + ): SpanStyle? = + when { + isHighlighting && isBold -> boldStyle.merge(highlightStyle) + isHighlighting -> highlightStyle + isBold -> boldStyle + else -> null + } +} + +@BasicPreview +@Composable +private fun DeleteAccountNoticeStepPreview() { + PrezelTheme { + DeleteAccountNoticeStep( + isChecked = false, + onCheckedChange = {}, + ) + } +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/component/DeleteAccountReasonStep.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/component/DeleteAccountReasonStep.kt new file mode 100644 index 00000000..d95dffde --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/component/DeleteAccountReasonStep.kt @@ -0,0 +1,160 @@ +package com.team.prezel.feature.setting.impl.delete.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import com.team.prezel.core.designsystem.component.PrezelRadio +import com.team.prezel.core.designsystem.component.list.PrezelList +import com.team.prezel.core.designsystem.component.textfield.PrezelTextArea +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.util.noRippleClickable +import com.team.prezel.feature.setting.impl.R +import com.team.prezel.feature.setting.impl.delete.model.DeleteAccountReasonOption +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun DeleteAccountReasonStep( + selectedReason: DeleteAccountReasonOption?, + otherReasonText: String, + onSelectReason: (DeleteAccountReasonOption) -> Unit, + onOtherReasonChanged: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = PrezelTheme.spacing.V12) + .padding(top = PrezelTheme.spacing.V16, bottom = PrezelTheme.spacing.V24), + ) { + DeleteAccountReasonHeader(modifier = Modifier.padding(horizontal = PrezelTheme.spacing.V8)) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) + + DeleteAccountReasons( + selectedReason = selectedReason, + otherReasonText = otherReasonText, + onSelectReason = onSelectReason, + onOtherReasonChanged = onOtherReasonChanged, + ) + } +} + +@Composable +private fun DeleteAccountReasonHeader(modifier: Modifier = Modifier) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.feature_setting_impl_delete_account_reason_title), + style = PrezelTheme.typography.title2Bold, + color = PrezelTheme.colors.textLarge, + ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V8)) + Text( + text = stringResource(R.string.feature_setting_impl_delete_account_reason_description), + style = PrezelTheme.typography.body3Regular, + color = PrezelTheme.colors.textRegular, + ) + } +} + +@Composable +private fun DeleteAccountReasons( + selectedReason: DeleteAccountReasonOption?, + otherReasonText: String, + onSelectReason: (DeleteAccountReasonOption) -> Unit, + onOtherReasonChanged: (String) -> Unit, + reasonOptions: ImmutableList> = persistentListOf( + DeleteAccountReasonOption.Etc to stringResource(R.string.feature_setting_impl_delete_account_reason_other), + DeleteAccountReasonOption.NotUsedOften to stringResource(R.string.feature_setting_impl_delete_account_reason_not_used_often), + DeleteAccountReasonOption.NoLongerNeeded to stringResource(R.string.feature_setting_impl_delete_account_reason_no_longer_needed), + DeleteAccountReasonOption.TooComplex to stringResource(R.string.feature_setting_impl_delete_account_reason_too_difficult_or_complex), + DeleteAccountReasonOption.InaccurateAnalysis to stringResource( + R.string.feature_setting_impl_delete_account_reason_analysis_result_inaccurate, + ), + DeleteAccountReasonOption.ManyErrors to stringResource(R.string.feature_setting_impl_delete_account_reason_too_many_errors), + ), +) { + Column(verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V12)) { + CompositionLocalProvider(LocalContentColor provides PrezelTheme.colors.textLarge) { + reasonOptions.forEach { (reason, label) -> + DeleteAccountReasonOptionItem( + reason = reason, + label = label, + selectedReason = selectedReason, + otherReasonText = otherReasonText, + onSelectReason = onSelectReason, + onOtherReasonChanged = onOtherReasonChanged, + ) + } + } + } +} + +@Composable +private fun DeleteAccountReasonOptionItem( + reason: DeleteAccountReasonOption, + label: String, + selectedReason: DeleteAccountReasonOption?, + otherReasonText: String, + onSelectReason: (DeleteAccountReasonOption) -> Unit, + onOtherReasonChanged: (String) -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + PrezelList( + title = label, + titleTextColor = PrezelTheme.colors.textLarge, + nested = true, + leadingContent = { + PrezelRadio( + checked = selectedReason == reason, + onCheckedChange = { checked -> if (checked) onSelectReason(reason) }, + ) + }, + modifier = Modifier.noRippleClickable { onSelectReason(reason) }, + ) + + if (reason == DeleteAccountReasonOption.Etc && selectedReason == DeleteAccountReasonOption.Etc) { + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V8)) + PrezelTextArea( + value = otherReasonText, + onValueChange = onOtherReasonChanged, + placeholder = stringResource(R.string.feature_setting_impl_delete_account_reason_other_placeholder), + maxLength = 200, + showCount = false, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PrezelTheme.spacing.V8), + ) + } + } +} + +@BasicPreview +@Composable +private fun DeleteAccountReasonStepPreview() { + PrezelTheme { + DeleteAccountReasonStep( + selectedReason = DeleteAccountReasonOption.Etc, + otherReasonText = "", + onSelectReason = {}, + onOtherReasonChanged = {}, + ) + } +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/component/DeleteAccountTopAppBar.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/component/DeleteAccountTopAppBar.kt new file mode 100644 index 00000000..e110f33d --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/component/DeleteAccountTopAppBar.kt @@ -0,0 +1,41 @@ +package com.team.prezel.feature.setting.impl.delete.component + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.team.prezel.core.designsystem.component.PrezelTopAppBar +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.setting.impl.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DeleteAccountTopAppBar( + onClickClose: () -> Unit, + modifier: Modifier = Modifier, +) { + PrezelTopAppBar( + modifier = modifier, + trailingIcons = { + IconButton(onClick = onClickClose) { + Icon( + painter = painterResource(PrezelIcons.Cancel), + contentDescription = stringResource(R.string.feature_setting_impl_close_icon_content_description), + ) + } + }, + ) +} + +@BasicPreview +@Composable +private fun DeleteAccountTopAppBarPreview() { + PrezelTheme { + DeleteAccountTopAppBar(onClickClose = {}) + } +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/contract/DeleteAccountUiEffect.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/contract/DeleteAccountUiEffect.kt new file mode 100644 index 00000000..48c1646e --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/contract/DeleteAccountUiEffect.kt @@ -0,0 +1,12 @@ +package com.team.prezel.feature.setting.impl.delete.contract + +import com.team.prezel.core.ui.base.UiEffect +import com.team.prezel.feature.setting.impl.delete.model.DeleteAccountUiMessage + +internal sealed interface DeleteAccountUiEffect : UiEffect { + data object NavigateToSplash : DeleteAccountUiEffect + + data class ShowMessage( + val message: DeleteAccountUiMessage, + ) : DeleteAccountUiEffect +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/contract/DeleteAccountUiIntent.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/contract/DeleteAccountUiIntent.kt new file mode 100644 index 00000000..57f7ff02 --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/contract/DeleteAccountUiIntent.kt @@ -0,0 +1,26 @@ +package com.team.prezel.feature.setting.impl.delete.contract + +import com.team.prezel.core.ui.base.UiIntent +import com.team.prezel.feature.setting.impl.delete.model.DeleteAccountReasonOption + +internal sealed interface DeleteAccountUiIntent : UiIntent { + data class ToggleNoticeChecked( + val checked: Boolean, + ) : DeleteAccountUiIntent + + data object ClickNext : DeleteAccountUiIntent + + data class SelectReason( + val reason: DeleteAccountReasonOption, + ) : DeleteAccountUiIntent + + data class ChangeOtherReason( + val value: String, + ) : DeleteAccountUiIntent + + data object ClickWithdraw : DeleteAccountUiIntent + + data object DismissDialog : DeleteAccountUiIntent + + data object ConfirmWithdraw : DeleteAccountUiIntent +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/contract/DeleteAccountUiState.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/contract/DeleteAccountUiState.kt new file mode 100644 index 00000000..e9bb29cd --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/contract/DeleteAccountUiState.kt @@ -0,0 +1,22 @@ +package com.team.prezel.feature.setting.impl.delete.contract + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.ui.base.UiState +import com.team.prezel.feature.setting.impl.delete.model.DeleteAccountReasonOption +import com.team.prezel.feature.setting.impl.delete.model.DeleteAccountStep + +@Immutable +internal data class DeleteAccountUiState( + val step: DeleteAccountStep = DeleteAccountStep.NOTICE, + val isNoticeChecked: Boolean = false, + val selectedReason: DeleteAccountReasonOption? = null, + val otherReasonText: String = "", + val isConfirmDialogVisible: Boolean = false, + val isSubmitting: Boolean = false, +) : UiState { + val isNextEnabled: Boolean = isNoticeChecked + + val isWithdrawEnabled: Boolean = selectedReason != null && !isSubmitting + + val isPrimaryActionEnabled: Boolean = if (step == DeleteAccountStep.NOTICE) isNextEnabled else isWithdrawEnabled +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/model/DeleteAccountReasonOption.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/model/DeleteAccountReasonOption.kt new file mode 100644 index 00000000..3d1d8cab --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/model/DeleteAccountReasonOption.kt @@ -0,0 +1,10 @@ +package com.team.prezel.feature.setting.impl.delete.model + +internal enum class DeleteAccountReasonOption { + NotUsedOften, + NoLongerNeeded, + TooComplex, + InaccurateAnalysis, + ManyErrors, + Etc, +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/model/DeleteAccountStep.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/model/DeleteAccountStep.kt new file mode 100644 index 00000000..8cc80dcc --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/model/DeleteAccountStep.kt @@ -0,0 +1,6 @@ +package com.team.prezel.feature.setting.impl.delete.model + +internal enum class DeleteAccountStep { + NOTICE, + REASON, +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/model/DeleteAccountUiMessage.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/model/DeleteAccountUiMessage.kt new file mode 100644 index 00000000..5ad49a58 --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/model/DeleteAccountUiMessage.kt @@ -0,0 +1,5 @@ +package com.team.prezel.feature.setting.impl.delete.model + +internal enum class DeleteAccountUiMessage { + WITHDRAW_FAILED, +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/navigation/SettingEntryBuilder.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/navigation/SettingEntryBuilder.kt new file mode 100644 index 00000000..1a4c55e4 --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/navigation/SettingEntryBuilder.kt @@ -0,0 +1,55 @@ +package com.team.prezel.feature.setting.impl.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.team.prezel.core.navigation.LocalNavigator +import com.team.prezel.feature.setting.api.DeleteAccountNavKey +import com.team.prezel.feature.setting.api.SettingNavKey +import com.team.prezel.feature.setting.impl.delete.DeleteAccountScreen +import com.team.prezel.feature.setting.impl.setting.SettingScreen +import com.team.prezel.feature.splash.api.SplashNavKey +import com.team.prezel.feature.terms.api.TermsDocumentType +import com.team.prezel.feature.terms.api.TermsNavKey +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +internal fun EntryProviderScope.featureSettingEntryBuilder() { + entry { + val navigator = LocalNavigator.current + + SettingScreen( + navigateBack = { navigator.goBack() }, + navigateToDeleteAccount = { navigator.navigate(DeleteAccountNavKey) }, + navigateToSplash = { navigator.replaceRoot(SplashNavKey) }, + navigateToTermsOfService = { + navigator.navigate(TermsNavKey.Detail(TermsDocumentType.TERMS_OF_SERVICE)) + }, + navigateToPrivacyPolicy = { + navigator.navigate(TermsNavKey.Detail(TermsDocumentType.PRIVACY_POLICY)) + }, + ) + } + + entry { + val navigator = LocalNavigator.current + + DeleteAccountScreen( + navigateBack = { navigator.goBack() }, + navigateToSplash = { navigator.replaceRoot(SplashNavKey) }, + ) + } +} + +@Module +@InstallIn(ActivityRetainedComponent::class) +object FeatureSettingModule { + @IntoSet + @Provides + fun provideFeatureSettingEntryBuilder(): EntryProviderScope.() -> Unit = + { + featureSettingEntryBuilder() + } +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/SettingScreen.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/SettingScreen.kt new file mode 100644 index 00000000..49989a74 --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/SettingScreen.kt @@ -0,0 +1,138 @@ +package com.team.prezel.feature.setting.impl.setting + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +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.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.state.LocalSnackbarHostState +import com.team.prezel.feature.setting.impl.R +import com.team.prezel.feature.setting.impl.setting.component.LogoutDialog +import com.team.prezel.feature.setting.impl.setting.component.SettingActionSection +import com.team.prezel.feature.setting.impl.setting.component.SettingBodySection +import com.team.prezel.feature.setting.impl.setting.component.SettingTopAppBar +import com.team.prezel.feature.setting.impl.setting.contract.SettingUiEffect +import com.team.prezel.feature.setting.impl.setting.contract.SettingUiIntent +import com.team.prezel.feature.setting.impl.setting.contract.SettingUiState +import com.team.prezel.feature.setting.impl.setting.model.SettingUiMessage + +@Composable +internal fun SettingScreen( + navigateBack: () -> Unit, + navigateToDeleteAccount: () -> Unit, + navigateToSplash: () -> Unit, + navigateToTermsOfService: () -> Unit, + navigateToPrivacyPolicy: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SettingViewModel = hiltViewModel(), +) { + val snackbarHostState = LocalSnackbarHostState.current + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val resource = LocalResources.current + var shouldShowLogoutDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + viewModel.onIntent(SettingUiIntent.FetchData) + + viewModel.uiEffect.collect { effect -> + when (effect) { + SettingUiEffect.NavigateToSplash -> { + shouldShowLogoutDialog = false + navigateToSplash() + snackbarHostState.showPrezelSnackbar(message = resource.getString(R.string.feature_setting_impl_logout_success)) + } + + is SettingUiEffect.ShowMessage -> { + snackbarHostState.showPrezelSnackbar(message = resource.getString(effect.message.toMessageRes())) + } + } + } + } + + if (shouldShowLogoutDialog) { + LogoutDialog( + onDismiss = { shouldShowLogoutDialog = false }, + onConfirmLogout = { viewModel.onIntent(SettingUiIntent.ClickLogout) }, + ) + } + + SettingScreen( + uiState = uiState, + onClickBack = navigateBack, + onClickTermsOfService = navigateToTermsOfService, + onClickPrivacyPolicy = navigateToPrivacyPolicy, + onClickLogout = { shouldShowLogoutDialog = true }, + onClickWithdraw = navigateToDeleteAccount, + modifier = modifier, + ) +} + +private fun SettingUiMessage.toMessageRes(): Int = + when (this) { + SettingUiMessage.FETCH_USER_INFO_FAILED -> R.string.feature_setting_impl_fetch_user_info_failed + SettingUiMessage.LOGOUT_FAILED -> R.string.feature_setting_impl_logout_failed + } + +@Composable +private fun SettingScreen( + uiState: SettingUiState, + onClickBack: () -> Unit, + onClickTermsOfService: () -> Unit, + onClickPrivacyPolicy: () -> Unit, + onClickLogout: () -> Unit, + onClickWithdraw: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxSize()) { + SettingHeaderSection(onClickBack = onClickBack) + SettingBodySection( + uiState = uiState, + onClickTermsOfService = onClickTermsOfService, + onClickPrivacyPolicy = onClickPrivacyPolicy, + modifier = Modifier.weight(1f), + ) + SettingActionSection( + onClickWithdraw = onClickWithdraw, + onClickLogout = onClickLogout, + ) + } +} + +@Composable +private fun SettingHeaderSection( + onClickBack: () -> Unit, + modifier: Modifier = Modifier, +) { + SettingTopAppBar( + onBack = onClickBack, + modifier = modifier, + ) +} + +@BasicPreview +@Composable +private fun SettingScreenPreview() { + PrezelTheme { + SettingScreen( + uiState = SettingUiState( + nickname = "프레젤", + email = "prezel@email.com", + ), + onClickBack = {}, + onClickTermsOfService = {}, + onClickPrivacyPolicy = {}, + onClickLogout = {}, + onClickWithdraw = {}, + ) + } +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/SettingViewModel.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/SettingViewModel.kt new file mode 100644 index 00000000..194708d4 --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/SettingViewModel.kt @@ -0,0 +1,60 @@ +package com.team.prezel.feature.setting.impl.setting + +import androidx.lifecycle.viewModelScope +import com.team.prezel.core.domain.usecase.auth.LogoutUseCase +import com.team.prezel.core.domain.usecase.user.FetchUserInfoUseCase +import com.team.prezel.core.ui.base.BaseViewModel +import com.team.prezel.feature.setting.impl.setting.contract.SettingUiEffect +import com.team.prezel.feature.setting.impl.setting.contract.SettingUiIntent +import com.team.prezel.feature.setting.impl.setting.contract.SettingUiState +import com.team.prezel.feature.setting.impl.setting.model.SettingUiMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +internal class SettingViewModel @Inject constructor( + private val fetchUserInfoUseCase: FetchUserInfoUseCase, + private val logoutUseCase: LogoutUseCase, +) : BaseViewModel(SettingUiState()) { + override fun onIntent(intent: SettingUiIntent) { + when (intent) { + SettingUiIntent.FetchData -> fetchUserInfo() + SettingUiIntent.ClickLogout -> logout() + } + } + + private fun fetchUserInfo() { + updateState { copy(isLoading = true) } + + viewModelScope + .launch { + fetchUserInfoUseCase() + .onSuccess { user -> + updateState { + copy( + profileImageUrl = user.profileImageUrl, + nickname = user.nickname, + email = user.email, + ) + } + }.onFailure { exception -> + Timber.e(t = exception) + sendEffect(SettingUiEffect.ShowMessage(SettingUiMessage.FETCH_USER_INFO_FAILED)) + } + }.invokeOnCompletion { updateState { copy(isLoading = false) } } + } + + private fun logout() { + viewModelScope.launch { + logoutUseCase() + .onSuccess { + sendEffect(SettingUiEffect.NavigateToSplash) + }.onFailure { exception -> + Timber.e(t = exception) + sendEffect(SettingUiEffect.ShowMessage(SettingUiMessage.LOGOUT_FAILED)) + } + } + } +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/component/LogoutDialog.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/component/LogoutDialog.kt new file mode 100644 index 00000000..b675b246 --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/component/LogoutDialog.kt @@ -0,0 +1,46 @@ +package com.team.prezel.feature.setting.impl.setting.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.team.prezel.core.designsystem.component.feedback.dialog.PrezelDialog +import com.team.prezel.core.designsystem.component.feedback.dialog.PrezelDialogScope.ActionType +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.setting.impl.R + +@Composable +internal fun LogoutDialog( + onDismiss: () -> Unit, + onConfirmLogout: () -> Unit, +) { + PrezelDialog( + title = stringResource(R.string.feature_setting_impl_logout_dialog_title), + onDismiss = onDismiss, + ) { + Action(label = stringResource(R.string.feature_setting_impl_cancel)) { + onDismiss() + } + Action( + label = stringResource(R.string.feature_setting_impl_logout), + type = ActionType.BAD, + ) { + onConfirmLogout() + } + } +} + +@BasicPreview +@Composable +private fun LogoutDialogPreview() { + PrezelTheme { + Box(modifier = Modifier.fillMaxSize()) { + LogoutDialog( + onDismiss = {}, + onConfirmLogout = {}, + ) + } + } +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/component/SettingActionSection.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/component/SettingActionSection.kt new file mode 100644 index 00000000..647281c0 --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/component/SettingActionSection.kt @@ -0,0 +1,55 @@ +package com.team.prezel.feature.setting.impl.setting.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea +import com.team.prezel.core.designsystem.component.actions.button.PrezelTextButton +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonSize +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.setting.impl.R + +@Composable +internal fun SettingActionSection( + onClickWithdraw: () -> Unit, + onClickLogout: () -> Unit, + modifier: Modifier = Modifier, +) { + PrezelButtonArea( + modifier = modifier, + mainButton = { modifier -> + PrezelTextButton( + modifier = modifier, + text = stringResource(R.string.feature_setting_impl_withdraw), + type = ButtonType.GHOST, + size = ButtonSize.XSMALL, + hierarchy = ButtonHierarchy.SECONDARY, + onClick = onClickWithdraw, + ) + }, + subButton = { modifier -> + PrezelTextButton( + modifier = modifier, + text = stringResource(R.string.feature_setting_impl_logout), + type = ButtonType.FILLED, + size = ButtonSize.SMALL, + hierarchy = ButtonHierarchy.SECONDARY, + onClick = onClickLogout, + ) + }, + ) +} + +@BasicPreview +@Composable +private fun SettingActionSectionPreview() { + PrezelTheme { + SettingActionSection( + onClickWithdraw = {}, + onClickLogout = {}, + ) + } +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/component/SettingBodySection.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/component/SettingBodySection.kt new file mode 100644 index 00000000..64fad349 --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/component/SettingBodySection.kt @@ -0,0 +1,168 @@ +package com.team.prezel.feature.setting.impl.setting.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.PrezelAvatar +import com.team.prezel.core.designsystem.component.PrezelAvatarSize +import com.team.prezel.core.designsystem.component.PrezelHorizontalDivider +import com.team.prezel.core.designsystem.component.list.PrezelList +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.util.noRippleClickable +import com.team.prezel.feature.setting.impl.R +import com.team.prezel.feature.setting.impl.setting.contract.SettingUiState + +@Composable +internal fun SettingBodySection( + uiState: SettingUiState, + onClickTermsOfService: () -> Unit, + onClickPrivacyPolicy: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + AccountSection( + profileImageUrl = uiState.profileImageUrl, + nickname = uiState.nickname, + email = uiState.email, + ) + + PrezelHorizontalDivider(color = PrezelTheme.colors.bgMedium) + + PolicySection( + onClickTermsOfService = onClickTermsOfService, + onClickPrivacyPolicy = onClickPrivacyPolicy, + ) + } +} + +@Composable +internal fun AccountSection( + profileImageUrl: String?, + nickname: String, + email: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding( + horizontal = PrezelTheme.spacing.V20, + vertical = PrezelTheme.spacing.V16, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + PrezelAvatar( + imageUrl = profileImageUrl, + contentDescription = stringResource(R.string.feature_setting_impl_profile_image_content_description), + size = PrezelAvatarSize.SMALL, + ) + + Spacer(modifier = Modifier.width(PrezelTheme.spacing.V12)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = nickname, + color = PrezelTheme.colors.textLarge, + style = PrezelTheme.typography.body2Medium, + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = email, + color = PrezelTheme.colors.textRegular, + style = PrezelTheme.typography.body3Medium, + ) + Spacer(modifier = Modifier.width(PrezelTheme.spacing.V4)) + Icon( + painter = painterResource(PrezelIcons.Kakao), + contentDescription = stringResource(R.string.feature_setting_impl_kakao_account_content_description), + tint = PrezelTheme.colors.solidBlack, + modifier = Modifier + .size(16.dp) + .clip(PrezelTheme.shapes.V1000) + .background(Color(0xFFFEE500)) + .padding(3.dp), + ) + } + } + } +} + +@Composable +internal fun PolicySection( + onClickTermsOfService: () -> Unit, + onClickPrivacyPolicy: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding( + horizontal = PrezelTheme.spacing.V20, + vertical = PrezelTheme.spacing.V28, + ), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V20), + ) { + CompositionLocalProvider( + LocalContentColor provides PrezelTheme.colors.textLarge, + ) { + val trailingIcon = @Composable { + Icon( + painter = painterResource(PrezelIcons.ChevronRight), + contentDescription = null, + tint = PrezelTheme.colors.iconRegular, + ) + } + + PrezelList( + title = stringResource(R.string.feature_setting_impl_terms_of_service), + nested = true, + trailingContent = trailingIcon, + modifier = Modifier.noRippleClickable( + onClick = onClickTermsOfService, + ), + ) + PrezelList( + title = stringResource(R.string.feature_setting_impl_privacy_policy), + nested = true, + trailingContent = trailingIcon, + modifier = Modifier.noRippleClickable( + onClick = onClickPrivacyPolicy, + ), + ) + } + } +} + +@BasicPreview +@Composable +private fun SettingBodySectionPreview() { + PrezelTheme { + SettingBodySection( + uiState = SettingUiState( + nickname = "발표잘하고싶어요", + email = "email@email.com", + ), + onClickTermsOfService = {}, + onClickPrivacyPolicy = {}, + ) + } +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/component/SettingTopAppBar.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/component/SettingTopAppBar.kt new file mode 100644 index 00000000..47ce2003 --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/component/SettingTopAppBar.kt @@ -0,0 +1,45 @@ +package com.team.prezel.feature.setting.impl.setting.component + +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.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.team.prezel.core.designsystem.component.PrezelTopAppBar +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.setting.impl.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SettingTopAppBar( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + PrezelTopAppBar( + modifier = modifier, + title = { + Text(text = stringResource(R.string.feature_setting_impl_title)) + }, + leadingIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(PrezelIcons.ArrowLeft), + contentDescription = stringResource(R.string.feature_setting_impl_back_icon_content_description), + ) + } + }, + ) +} + +@BasicPreview +@Composable +private fun SettingTopAppBarPreview() { + PrezelTheme { + SettingTopAppBar(onBack = {}) + } +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiEffect.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiEffect.kt new file mode 100644 index 00000000..de89c4b2 --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiEffect.kt @@ -0,0 +1,12 @@ +package com.team.prezel.feature.setting.impl.setting.contract + +import com.team.prezel.core.ui.base.UiEffect +import com.team.prezel.feature.setting.impl.setting.model.SettingUiMessage + +internal sealed interface SettingUiEffect : UiEffect { + data object NavigateToSplash : SettingUiEffect + + data class ShowMessage( + val message: SettingUiMessage, + ) : SettingUiEffect +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiIntent.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiIntent.kt new file mode 100644 index 00000000..5722f31e --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiIntent.kt @@ -0,0 +1,9 @@ +package com.team.prezel.feature.setting.impl.setting.contract + +import com.team.prezel.core.ui.base.UiIntent + +internal sealed interface SettingUiIntent : UiIntent { + data object FetchData : SettingUiIntent + + data object ClickLogout : SettingUiIntent +} diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiState.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiState.kt new file mode 100644 index 00000000..db19cca0 --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiState.kt @@ -0,0 +1,12 @@ +package com.team.prezel.feature.setting.impl.setting.contract + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.ui.base.UiState + +@Immutable +internal data class SettingUiState( + val isLoading: Boolean = false, + val profileImageUrl: String? = null, + val nickname: String = "", + val email: String = "", +) : UiState diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/model/SettingUiMessage.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/model/SettingUiMessage.kt new file mode 100644 index 00000000..243f86eb --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/model/SettingUiMessage.kt @@ -0,0 +1,6 @@ +package com.team.prezel.feature.setting.impl.setting.model + +enum class SettingUiMessage { + FETCH_USER_INFO_FAILED, + LOGOUT_FAILED, +} diff --git a/Prezel/feature/setting/impl/src/main/res/values/strings.xml b/Prezel/feature/setting/impl/src/main/res/values/strings.xml new file mode 100644 index 00000000..113445dd --- /dev/null +++ b/Prezel/feature/setting/impl/src/main/res/values/strings.xml @@ -0,0 +1,46 @@ + + + 설정 + 뒤로가기 + 닫기 + 프로필 이미지 + 카카오 계정 + 유저 정보 조회에 실패했어요. + 로그아웃되었어요. + 로그아웃에 실패했어요. 다시 시도해 주세요. + 로그아웃 하시겠어요? + 취소 + 로그아웃 + 이용약관 + 개인정보 정책 + 탈퇴하기 + + + 회원을 탈퇴하시면 + 추가한 발표, 발표 연습 기록, 획득한 뱃지 등을\n포함한 이용 내역 및 개인정보가 <highlight><bold>영구 삭제</bold></highlight>되며,\n<bold>다시 복구할 수 없어요.</bold> + 회원 탈퇴 시 즉시 탈퇴 처리되며, <highlight>이후 서비스 이용이 불가합니다.</highlight> 탈퇴 후에는 동일 계정으로 재가입하더라도 기존 정보는 복구되지 않습니다. + 회원 탈퇴 시 아래 정보의 복구가 불가능합니다. <highlight>연습 녹음 기록, 음성 분석 결과 및 리포트, 발표 대본 및 연습 히스토리 등의 데이터가 삭제</highlight>되며, 탈퇴 후에는 삭제된 데이터에 대한 복구 요청이 불가합니다. + 탈퇴 시 진행 중인 발표 연습, 분석 요청은 모두 종료됩니다. 미완료된 분석 결과는 제공되지 않습니다. + 탈퇴 후 동일한 카카오 계정으로 재가입은 가능하나, <highlight>이전 이용 기록 및 연습 데이터는 새로 생성되지 않습니다.</highlight> + 탈퇴 시 주의사항을 모두 확인했습니다. + 다음 + + + 탈퇴하시는 이유가 궁금해요 + 더 나은 서비스를 만들기 위한 참고 자료로만 사용돼요. + 자주 이용하지 않아요 + 더 이상 필요하지 않아요 + 사용법이 어렵거나 복잡해요 + 분석 결과가 정확하지 않다고 느꼈어요 + 오류가 많아요 + 기타 + (선택) 탈퇴 사유를 작성해주세요 + 탈퇴하기 + + + 정말 탈퇴하시겠어요? + 취소 + 탈퇴 + 회원탈퇴가 완료되었어요. + 회원탈퇴에 실패했어요. 다시 시도해 주세요. + diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/navigation/SplashEntryBuilder.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/navigation/SplashEntryBuilder.kt index 91f04879..7d552aa2 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/navigation/SplashEntryBuilder.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/navigation/SplashEntryBuilder.kt @@ -32,7 +32,7 @@ internal fun EntryProviderScope.featureSplashEntryBuilder() { }, navigateToTerms = { navigator.replaceRoot(LoginNavKey) - navigator.navigate(TermsNavKey) + navigator.navigate(TermsNavKey.List) }, navigateToCreateProfile = { navigator.replaceRoot(LoginNavKey) diff --git a/Prezel/feature/terms/api/src/main/java/com/team/prezel/feature/terms/api/TermsDocumentType.kt b/Prezel/feature/terms/api/src/main/java/com/team/prezel/feature/terms/api/TermsDocumentType.kt new file mode 100644 index 00000000..48c87dd9 --- /dev/null +++ b/Prezel/feature/terms/api/src/main/java/com/team/prezel/feature/terms/api/TermsDocumentType.kt @@ -0,0 +1,9 @@ +package com.team.prezel.feature.terms.api + +import kotlinx.serialization.Serializable + +@Serializable +enum class TermsDocumentType { + TERMS_OF_SERVICE, + PRIVACY_POLICY, +} diff --git a/Prezel/feature/terms/api/src/main/java/com/team/prezel/feature/terms/api/TermsNavKey.kt b/Prezel/feature/terms/api/src/main/java/com/team/prezel/feature/terms/api/TermsNavKey.kt index c34c996f..fea30c17 100644 --- a/Prezel/feature/terms/api/src/main/java/com/team/prezel/feature/terms/api/TermsNavKey.kt +++ b/Prezel/feature/terms/api/src/main/java/com/team/prezel/feature/terms/api/TermsNavKey.kt @@ -4,4 +4,12 @@ import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable @Serializable -data object TermsNavKey : NavKey +sealed interface TermsNavKey : NavKey { + @Serializable + data object List : TermsNavKey + + @Serializable + data class Detail( + val document: TermsDocumentType, + ) : TermsNavKey +} diff --git a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/TermsScreen.kt b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/TermsScreen.kt index 2a7bfe49..5b3fe132 100644 --- a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/TermsScreen.kt +++ b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/TermsScreen.kt @@ -36,14 +36,16 @@ 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.PrezelButton import com.team.prezel.core.designsystem.component.actions.button.PrezelHyperlinkButton +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar 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.core.ui.state.LocalSnackbarHostState -import com.team.prezel.feature.terms.impl.component.TermsDetailModal import com.team.prezel.feature.terms.impl.contract.TermsUiEffect import com.team.prezel.feature.terms.impl.contract.TermsUiIntent import com.team.prezel.feature.terms.impl.contract.TermsUiState @@ -52,6 +54,8 @@ import com.team.prezel.feature.terms.impl.model.TermsUiMessage @Composable internal fun TermsScreen( navigateBack: () -> Unit, + navigateToTermsOfServiceDetail: () -> Unit, + navigateToPrivacyPolicyDetail: () -> Unit, navigateToProfile: () -> Unit, modifier: Modifier = Modifier, viewModel: TermsViewModel = hiltViewModel(), @@ -59,7 +63,6 @@ internal fun TermsScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val resources = LocalResources.current val snackbarHostState = LocalSnackbarHostState.current - var activeDetailUrl by remember { mutableStateOf(null) } LaunchedEffect(Unit) { viewModel.uiEffect.collect { effect -> @@ -79,13 +82,6 @@ internal fun TermsScreen( } } - activeDetailUrl?.let { url -> - TermsDetailModal( - url = url, - onDismiss = { activeDetailUrl = null }, - ) - } - TermsScreenScreen( uiState = uiState, onBack = navigateBack, @@ -93,8 +89,8 @@ internal fun TermsScreen( 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 }, + onClickTermsOfServiceDetail = navigateToTermsOfServiceDetail, + onClickPrivacyPolicyDetail = navigateToPrivacyPolicyDetail, onContinue = { viewModel.onIntent(TermsUiIntent.ClickContinue) }, modifier = modifier, ) @@ -114,8 +110,6 @@ private fun TermsScreenScreen( onContinue: () -> Unit, modifier: Modifier = Modifier, ) { - val resources = LocalResources.current - Column(modifier = modifier.fillMaxSize()) { TermsScreenTopAppBar(onBack = onBack) @@ -130,13 +124,18 @@ private fun TermsScreenScreen( onToggleMarketingConsent = onToggleMarketingConsent, ) - PrezelButtonArea { - MainButton( - label = resources.getString(R.string.feature_terms_impl_terms_continue_button_text), - enabled = uiState.isRequiredChecked && !uiState.isLoading, - onClick = onContinue, - ) - } + PrezelButtonArea( + mainButton = { modifier -> + PrezelButton( + modifier = modifier, + text = stringResource(R.string.feature_terms_impl_terms_continue_button_text), + onClick = onContinue, + enabled = uiState.isRequiredChecked && !uiState.isLoading, + type = ButtonType.FILLED, + hierarchy = ButtonHierarchy.PRIMARY, + ) + }, + ) } } diff --git a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/navigation/TermsEntryBuilder.kt b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/navigation/TermsEntryBuilder.kt index ebb66d09..5bf6efa7 100644 --- a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/navigation/TermsEntryBuilder.kt +++ b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/navigation/TermsEntryBuilder.kt @@ -4,8 +4,11 @@ import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.feature.profile.api.ProfileNavKey +import com.team.prezel.feature.terms.api.TermsDocumentType import com.team.prezel.feature.terms.api.TermsNavKey +import com.team.prezel.feature.terms.impl.BuildConfig import com.team.prezel.feature.terms.impl.TermsScreen +import com.team.prezel.feature.terms.impl.component.TermsDetailModal import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -13,20 +16,41 @@ import dagger.hilt.android.components.ActivityRetainedComponent import dagger.multibindings.IntoSet internal fun EntryProviderScope.featureTermsEntryBuilder() { - entry { + entry { val navigator = LocalNavigator.current TermsScreen( navigateBack = { navigator.goBack() }, + navigateToTermsOfServiceDetail = { + navigator.navigate(TermsNavKey.Detail(TermsDocumentType.TERMS_OF_SERVICE)) + }, + navigateToPrivacyPolicyDetail = { + navigator.navigate(TermsNavKey.Detail(TermsDocumentType.PRIVACY_POLICY)) + }, navigateToProfile = { navigator.navigate(ProfileNavKey.Create) }, ) } + + entry { key -> + val navigator = LocalNavigator.current + + TermsDetailModal( + url = key.document.toUrl(), + onDismiss = { navigator.goBack() }, + ) + } } +private fun TermsDocumentType.toUrl(): String = + when (this) { + TermsDocumentType.TERMS_OF_SERVICE -> BuildConfig.TERMS_OF_SERVICE_URL + TermsDocumentType.PRIVACY_POLICY -> BuildConfig.PRIVACY_POLICY_URL + } + @Module @InstallIn(ActivityRetainedComponent::class) object FeatureTermsModule { diff --git a/Prezel/settings.gradle.kts b/Prezel/settings.gradle.kts index 6e39fb67..825400ea 100644 --- a/Prezel/settings.gradle.kts +++ b/Prezel/settings.gradle.kts @@ -55,6 +55,8 @@ includeAuto( ":feature:history:impl", ":feature:my:api", ":feature:my:impl", + ":feature:setting:api", + ":feature:setting:impl", ":feature:profile:api", ":feature:profile:impl", ":feature:login:api",