diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 198827f55..c1602dd1d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { defaultConfig { applicationId = "com.yapp.twix" - versionCode = 9 - versionName = "1.0.1" + versionCode = 10 + versionName = "1.0.2" val kakaoKey = properties["kakao_dev_native_app_key"].toString() manifestPlaceholders["KAKAO_NATIVE_APP_KEY"] = kakaoKey.trim('"') diff --git a/core/design-system/src/main/java/com/twix/designsystem/components/common/CommonSwitch.kt b/core/design-system/src/main/java/com/twix/designsystem/components/common/CommonSwitch.kt index ac156819c..2e4f7275e 100644 --- a/core/design-system/src/main/java/com/twix/designsystem/components/common/CommonSwitch.kt +++ b/core/design-system/src/main/java/com/twix/designsystem/components/common/CommonSwitch.kt @@ -27,6 +27,7 @@ import kotlin.math.roundToInt fun CommonSwitch( modifier: Modifier = Modifier, checked: Boolean, + enabled: Boolean = true, onClick: (Boolean) -> Unit, ) { val density = LocalDensity.current @@ -45,7 +46,10 @@ fun CommonSwitch( .clip(RoundedCornerShape(999.dp)) .background(if (checked) GrayColor.C500 else CommonColor.White) .border(1.dp, GrayColor.C500, RoundedCornerShape(999.dp)) - .clickable(onClick = { onClick(!checked) }), + .clickable( + enabled = enabled, + onClick = { onClick(!checked) }, + ), contentAlignment = Alignment.CenterStart, ) { Box( diff --git a/feature/goal-editor/src/main/java/com/twix/goal_editor/component/GoalTextField.kt b/core/design-system/src/main/java/com/twix/designsystem/components/text_field/ValidateUnderlineTextField.kt similarity index 78% rename from feature/goal-editor/src/main/java/com/twix/goal_editor/component/GoalTextField.kt rename to core/design-system/src/main/java/com/twix/designsystem/components/text_field/ValidateUnderlineTextField.kt index 14542af6d..6bfeeff6c 100644 --- a/feature/goal-editor/src/main/java/com/twix/goal_editor/component/GoalTextField.kt +++ b/core/design-system/src/main/java/com/twix/designsystem/components/text_field/ValidateUnderlineTextField.kt @@ -1,4 +1,4 @@ -package com.twix.goal_editor.component +package com.twix.designsystem.components.text_field import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement @@ -27,72 +27,81 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import com.twix.designsystem.R import com.twix.designsystem.components.text.AppText -import com.twix.designsystem.components.text_field.UnderlineTextField import com.twix.designsystem.theme.SystemColor import com.twix.domain.model.enums.AppTextStyle import com.twix.ui.extension.noRippleClickable @OptIn(ExperimentalLayoutApi::class) @Composable -fun GoalTextField( +fun ValidateUnderlineTextField( value: String, - onCommitTitle: (String) -> Unit, + modifier: Modifier = Modifier, + textFieldModifier: Modifier = Modifier, + placeholder: String, + guideText: String, + validLengthRange: IntRange = 2..14, + maxLength: Int = validLengthRange.last, + showGuideWhenValid: Boolean = true, + onCommit: (String) -> Unit, ) { val focusManager = LocalFocusManager.current val density = LocalDensity.current + var internalValue by rememberSaveable(value) { mutableStateOf(value) } var isFocused by remember { mutableStateOf(false) } var lastCommitted by remember(value) { mutableStateOf(value.trim()) } fun commitIfChanged() { val trimmed = internalValue.trim() - if (trimmed != lastCommitted) { + + if (trimmed.length in validLengthRange && trimmed != lastCommitted) { lastCommitted = trimmed - onCommitTitle(trimmed) + onCommit(trimmed) } } - val imeVisibleState = - remember { - mutableStateOf(false) - } - + val imeVisibleState = remember { mutableStateOf(false) } imeVisibleState.value = WindowInsets.ime.getBottom(density) > 0 + LaunchedEffect(isFocused) { var prev = imeVisibleState.value + snapshotFlow { imeVisibleState.value } .collect { now -> if (prev && !now && isFocused) { commitIfChanged() focusManager.clearFocus(force = true) } + prev = now } } + val isValidLength = internalValue.length in validLengthRange + Column( - modifier = - Modifier - .height(96.dp), + modifier = modifier.height(96.dp), verticalArrangement = Arrangement.Top, ) { UnderlineTextField( modifier = - Modifier + textFieldModifier .padding(horizontal = 20.dp) .fillMaxWidth() .onFocusChanged { state -> isFocused = state.isFocused - if (!state.isFocused) commitIfChanged() + + if (!state.isFocused) { + commitIfChanged() + } }, value = internalValue, - placeHolder = stringResource(R.string.goal_editor_text_field_placeholder), - maxLength = 14, + placeHolder = placeholder, + maxLength = maxLength, showTrailing = internalValue.isNotBlank(), onValueChange = { internalValue = it }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), @@ -107,12 +116,15 @@ fun GoalTextField( Image( painter = painterResource(R.drawable.ic_clear_text), contentDescription = null, - modifier = Modifier.noRippleClickable { internalValue = "" }, + modifier = + Modifier.noRippleClickable { + internalValue = "" + }, ) }, ) - if (internalValue.length in 2..14) { + if (showGuideWhenValid && isValidLength) { Row( modifier = Modifier @@ -124,13 +136,11 @@ fun GoalTextField( Image( painter = painterResource(R.drawable.ic_check_success), contentDescription = null, - modifier = - Modifier - .size(16.dp), + modifier = Modifier.size(16.dp), ) AppText( - text = stringResource(R.string.goal_editor_text_filed_guide), + text = guideText, style = AppTextStyle.C2, color = SystemColor.Success, ) diff --git a/core/design-system/src/main/res/drawable/ic_arrow_down_circle.xml b/core/design-system/src/main/res/drawable/ic_arrow_down_circle.xml new file mode 100644 index 000000000..2528c7a15 --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_arrow_down_circle.xml @@ -0,0 +1,16 @@ + + + + diff --git a/core/design-system/src/main/res/drawable/ic_language.xml b/core/design-system/src/main/res/drawable/ic_language.xml new file mode 100644 index 000000000..a2819377f --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_language.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/design-system/src/main/res/drawable/ic_notification.xml b/core/design-system/src/main/res/drawable/ic_notification.xml new file mode 100644 index 000000000..4fa2a51d2 --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,12 @@ + + + diff --git a/core/design-system/src/main/res/drawable/ic_question.xml b/core/design-system/src/main/res/drawable/ic_question.xml new file mode 100644 index 000000000..e05384fe2 --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_question.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/design-system/src/main/res/values/strings.xml b/core/design-system/src/main/res/values/strings.xml index 7e093d2bf..bf542c7cd 100644 --- a/core/design-system/src/main/res/values/strings.xml +++ b/core/design-system/src/main/res/values/strings.xml @@ -26,6 +26,7 @@ 찌르기 알림 NEW + 커플코드 매일 매주 @@ -56,6 +57,8 @@ 탈퇴하기 찌르기 찌르기! + 커플 끊기 + 연결 끊기 오늘 우리 목표 @@ -100,6 +103,16 @@ 개인정보 처리방침 나의 버전 알림 설정 + 언어 설정 + 문의하기 + 평일 오전 9시 - 오후 6시 운영 + 닉네임을 입력해 주세요. + 닉네임 2-8자 + 이미 앱 내에 저장된 언어는 변경되지 않아요 + 문의하기 화면을 열 수 없어요. 잠시 후 다시 시도해 주세요. + 찌르기 푸쉬알림 + 마케팅 정보 푸쉬알림 + 야간 마케팅 정보 푸쉬알림 스탬프 통계 @@ -133,6 +146,8 @@ 이미 인증되었어요. 인증샷 촬영을 위해서 카메라 권한이 필요해요. 알림 목록 조회에 실패했습니다. + 알림 설정 조회에 실패했습니다. + 알림 설정 변경에 실패했습니다. 상대방을 찔렀어요! 찌르기에 실패했습니다. %1$s 뒤에 다시 찌를 수 있어요 @@ -157,6 +172,8 @@ 저장된 인증샷은 모두 삭제됩니다. 정말 탈퇴하시겠어요? 커플 연결이 끊어집니다.\n데이터는 전부 삭제되며 복구가 불가능합니다. + 정말 커플을 끊으시겠어요? + 오늘부로 30일 후, 모든 데이터가 삭제됩니다.\n복구 가능 기간은 30일 이내입니다.\n복구 희망시 ttwixteamm@gmail.com로 문의해 주시기 바랍니다. 도움이 되는 정보를\n알림으로 받아보시겠어요? diff --git a/core/navigation/src/main/java/com/twix/navigation/NavRoutes.kt b/core/navigation/src/main/java/com/twix/navigation/NavRoutes.kt index a1c041d00..4cfa538b4 100644 --- a/core/navigation/src/main/java/com/twix/navigation/NavRoutes.kt +++ b/core/navigation/src/main/java/com/twix/navigation/NavRoutes.kt @@ -135,6 +135,8 @@ sealed class NavRoutes( object SettingsAboutRoute : NavRoutes("settings/about") + object SettingsNotificationRoute : NavRoutes("settings/notification") + /** * StatsGraph * */ diff --git a/core/network/src/main/java/com/twix/network/model/request/notification/UpdateNotificationSettingRequest.kt b/core/network/src/main/java/com/twix/network/model/request/notification/UpdateNotificationSettingRequest.kt new file mode 100644 index 000000000..b8086f0ec --- /dev/null +++ b/core/network/src/main/java/com/twix/network/model/request/notification/UpdateNotificationSettingRequest.kt @@ -0,0 +1,8 @@ +package com.twix.network.model.request.notification + +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateNotificationSettingRequest( + val enabled: Boolean, +) diff --git a/core/network/src/main/java/com/twix/network/model/response/notification/mapper/NotificationMapper.kt b/core/network/src/main/java/com/twix/network/model/response/notification/mapper/NotificationMapper.kt index 0df92ca63..5d7645922 100644 --- a/core/network/src/main/java/com/twix/network/model/response/notification/mapper/NotificationMapper.kt +++ b/core/network/src/main/java/com/twix/network/model/response/notification/mapper/NotificationMapper.kt @@ -3,8 +3,10 @@ package com.twix.network.model.response.notification.mapper import com.twix.domain.model.enums.NotificationType import com.twix.domain.model.notification.Notification import com.twix.domain.model.notification.NotificationPage +import com.twix.domain.model.notification.NotificationSettings import com.twix.network.model.response.notification.model.NotificationListResponse import com.twix.network.model.response.notification.model.NotificationResponse +import com.twix.network.model.response.notification.model.NotificationSettingsResponse import java.time.LocalDateTime import java.time.OffsetDateTime @@ -25,4 +27,11 @@ fun NotificationResponse.toDomain(): Notification = createdAt = createdAt.toLocalDateTimeOrNull(), ) +fun NotificationSettingsResponse.toDomain(): NotificationSettings = + NotificationSettings( + isPushEnabled = isPushEnabled, + isMarketingPushEnabled = isMarketingPushEnabled, + isNightPushEnabled = isNightPushEnabled, + ) + private fun String.toLocalDateTimeOrNull(): LocalDateTime? = runCatching { OffsetDateTime.parse(this).toLocalDateTime() }.getOrNull() diff --git a/core/network/src/main/java/com/twix/network/model/response/notification/model/NotificationSettingsResponse.kt b/core/network/src/main/java/com/twix/network/model/response/notification/model/NotificationSettingsResponse.kt new file mode 100644 index 000000000..8cbc0d667 --- /dev/null +++ b/core/network/src/main/java/com/twix/network/model/response/notification/model/NotificationSettingsResponse.kt @@ -0,0 +1,10 @@ +package com.twix.network.model.response.notification.model + +import kotlinx.serialization.Serializable + +@Serializable +data class NotificationSettingsResponse( + val isPushEnabled: Boolean, + val isMarketingPushEnabled: Boolean, + val isNightPushEnabled: Boolean, +) diff --git a/core/network/src/main/java/com/twix/network/service/NotificationService.kt b/core/network/src/main/java/com/twix/network/service/NotificationService.kt index 409ca05c0..f7be92ff5 100644 --- a/core/network/src/main/java/com/twix/network/service/NotificationService.kt +++ b/core/network/src/main/java/com/twix/network/service/NotificationService.kt @@ -3,7 +3,9 @@ package com.twix.network.service import com.twix.network.model.request.notification.InitNotificationSettingsRequest import com.twix.network.model.request.notification.RegisterFcmTokenRequest import com.twix.network.model.request.notification.TokenRequest +import com.twix.network.model.request.notification.UpdateNotificationSettingRequest import com.twix.network.model.response.notification.model.NotificationListResponse +import com.twix.network.model.response.notification.model.NotificationSettingsResponse import de.jensklingenberg.ktorfit.http.Body import de.jensklingenberg.ktorfit.http.DELETE import de.jensklingenberg.ktorfit.http.GET @@ -33,6 +35,24 @@ interface NotificationService { @Body request: InitNotificationSettingsRequest, ) + @GET("api/v1/notifications/settings") + suspend fun fetchNotificationSettings(): NotificationSettingsResponse + + @PATCH("api/v1/notifications/settings/poke") + suspend fun updatePokeNotificationSetting( + @Body request: UpdateNotificationSettingRequest, + ): NotificationSettingsResponse + + @PATCH("api/v1/notifications/settings/marketing") + suspend fun updateMarketingNotificationSetting( + @Body request: UpdateNotificationSettingRequest, + ): NotificationSettingsResponse + + @PATCH("api/v1/notifications/settings/night") + suspend fun updateNightNotificationSetting( + @Body request: UpdateNotificationSettingRequest, + ): NotificationSettingsResponse + @GET("api/v1/notifications") suspend fun fetchNotifications( @Query("lastId") lastId: Long? = null, diff --git a/core/network/src/main/java/com/twix/network/service/OnboardingService.kt b/core/network/src/main/java/com/twix/network/service/OnboardingService.kt index 74a26141b..31f81de07 100644 --- a/core/network/src/main/java/com/twix/network/service/OnboardingService.kt +++ b/core/network/src/main/java/com/twix/network/service/OnboardingService.kt @@ -7,6 +7,7 @@ import com.twix.network.model.response.onboarding.InviteCodeResponse import com.twix.network.model.response.onboarding.OnBoardingStatusResponse import de.jensklingenberg.ktorfit.http.Body import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.PATCH import de.jensklingenberg.ktorfit.http.POST interface OnboardingService { @@ -30,4 +31,9 @@ interface OnboardingService { @GET("api/v1/onboarding/status") suspend fun fetchOnBoardingStatus(): OnBoardingStatusResponse + + @PATCH("api/v1/onboarding/profile") + suspend fun updateProfile( + @Body request: ProfileRequest, + ) } diff --git a/core/util/src/main/java/com/twix/util/extension/ContextExt.kt b/core/util/src/main/java/com/twix/util/extension/ContextExt.kt new file mode 100644 index 000000000..c98b7debb --- /dev/null +++ b/core/util/src/main/java/com/twix/util/extension/ContextExt.kt @@ -0,0 +1,34 @@ +package com.twix.util.extension + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri + +fun Context.openExternalUrl( + url: String, + onFailed: () -> Unit = {}, +) { + if (url.isBlank()) { + onFailed() + return + } + + val intent = + Intent(Intent.ACTION_VIEW, url.toUri()).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + + if (this@openExternalUrl !is Activity) { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } + + try { + startActivity(intent) + } catch (e: ActivityNotFoundException) { + onFailed() + } catch (e: Exception) { + onFailed() + } +} diff --git a/data/src/main/java/com/twix/data/repository/DefaultNotificationRepository.kt b/data/src/main/java/com/twix/data/repository/DefaultNotificationRepository.kt index e2aa91042..cd9c54e66 100644 --- a/data/src/main/java/com/twix/data/repository/DefaultNotificationRepository.kt +++ b/data/src/main/java/com/twix/data/repository/DefaultNotificationRepository.kt @@ -5,6 +5,7 @@ import com.twix.network.execute.safeApiCall import com.twix.network.model.request.notification.InitNotificationSettingsRequest import com.twix.network.model.request.notification.RegisterFcmTokenRequest import com.twix.network.model.request.notification.TokenRequest +import com.twix.network.model.request.notification.UpdateNotificationSettingRequest import com.twix.network.model.response.notification.mapper.toDomain import com.twix.network.service.NotificationService @@ -34,6 +35,29 @@ class DefaultNotificationRepository( ) } + override suspend fun fetchNotificationSettings() = safeApiCall { service.fetchNotificationSettings().toDomain() } + + override suspend fun updatePokeNotificationSetting(enabled: Boolean) = + safeApiCall { + service + .updatePokeNotificationSetting(UpdateNotificationSettingRequest(enabled)) + .toDomain() + } + + override suspend fun updateMarketingNotificationSetting(enabled: Boolean) = + safeApiCall { + service + .updateMarketingNotificationSetting(UpdateNotificationSettingRequest(enabled)) + .toDomain() + } + + override suspend fun updateNightNotificationSetting(enabled: Boolean) = + safeApiCall { + service + .updateNightNotificationSetting(UpdateNotificationSettingRequest(enabled)) + .toDomain() + } + override suspend fun fetchNotifications( lastId: Long?, size: Int, diff --git a/data/src/main/java/com/twix/data/repository/DefaultOnboardingRepository.kt b/data/src/main/java/com/twix/data/repository/DefaultOnboardingRepository.kt index 4d017c508..8451ef784 100644 --- a/data/src/main/java/com/twix/data/repository/DefaultOnboardingRepository.kt +++ b/data/src/main/java/com/twix/data/repository/DefaultOnboardingRepository.kt @@ -34,4 +34,6 @@ class DefaultOnboardingRepository( safeApiCall { service.coupleConnection(CoupleConnectionRequest(request)) } override suspend fun profileSetup(request: String): AppResult = safeApiCall { service.profileSetup(ProfileRequest(request)) } + + override suspend fun updateProfile(request: String): AppResult = safeApiCall { service.updateProfile(ProfileRequest(request)) } } diff --git a/domain/src/main/java/com/twix/domain/model/notification/NotificationSettings.kt b/domain/src/main/java/com/twix/domain/model/notification/NotificationSettings.kt new file mode 100644 index 000000000..415b6f506 --- /dev/null +++ b/domain/src/main/java/com/twix/domain/model/notification/NotificationSettings.kt @@ -0,0 +1,7 @@ +package com.twix.domain.model.notification + +data class NotificationSettings( + val isPushEnabled: Boolean, + val isMarketingPushEnabled: Boolean, + val isNightPushEnabled: Boolean, +) diff --git a/domain/src/main/java/com/twix/domain/repository/NotificationRepository.kt b/domain/src/main/java/com/twix/domain/repository/NotificationRepository.kt index 9d8636dc3..6c615eae0 100644 --- a/domain/src/main/java/com/twix/domain/repository/NotificationRepository.kt +++ b/domain/src/main/java/com/twix/domain/repository/NotificationRepository.kt @@ -1,6 +1,7 @@ package com.twix.domain.repository import com.twix.domain.model.notification.NotificationPage +import com.twix.domain.model.notification.NotificationSettings import com.twix.result.AppResult interface NotificationRepository { @@ -19,6 +20,14 @@ interface NotificationRepository { isNightPushEnabled: Boolean, ): AppResult + suspend fun fetchNotificationSettings(): AppResult + + suspend fun updatePokeNotificationSetting(enabled: Boolean): AppResult + + suspend fun updateMarketingNotificationSetting(enabled: Boolean): AppResult + + suspend fun updateNightNotificationSetting(enabled: Boolean): AppResult + suspend fun fetchNotifications( lastId: Long? = null, size: Int = 20, diff --git a/domain/src/main/java/com/twix/domain/repository/OnBoardingRepository.kt b/domain/src/main/java/com/twix/domain/repository/OnBoardingRepository.kt index 24e58b1bd..136205587 100644 --- a/domain/src/main/java/com/twix/domain/repository/OnBoardingRepository.kt +++ b/domain/src/main/java/com/twix/domain/repository/OnBoardingRepository.kt @@ -14,4 +14,6 @@ interface OnBoardingRepository { suspend fun fetchInviteCode(): AppResult suspend fun fetchOnboardingStatus(): AppResult + + suspend fun updateProfile(request: String): AppResult } diff --git a/feature/goal-editor/src/main/java/com/twix/goal_editor/GoalEditorScreen.kt b/feature/goal-editor/src/main/java/com/twix/goal_editor/GoalEditorScreen.kt index 556868b87..b6a18077f 100644 --- a/feature/goal-editor/src/main/java/com/twix/goal_editor/GoalEditorScreen.kt +++ b/feature/goal-editor/src/main/java/com/twix/goal_editor/GoalEditorScreen.kt @@ -49,6 +49,7 @@ import com.twix.designsystem.components.dialog.CommonDialog import com.twix.designsystem.components.error.ErrorScreen import com.twix.designsystem.components.loading.TwixLoadingOverlay import com.twix.designsystem.components.text.AppText +import com.twix.designsystem.components.text_field.ValidateUnderlineTextField import com.twix.designsystem.components.toast.ToastManager import com.twix.designsystem.components.toast.model.ToastData import com.twix.designsystem.components.topbar.CommonTopBar @@ -63,7 +64,6 @@ import com.twix.domain.model.enums.RepeatCycle import com.twix.domain.model.goal.RecommendedGoalPresets import com.twix.goal_editor.component.EmojiPicker import com.twix.goal_editor.component.GoalInfoCard -import com.twix.goal_editor.component.GoalTextField import com.twix.goal_editor.model.GoalEditorUiState import com.twix.ui.extension.dismissKeyboardOnTap import com.twix.ui.extension.noRippleClickable @@ -214,9 +214,12 @@ fun GoalEditorScreen( Spacer(Modifier.height(44.dp)) - GoalTextField( + ValidateUnderlineTextField( value = uiState.goalTitle, - onCommitTitle = onCommitTitle, + onCommit = onCommitTitle, + placeholder = stringResource(R.string.goal_editor_text_field_placeholder), + guideText = stringResource(R.string.goal_editor_text_filed_guide), + validLengthRange = 2..14, ) GoalInfoCard( diff --git a/feature/main/src/main/java/com/twix/main/navigation/MainNavGraph.kt b/feature/main/src/main/java/com/twix/main/navigation/MainNavGraph.kt index a100428f9..ba38be4dc 100644 --- a/feature/main/src/main/java/com/twix/main/navigation/MainNavGraph.kt +++ b/feature/main/src/main/java/com/twix/main/navigation/MainNavGraph.kt @@ -38,7 +38,7 @@ object MainNavGraph : NavGraphContributor { } }, navigateToSettings = { - navController.navigate(NavRoutes.SettingsRoute.route) { + navController.navigate(NavRoutes.SettingsGraph.route) { launchSingleTop = true } }, diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index dd2d77af0..b558d35e7 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -24,9 +24,15 @@ android { ?: providers.gradleProperty("privacy_policy_url").orNull ?: "https://incongruous-sweatshirt-b32.notion.site/Keepliuv-3024eb2e10638051824ef9ac7f9a522f" + val kakaoOpenChatUrl = + localProperties.getProperty("kakao_open_chat_url") + ?: providers.gradleProperty("kakao_open_chat_url").orNull + ?: "https://open.kakao.com/o/sTwixHelp" + buildTypes { all { buildConfigField("String", "PRIVACY_POLICY_URL", "\"$privacyPolicyUrl\"") + buildConfigField("String", "KAKAO_OPEN_CHAT_URL", "\"$kakaoOpenChatUrl\"") } } } diff --git a/feature/settings/src/main/java/com/twix/settings/SettingsIntent.kt b/feature/settings/src/main/java/com/twix/settings/SettingsIntent.kt index 83dfc7190..e93db871b 100644 --- a/feature/settings/src/main/java/com/twix/settings/SettingsIntent.kt +++ b/feature/settings/src/main/java/com/twix/settings/SettingsIntent.kt @@ -9,6 +9,18 @@ sealed interface SettingsIntent : Intent { val nickName: String, ) : SettingsIntent + data class SetPokeNotificationEnabled( + val enabled: Boolean, + ) : SettingsIntent + + data class SetMarketingNotificationEnabled( + val enabled: Boolean, + ) : SettingsIntent + + data class SetNightMarketingNotificationEnabled( + val enabled: Boolean, + ) : SettingsIntent + data object Logout : SettingsIntent data object WithdrawAccount : SettingsIntent diff --git a/feature/settings/src/main/java/com/twix/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/twix/settings/SettingsScreen.kt index 8dead48a9..26e5c649d 100644 --- a/feature/settings/src/main/java/com/twix/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/twix/settings/SettingsScreen.kt @@ -2,6 +2,7 @@ package com.twix.settings import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -14,33 +15,53 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.twix.designsystem.R +import com.twix.designsystem.components.dialog.CommonDialog import com.twix.designsystem.components.error.ErrorScreen import com.twix.designsystem.components.loading.TwixLoadingOverlay import com.twix.designsystem.components.text.AppText +import com.twix.designsystem.components.toast.ToastManager +import com.twix.designsystem.components.toast.model.ToastData +import com.twix.designsystem.components.toast.model.ToastType import com.twix.designsystem.components.topbar.CommonTopBar import com.twix.designsystem.theme.CommonColor import com.twix.designsystem.theme.GrayColor +import com.twix.designsystem.theme.TwixTheme import com.twix.domain.model.enums.AppTextStyle +import com.twix.settings.component.ProfileInfo import com.twix.settings.component.SettingsMenuFrame import com.twix.settings.component.SettingsMenuItem +import com.twix.settings.model.SettingsLanguage import com.twix.settings.model.SettingsUiState +import com.twix.ui.extension.dismissKeyboardOnTap import com.twix.ui.extension.noRippleClickable +import com.twix.util.extension.openExternalUrl import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject @Composable fun SettingsRoute( viewModel: SettingsViewModel = koinViewModel(), + toastManager: ToastManager = koinInject(), popBackStack: () -> Unit, navigateToSettingsAccount: () -> Unit, navigateToSettingsAbout: () -> Unit, + navigateToSettingsNotification: () -> Unit, ) { + val context = LocalContext.current + val currentContext by rememberUpdatedState(context) val uiState by viewModel.uiState.collectAsStateWithLifecycle() SettingsScreen( @@ -49,6 +70,21 @@ fun SettingsRoute( onRetry = { viewModel.dispatch(SettingsIntent.Retry) }, onAccountClick = navigateToSettingsAccount, onAboutClick = navigateToSettingsAbout, + onInquiryClick = { + currentContext.openExternalUrl( + url = BuildConfig.KAKAO_OPEN_CHAT_URL, + onFailed = { + toastManager.tryShow( + ToastData( + message = currentContext.getString(R.string.settings_inquiry_open_failed), + type = ToastType.ERROR, + ), + ) + }, + ) + }, + onNotificationClick = navigateToSettingsNotification, + onCommitNickName = { viewModel.dispatch(SettingsIntent.SetNickName(it)) }, ) } @@ -57,69 +93,179 @@ private fun SettingsScreen( uiState: SettingsUiState, onBack: () -> Unit, onRetry: () -> Unit, - onAccountClick: () -> Unit, - onAboutClick: () -> Unit, + onAccountClick: () -> Unit = {}, + onAboutClick: () -> Unit = {}, + onInquiryClick: () -> Unit = {}, + onNotificationClick: () -> Unit = {}, + onCommitNickName: (String) -> Unit = {}, ) { + var isEditMode by remember { mutableStateOf(false) } + var showLanguageDialog by remember { mutableStateOf(false) } + when { uiState.showLoading -> TwixLoadingOverlay() uiState.showError -> ErrorScreen(onClickRetry = onRetry, onClickBack = onBack) else -> { - Column( + Box( modifier = Modifier .fillMaxSize() + .dismissKeyboardOnTap(onDismiss = { isEditMode = false }) .background(CommonColor.White), ) { - CommonTopBar( - title = stringResource(R.string.word_setting), - left = { - Image( - painter = painterResource(R.drawable.ic_arrow3_left), - contentDescription = "back", - modifier = - Modifier - .padding(18.dp) - .size(24.dp) - .noRippleClickable(onClick = onBack), - ) - }, - ) - - Spacer(Modifier.height(20.dp)) - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), + modifier = Modifier.fillMaxSize(), ) { - ProfileInfo(nickname = uiState.nickName) + CommonTopBar( + title = stringResource(R.string.word_setting), + left = { + Image( + painter = painterResource(R.drawable.ic_arrow3_left), + contentDescription = "back", + modifier = + Modifier + .padding(18.dp) + .size(24.dp) + .noRippleClickable(onClick = onBack), + ) + }, + ) - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(20.dp)) - SettingsMenuFrame { - SettingsMenuItem( - resId = R.drawable.ic_profile_small, - title = stringResource(R.string.word_account), - onClick = onAccountClick, + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) { + ProfileInfo( + nickname = uiState.nickName, + isEditMode = isEditMode, + onCommitNickName = { + onCommitNickName(it) + isEditMode = false + }, + onEditModeChange = { isEditMode = it }, ) - HorizontalDivider(thickness = 1.dp, color = GrayColor.C500) + Spacer(Modifier.height(24.dp)) - SettingsMenuItem( - resId = R.drawable.ic_info, - title = stringResource(R.string.word_information), - onClick = onAboutClick, - ) + SettingsMenuFrame { + SettingsMenuItem( + resId = R.drawable.ic_language, + title = stringResource(R.string.settings_language), + right = { + LanguageMenuRight(language = SettingsLanguage.Korean) + }, + onClick = { showLanguageDialog = true }, + ) + + SettingsDivider() + + SettingsMenuItem( + resId = R.drawable.ic_profile_small, + title = stringResource(R.string.word_account), + onClick = onAccountClick, + ) + + SettingsDivider() + + SettingsMenuItem( + resId = R.drawable.ic_info, + title = stringResource(R.string.word_information), + onClick = onAboutClick, + ) + + SettingsDivider() + + SettingsMenuItem( + resId = R.drawable.ic_question, + title = stringResource(R.string.settings_inquiry), + right = { + AppText( + text = stringResource(R.string.settings_inquiry_time), + style = AppTextStyle.B2, + color = GrayColor.C500, + ) + }, + onClick = onInquiryClick, + ) + + SettingsDivider() + + SettingsMenuItem( + resId = R.drawable.ic_notification, + title = stringResource(R.string.settings_notification), + onClick = onNotificationClick, + ) + } } } + + LanguageSettingDialog( + visible = showLanguageDialog, + selectedLanguage = SettingsLanguage.Korean, + onDismissRequest = { + showLanguageDialog = false + }, + onConfirm = { + showLanguageDialog = false + }, + onDismiss = { + showLanguageDialog = false + }, + ) } } } } @Composable -private fun ProfileInfo(nickname: String) { +private fun LanguageSettingDialog( + visible: Boolean, + selectedLanguage: SettingsLanguage, + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + CommonDialog( + visible = visible, + confirmText = stringResource(R.string.word_completion), + dismissText = stringResource(R.string.word_cancel), + onDismissRequest = onDismissRequest, + onConfirm = onConfirm, + onDismiss = onDismiss, + content = { + AppText( + text = stringResource(R.string.settings_language), + style = AppTextStyle.T1, + color = GrayColor.C500, + ) + + Spacer(Modifier.height(8.dp)) + + AppText( + text = stringResource(R.string.settings_language_description), + style = AppTextStyle.B2, + color = GrayColor.C400, + ) + + Spacer(Modifier.height(24.dp)) + + LanguageDialogItem( + language = SettingsLanguage.Korean, + selected = selectedLanguage == SettingsLanguage.Korean, + ) + }, + ) +} + +@Composable +private fun LanguageDialogItem( + language: SettingsLanguage, + selected: Boolean, +) { Row( modifier = Modifier @@ -127,19 +273,65 @@ private fun ProfileInfo(nickname: String) { verticalAlignment = Alignment.CenterVertically, ) { Image( - painter = painterResource(R.drawable.ic_profile), - contentDescription = "profile", - modifier = - Modifier - .size(52.dp), + painter = + painterResource( + if (selected) { + R.drawable.ic_checked_you + } else { + R.drawable.ic_empty_check + }, + ), + contentDescription = null, + modifier = Modifier.size(28.dp), ) - Spacer(Modifier.width(16.dp)) + Spacer(Modifier.width(8.dp)) AppText( - text = nickname, - style = AppTextStyle.T1, + text = language.displayName, + style = AppTextStyle.B2, color = GrayColor.C500, ) } } + +@Composable +private fun SettingsDivider() { + HorizontalDivider( + thickness = 1.dp, + color = GrayColor.C500, + ) +} + +@Composable +private fun LanguageMenuRight(language: SettingsLanguage = SettingsLanguage.Korean) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + AppText( + text = language.displayName, + style = AppTextStyle.B2, + color = GrayColor.C500, + ) + + Spacer(Modifier.width(8.dp)) + + Image( + painter = painterResource(R.drawable.ic_arrow_down_circle), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +private fun Preview() { + TwixTheme { + SettingsScreen( + uiState = SettingsUiState(), + onBack = {}, + onRetry = {}, + ) + } +} diff --git a/feature/settings/src/main/java/com/twix/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/twix/settings/SettingsViewModel.kt index 303a5c4e2..d890e0d3a 100644 --- a/feature/settings/src/main/java/com/twix/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/twix/settings/SettingsViewModel.kt @@ -3,6 +3,8 @@ package com.twix.settings import com.twix.designsystem.R import com.twix.designsystem.components.toast.model.ToastType import com.twix.domain.repository.AuthRepository +import com.twix.domain.repository.NotificationRepository +import com.twix.domain.repository.OnBoardingRepository import com.twix.domain.repository.UserRepository import com.twix.notification.token.NotificationTokenRegistrar import com.twix.settings.model.SettingsUiState @@ -12,20 +14,59 @@ class SettingsViewModel( private val userRepository: UserRepository, private val authRepository: AuthRepository, private val tokenRegistrar: NotificationTokenRegistrar, + private val onBoardingRepository: OnBoardingRepository, + private val notificationRepository: NotificationRepository, ) : BaseViewModel(SettingsUiState()) { init { fetchUserInfo() + fetchNotificationSetting() } override suspend fun handleIntent(intent: SettingsIntent) { when (intent) { - SettingsIntent.Retry -> fetchUserInfo() + SettingsIntent.Retry -> { + fetchUserInfo() + fetchNotificationSetting() + } is SettingsIntent.SetNickName -> setNickName(intent.nickName) + is SettingsIntent.SetPokeNotificationEnabled -> { + setPokeNotificationEnabled(intent.enabled) + } + + is SettingsIntent.SetMarketingNotificationEnabled -> { + setMarketingNotificationEnabled(intent.enabled) + } + + is SettingsIntent.SetNightMarketingNotificationEnabled -> { + setNightMarketingNotificationEnabled(intent.enabled) + } SettingsIntent.Logout -> logout() SettingsIntent.WithdrawAccount -> withdrawAccount() } } + private fun fetchNotificationSetting() { + launchResult( + block = { notificationRepository.fetchNotificationSettings() }, + onSuccess = { setting -> + reduceNotificationSettings( + isPushEnabled = setting.isPushEnabled, + isMarketingPushEnabled = setting.isMarketingPushEnabled, + isNightPushEnabled = setting.isNightPushEnabled, + ) + reduce { copy(isLoadedNotificationSettings = true) } + }, + onError = { + tryEmitSideEffect( + SettingsSideEffect.ShowToast( + R.string.toast_notification_setting_load_failed, + ToastType.ERROR, + ), + ) + }, + ) + } + private fun fetchUserInfo() { launchResult( block = { userRepository.fetchUserInfo() }, @@ -34,7 +75,8 @@ class SettingsViewModel( copy( nickName = it.name, email = it.email, - hasLoadedContent = true, + inviteCode = it.inviteCode, + isLoadedUserInfo = true, ) } }, @@ -42,7 +84,138 @@ class SettingsViewModel( } private fun setNickName(nickName: String) { + val originalNickName = currentState.nickName reduce { copy(nickName = nickName) } + + launchResult( + block = { onBoardingRepository.updateProfile(nickName) }, + onSuccess = {}, + onError = { reduce { copy(nickName = originalNickName) } }, + ) + } + + private fun setPokeNotificationEnabled(enabled: Boolean) { + if (currentState.notificationSettingsUpdating) return + + val originalState = currentState + + reduce { + copy( + pokeNotificationEnabled = enabled, + notificationSettingsUpdating = true, + ) + } + + launchResult( + onFinally = { reduce { copy(notificationSettingsUpdating = false) } }, + block = { notificationRepository.updatePokeNotificationSetting(enabled) }, + onSuccess = { setting -> + reduceNotificationSettings( + isPushEnabled = setting.isPushEnabled, + isMarketingPushEnabled = setting.isMarketingPushEnabled, + isNightPushEnabled = setting.isNightPushEnabled, + ) + }, + onError = { + restoreNotificationSettings(originalState) + tryEmitSideEffect( + SettingsSideEffect.ShowToast( + R.string.toast_notification_setting_update_failed, + ToastType.ERROR, + ), + ) + }, + ) + } + + private fun setMarketingNotificationEnabled(enabled: Boolean) { + if (currentState.notificationSettingsUpdating) return + + val originalState = currentState + + reduce { + copy( + marketingNotificationEnabled = enabled, + notificationSettingsUpdating = true, + ) + } + + launchResult( + onFinally = { reduce { copy(notificationSettingsUpdating = false) } }, + block = { notificationRepository.updateMarketingNotificationSetting(enabled) }, + onSuccess = { setting -> + reduceNotificationSettings( + isPushEnabled = setting.isPushEnabled, + isMarketingPushEnabled = setting.isMarketingPushEnabled, + isNightPushEnabled = setting.isNightPushEnabled, + ) + }, + onError = { + restoreNotificationSettings(originalState) + tryEmitSideEffect( + SettingsSideEffect.ShowToast( + R.string.toast_notification_setting_update_failed, + ToastType.ERROR, + ), + ) + }, + ) + } + + private fun setNightMarketingNotificationEnabled(enabled: Boolean) { + if (currentState.notificationSettingsUpdating) return + + val originalState = currentState + + reduce { + copy( + nightMarketingNotificationEnabled = enabled, + notificationSettingsUpdating = true, + ) + } + + launchResult( + onFinally = { reduce { copy(notificationSettingsUpdating = false) } }, + block = { notificationRepository.updateNightNotificationSetting(enabled) }, + onSuccess = { setting -> + reduceNotificationSettings( + isPushEnabled = setting.isPushEnabled, + isMarketingPushEnabled = setting.isMarketingPushEnabled, + isNightPushEnabled = setting.isNightPushEnabled, + ) + }, + onError = { + restoreNotificationSettings(originalState) + tryEmitSideEffect( + SettingsSideEffect.ShowToast( + R.string.toast_notification_setting_update_failed, + ToastType.ERROR, + ), + ) + }, + ) + } + + private fun reduceNotificationSettings( + isPushEnabled: Boolean, + isMarketingPushEnabled: Boolean, + isNightPushEnabled: Boolean, + ) { + reduce { + copy( + pokeNotificationEnabled = isPushEnabled, + marketingNotificationEnabled = isMarketingPushEnabled, + nightMarketingNotificationEnabled = isNightPushEnabled, + ) + } + } + + private fun restoreNotificationSettings(state: SettingsUiState) { + reduceNotificationSettings( + isPushEnabled = state.pokeNotificationEnabled, + isMarketingPushEnabled = state.marketingNotificationEnabled, + isNightPushEnabled = state.nightMarketingNotificationEnabled, + ) } private fun logout() { diff --git a/feature/settings/src/main/java/com/twix/settings/account/SettingsAccountScreen.kt b/feature/settings/src/main/java/com/twix/settings/account/SettingsAccountScreen.kt index 3dafcfbab..3a27d10a5 100644 --- a/feature/settings/src/main/java/com/twix/settings/account/SettingsAccountScreen.kt +++ b/feature/settings/src/main/java/com/twix/settings/account/SettingsAccountScreen.kt @@ -13,13 +13,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.twix.designsystem.R @@ -27,58 +26,49 @@ import com.twix.designsystem.components.dialog.CommonDialog import com.twix.designsystem.components.error.ErrorScreen import com.twix.designsystem.components.loading.TwixLoadingOverlay import com.twix.designsystem.components.text.AppText -import com.twix.designsystem.components.toast.ToastManager -import com.twix.designsystem.components.toast.model.ToastData import com.twix.designsystem.components.topbar.CommonTopBar import com.twix.designsystem.theme.CommonColor import com.twix.designsystem.theme.GrayColor +import com.twix.designsystem.theme.TwixTheme import com.twix.domain.model.enums.AppTextStyle import com.twix.settings.SettingsIntent -import com.twix.settings.SettingsSideEffect import com.twix.settings.SettingsViewModel import com.twix.settings.component.SettingsMenuFrame import com.twix.settings.component.SettingsMenuItem import com.twix.settings.model.SettingsUiState -import com.twix.ui.base.ObserveAsEvents import com.twix.ui.extension.noRippleClickable -import org.koin.compose.koinInject @Composable fun SettingsAccountRoute( - toastManager: ToastManager = koinInject(), viewModel: SettingsViewModel, popBackStack: () -> Unit, - navigateToLogin: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val context = LocalContext.current - val currentContext by rememberUpdatedState(context) - - ObserveAsEvents(viewModel.sideEffect) { effect -> - when (effect) { - SettingsSideEffect.NavigateToLogin -> navigateToLogin() - is SettingsSideEffect.ShowToast -> toastManager.show(ToastData(currentContext.getString(effect.resId), effect.type)) - } - } SettingsAccountScreen( uiState = uiState, + inviteCode = uiState.inviteCode, onBack = popBackStack, onRetry = { viewModel.dispatch(SettingsIntent.Retry) }, onLogout = { viewModel.dispatch(SettingsIntent.Logout) }, onWithdrawAccount = { viewModel.dispatch(SettingsIntent.WithdrawAccount) }, + // TODO: 커플 끊기 API 구현 후 SettingsIntent.UnlinkCouple 로 교체 + onUnlinkCouple = { viewModel.dispatch(SettingsIntent.WithdrawAccount) }, ) } @Composable private fun SettingsAccountScreen( + inviteCode: String = "", uiState: SettingsUiState, onBack: () -> Unit, onRetry: () -> Unit, onLogout: () -> Unit, onWithdrawAccount: () -> Unit, + onUnlinkCouple: () -> Unit, ) { var showWithdrawDialog by remember { mutableStateOf(false) } + var showUnlinkCoupleDialog by remember { mutableStateOf(false) } when { uiState.showLoading -> TwixLoadingOverlay() @@ -123,6 +113,26 @@ private fun SettingsAccountScreen( HorizontalDivider(thickness = 1.dp, color = GrayColor.C500) + SettingsMenuItem( + title = stringResource(R.string.word_couple_code), + right = { + AppText( + text = inviteCode, + color = GrayColor.C500, + style = AppTextStyle.B2, + ) + }, + ) + + HorizontalDivider(thickness = 1.dp, color = GrayColor.C500) + + SettingsMenuItem( + title = stringResource(R.string.action_unlink_couple), + onClick = { showUnlinkCoupleDialog = true }, + ) + + HorizontalDivider(thickness = 1.dp, color = GrayColor.C500) + SettingsMenuItem( title = stringResource(R.string.action_withdraw_account), onClick = { @@ -149,32 +159,96 @@ private fun SettingsAccountScreen( onConfirm = { showWithdrawDialog = false }, onDismissRequest = { showWithdrawDialog = false }, content = { - Image( - painter = painterResource(R.drawable.ic_warning), - contentDescription = "warning", - modifier = - Modifier - .size(60.dp), - ) - - Spacer(Modifier.height(12.dp)) - - AppText( - text = stringResource(R.string.dialog_withdraw_account_title), - color = GrayColor.C500, - style = AppTextStyle.T1, - ) - - Spacer(Modifier.height(8.dp)) + WithdrawAccountDialogContent() + }, + ) - AppText( - text = stringResource(R.string.dialog_withdraw_account_content), - color = GrayColor.C400, - style = AppTextStyle.B2, - textAlign = TextAlign.Center, - ) + CommonDialog( + visible = showUnlinkCoupleDialog, + confirmText = stringResource(R.string.word_cancel), + dismissText = stringResource(R.string.action_unlink_couple), + onDismiss = { + showUnlinkCoupleDialog = false + onUnlinkCouple() + }, + onConfirm = { showUnlinkCoupleDialog = false }, + onDismissRequest = { showUnlinkCoupleDialog = false }, + content = { + UnlinkCoupleDialogContent() }, ) } } } + +@Composable +private fun WithdrawAccountDialogContent() { + Image( + painter = painterResource(R.drawable.ic_warning), + contentDescription = "warning", + modifier = + Modifier + .size(60.dp), + ) + + Spacer(Modifier.height(12.dp)) + + AppText( + text = stringResource(R.string.dialog_withdraw_account_title), + color = GrayColor.C500, + style = AppTextStyle.T1, + ) + + Spacer(Modifier.height(8.dp)) + + AppText( + text = stringResource(R.string.dialog_withdraw_account_content), + color = GrayColor.C400, + style = AppTextStyle.B2, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun UnlinkCoupleDialogContent() { + Image( + painter = painterResource(R.drawable.ic_warning), + contentDescription = "warning", + modifier = + Modifier + .size(60.dp), + ) + + Spacer(Modifier.height(12.dp)) + + AppText( + text = stringResource(R.string.dialog_unlink_couple_title), + color = GrayColor.C500, + style = AppTextStyle.T1, + ) + + Spacer(Modifier.height(8.dp)) + + AppText( + text = stringResource(R.string.dialog_unlink_couple_content), + color = GrayColor.C400, + style = AppTextStyle.B2, + textAlign = TextAlign.Center, + ) +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +private fun Preview() { + TwixTheme { + SettingsAccountScreen( + inviteCode = "inviteCode", + uiState = SettingsUiState(), + onBack = {}, + onRetry = {}, + onLogout = {}, + onWithdrawAccount = {}, + onUnlinkCouple = {}, + ) + } +} diff --git a/feature/settings/src/main/java/com/twix/settings/component/ProfileInfo.kt b/feature/settings/src/main/java/com/twix/settings/component/ProfileInfo.kt new file mode 100644 index 000000000..52cc6369c --- /dev/null +++ b/feature/settings/src/main/java/com/twix/settings/component/ProfileInfo.kt @@ -0,0 +1,90 @@ +package com.twix.settings.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.twix.designsystem.R +import com.twix.designsystem.components.text.AppText +import com.twix.designsystem.components.text_field.ValidateUnderlineTextField +import com.twix.designsystem.theme.GrayColor +import com.twix.domain.model.enums.AppTextStyle +import com.twix.ui.extension.noRippleClickable + +@Composable +fun ProfileInfo( + nickname: String, + isEditMode: Boolean, + onCommitNickName: (String) -> Unit, + onEditModeChange: (Boolean) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + Image( + painter = painterResource(R.drawable.ic_profile), + contentDescription = "profile", + modifier = + Modifier + .size(52.dp), + ) + + Column( + horizontalAlignment = Alignment.Start, + ) { + if (isEditMode) { + ValidateUnderlineTextField( + modifier = + Modifier + .height(77.dp), + value = nickname, + onCommit = onCommitNickName, + placeholder = stringResource(R.string.settings_nickname_placeholder), + guideText = stringResource(R.string.settings_nickname_text_filed_guide), + validLengthRange = 2..8, + ) + } else { + Row( + modifier = + Modifier + .height(52.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + Spacer(Modifier.width(16.dp)) + + AppText( + text = nickname, + style = AppTextStyle.T1, + color = GrayColor.C500, + ) + + Image( + painter = painterResource(R.drawable.ic_edit), + contentDescription = null, + modifier = + Modifier + .padding(10.dp) + .size(24.dp) + .noRippleClickable { onEditModeChange(true) }, + ) + } + } + } + } +} diff --git a/feature/settings/src/main/java/com/twix/settings/model/SettingMenuUiModel.kt b/feature/settings/src/main/java/com/twix/settings/model/SettingMenuUiModel.kt new file mode 100644 index 000000000..3fd4b6959 --- /dev/null +++ b/feature/settings/src/main/java/com/twix/settings/model/SettingMenuUiModel.kt @@ -0,0 +1,9 @@ +package com.twix.settings.model + +data class SettingMenuUiModel( + val iconResId: Int, + val title: String, + val trailingText: String? = null, + val trailingIconResId: Int? = null, + val onClick: () -> Unit, +) diff --git a/feature/settings/src/main/java/com/twix/settings/model/SettingsLanguage.kt b/feature/settings/src/main/java/com/twix/settings/model/SettingsLanguage.kt new file mode 100644 index 000000000..50eb92c7b --- /dev/null +++ b/feature/settings/src/main/java/com/twix/settings/model/SettingsLanguage.kt @@ -0,0 +1,7 @@ +package com.twix.settings.model + +enum class SettingsLanguage( + val displayName: String, +) { + Korean("한국어"), +} diff --git a/feature/settings/src/main/java/com/twix/settings/model/SettingsUiState.kt b/feature/settings/src/main/java/com/twix/settings/model/SettingsUiState.kt index 7088680d0..7f4dd5d5d 100644 --- a/feature/settings/src/main/java/com/twix/settings/model/SettingsUiState.kt +++ b/feature/settings/src/main/java/com/twix/settings/model/SettingsUiState.kt @@ -6,11 +6,20 @@ import com.twix.ui.base.ContentLoadableState data class SettingsUiState( val nickName: String = "", val email: String = "", - override val hasLoadedContent: Boolean = false, - val isAccountActionInFlight: Boolean = false, + val inviteCode: String = "", + val pokeNotificationEnabled: Boolean = false, + val marketingNotificationEnabled: Boolean = false, + val nightMarketingNotificationEnabled: Boolean = false, + val notificationSettingsUpdating: Boolean = false, override val isLoading: Boolean = true, + val isLoadedUserInfo: Boolean = false, + val isLoadedNotificationSettings: Boolean = false, + val isAccountActionInFlight: Boolean = false, override val error: AppError? = null, ) : ContentLoadableState { + override val hasLoadedContent: Boolean + get() = isLoadedUserInfo && isLoadedNotificationSettings + override fun copyState( isLoading: Boolean, error: AppError?, diff --git a/feature/settings/src/main/java/com/twix/settings/navigation/SettingsNavGraph.kt b/feature/settings/src/main/java/com/twix/settings/navigation/SettingsNavGraph.kt index d37603d31..fde3e9927 100644 --- a/feature/settings/src/main/java/com/twix/settings/navigation/SettingsNavGraph.kt +++ b/feature/settings/src/main/java/com/twix/settings/navigation/SettingsNavGraph.kt @@ -1,17 +1,31 @@ package com.twix.settings.navigation +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import androidx.navigation.navigation +import androidx.navigation.compose.rememberNavController +import com.twix.designsystem.components.toast.ToastManager +import com.twix.designsystem.components.toast.model.ToastData import com.twix.navigation.NavRoutes import com.twix.navigation.base.NavGraphContributor -import com.twix.navigation.owner.rememberNavGraphOwner import com.twix.settings.SettingsRoute +import com.twix.settings.SettingsSideEffect import com.twix.settings.SettingsViewModel import com.twix.settings.about.SettingsAboutRoute import com.twix.settings.account.SettingsAccountRoute +import com.twix.settings.notification.SettingsNotificationRoute +import com.twix.ui.base.ObserveAsEvents import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject object SettingsNavGraph : NavGraphContributor { override val graphRoute: NavRoutes @@ -20,63 +34,106 @@ object SettingsNavGraph : NavGraphContributor { get() = NavRoutes.SettingsRoute.route override fun NavGraphBuilder.registerGraph(navController: NavHostController) { - navigation( - route = graphRoute.route, - startDestination = startDestination, - ) { - composable(NavRoutes.SettingsRoute.route) { entry -> - val graphEntry = - rememberNavGraphOwner( - navController = navController, - graphRoute = NavRoutes.SettingsGraph.route, - currentEntry = entry, - ) - val viewModel: SettingsViewModel = koinViewModel(viewModelStoreOwner = graphEntry) + composable(graphRoute.route) { + SettingsGraphHost(rootNavController = navController) + } + } +} - SettingsRoute( - viewModel = viewModel, - popBackStack = { navController.popBackStack() }, - navigateToSettingsAccount = { - navController.navigate(NavRoutes.SettingsAccountRoute.route) { - launchSingleTop = true - } - }, - navigateToSettingsAbout = { - navController.navigate(NavRoutes.SettingsAboutRoute.route) { - launchSingleTop = true - } - }, - ) - } +@Composable +private fun SettingsGraphHost( + rootNavController: NavHostController, + viewModel: SettingsViewModel = koinViewModel(), + toastManager: ToastManager = koinInject(), +) { + val settingsNavController = rememberNavController() + val context = LocalContext.current + val currentContext by rememberUpdatedState(context) - composable(NavRoutes.SettingsAccountRoute.route) { entry -> - val graphEntry = - rememberNavGraphOwner( - navController = navController, - graphRoute = NavRoutes.SettingsGraph.route, - currentEntry = entry, - ) - val viewModel: SettingsViewModel = koinViewModel(viewModelStoreOwner = graphEntry) + ObserveAsEvents(viewModel.sideEffect) { effect -> + when (effect) { + SettingsSideEffect.NavigateToLogin -> + rootNavController.navigate(NavRoutes.LoginRoute.route) { + launchSingleTop = true + popUpTo(NavRoutes.SettingsGraph.route) { + inclusive = true + } + } + is SettingsSideEffect.ShowToast -> + toastManager.show(ToastData(currentContext.getString(effect.resId), effect.type)) + } + } - SettingsAccountRoute( - viewModel = viewModel, - popBackStack = { navController.popBackStack() }, - navigateToLogin = { - navController.navigate(NavRoutes.LoginRoute.route) { - launchSingleTop = true - popUpTo(NavRoutes.SettingsRoute.route) { - inclusive = true - } - } - }, - ) - } + NavHost( + navController = settingsNavController, + startDestination = NavRoutes.SettingsRoute.route, + enterTransition = { + slideInHorizontally( + initialOffsetX = { fullWidth -> fullWidth }, + animationSpec = tween(NAVIGATION_ANIMATION_DURATION, easing = FastOutSlowInEasing), + ) + }, + exitTransition = { + slideOutHorizontally( + targetOffsetX = { fullWidth -> -fullWidth }, + animationSpec = tween(NAVIGATION_ANIMATION_DURATION, easing = FastOutSlowInEasing), + ) + }, + popEnterTransition = { + slideInHorizontally( + initialOffsetX = { fullWidth -> -fullWidth }, + animationSpec = tween(NAVIGATION_ANIMATION_DURATION, easing = FastOutSlowInEasing), + ) + }, + popExitTransition = { + slideOutHorizontally( + targetOffsetX = { fullWidth -> fullWidth }, + animationSpec = tween(NAVIGATION_ANIMATION_DURATION, easing = FastOutSlowInEasing), + ) + }, + ) { + composable(NavRoutes.SettingsRoute.route) { + SettingsRoute( + viewModel = viewModel, + popBackStack = { rootNavController.popBackStack() }, + navigateToSettingsAccount = { + settingsNavController.navigate(NavRoutes.SettingsAccountRoute.route) { + launchSingleTop = true + } + }, + navigateToSettingsAbout = { + settingsNavController.navigate(NavRoutes.SettingsAboutRoute.route) { + launchSingleTop = true + } + }, + navigateToSettingsNotification = { + settingsNavController.navigate(NavRoutes.SettingsNotificationRoute.route) { + launchSingleTop = true + } + }, + ) + } - composable(NavRoutes.SettingsAboutRoute.route) { - SettingsAboutRoute( - popBackStack = { navController.popBackStack() }, - ) - } + composable(NavRoutes.SettingsAccountRoute.route) { + SettingsAccountRoute( + viewModel = viewModel, + popBackStack = { settingsNavController.popBackStack() }, + ) + } + + composable(NavRoutes.SettingsAboutRoute.route) { + SettingsAboutRoute( + popBackStack = { settingsNavController.popBackStack() }, + ) + } + + composable(NavRoutes.SettingsNotificationRoute.route) { + SettingsNotificationRoute( + viewModel = viewModel, + popBackStack = { settingsNavController.popBackStack() }, + ) } } } + +private const val NAVIGATION_ANIMATION_DURATION = 300 diff --git a/feature/settings/src/main/java/com/twix/settings/notification/SettingsNotificationScreen.kt b/feature/settings/src/main/java/com/twix/settings/notification/SettingsNotificationScreen.kt new file mode 100644 index 000000000..5a4978bf8 --- /dev/null +++ b/feature/settings/src/main/java/com/twix/settings/notification/SettingsNotificationScreen.kt @@ -0,0 +1,174 @@ +package com.twix.settings.notification + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.twix.designsystem.R +import com.twix.designsystem.components.common.CommonSwitch +import com.twix.designsystem.components.error.ErrorScreen +import com.twix.designsystem.components.loading.TwixLoadingOverlay +import com.twix.designsystem.components.text.AppText +import com.twix.designsystem.components.topbar.CommonTopBar +import com.twix.designsystem.theme.CommonColor +import com.twix.designsystem.theme.GrayColor +import com.twix.designsystem.theme.TwixTheme +import com.twix.domain.model.enums.AppTextStyle +import com.twix.settings.SettingsIntent +import com.twix.settings.SettingsViewModel +import com.twix.settings.component.SettingsMenuFrame +import com.twix.settings.model.SettingsUiState +import com.twix.ui.extension.noRippleClickable + +@Composable +fun SettingsNotificationRoute( + viewModel: SettingsViewModel, + popBackStack: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + SettingsNotificationScreen( + uiState = uiState, + onBack = popBackStack, + onRetry = { viewModel.dispatch(SettingsIntent.Retry) }, + onPokeNotificationChange = { + viewModel.dispatch(SettingsIntent.SetPokeNotificationEnabled(it)) + }, + onMarketingNotificationChange = { + viewModel.dispatch(SettingsIntent.SetMarketingNotificationEnabled(it)) + }, + onNightMarketingNotificationChange = { + viewModel.dispatch(SettingsIntent.SetNightMarketingNotificationEnabled(it)) + }, + ) +} + +@Composable +private fun SettingsNotificationScreen( + uiState: SettingsUiState = SettingsUiState(), + onBack: () -> Unit = {}, + onRetry: () -> Unit = {}, + onPokeNotificationChange: (Boolean) -> Unit = {}, + onMarketingNotificationChange: (Boolean) -> Unit = {}, + onNightMarketingNotificationChange: (Boolean) -> Unit = {}, +) { + when { + uiState.showLoading -> TwixLoadingOverlay() + uiState.showError -> ErrorScreen(onClickRetry = onRetry, onClickBack = onBack) + else -> { + Column( + modifier = + Modifier + .fillMaxSize() + .background(CommonColor.White), + ) { + CommonTopBar( + title = stringResource(R.string.settings_notification), + left = { + Image( + painter = painterResource(R.drawable.ic_arrow3_left), + contentDescription = "back", + modifier = + Modifier + .padding(18.dp) + .noRippleClickable(onClick = onBack), + ) + }, + ) + + Spacer(Modifier.height(20.dp)) + + SettingsMenuFrame( + modifier = Modifier.padding(horizontal = 20.dp), + ) { + NotificationSettingItem( + title = stringResource(R.string.settings_poke_push_notification), + checked = uiState.pokeNotificationEnabled, + enabled = !uiState.notificationSettingsUpdating, + onCheckedChange = onPokeNotificationChange, + ) + + SettingsNotificationDivider() + + NotificationSettingItem( + title = stringResource(R.string.settings_marketing_push_notification), + checked = uiState.marketingNotificationEnabled, + enabled = !uiState.notificationSettingsUpdating, + onCheckedChange = onMarketingNotificationChange, + ) + + SettingsNotificationDivider() + + NotificationSettingItem( + title = stringResource(R.string.settings_night_marketing_push_notification), + checked = uiState.nightMarketingNotificationEnabled, + enabled = !uiState.notificationSettingsUpdating, + onCheckedChange = onNightMarketingNotificationChange, + ) + } + } + } + } +} + +@Composable +private fun NotificationSettingItem( + title: String, + checked: Boolean, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(60.dp) + .padding(start = 16.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppText( + text = title, + style = AppTextStyle.B1, + color = GrayColor.C500, + ) + + Spacer(Modifier.weight(1f)) + + CommonSwitch( + checked = checked, + enabled = enabled, + onClick = onCheckedChange, + ) + } +} + +@Composable +private fun SettingsNotificationDivider() { + HorizontalDivider( + thickness = 1.dp, + color = GrayColor.C500, + ) +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +private fun SettingsNotificationScreenPreview() { + TwixTheme { + SettingsNotificationScreen() + } +}