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()
+ }
+}