diff --git a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt index 40cb28a7a5..8e27e28a86 100644 --- a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt +++ b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt @@ -252,7 +252,13 @@ fun rememberSettingNavActions(appState: AppState): SettingNavActions = } override fun onNavigateToRecipientList() { - appState.navController.navigate(SettingRoute.RecipientListRoute) + appState.navController.navigate(SettingRoute.RecipientListRoute()) + } + + override fun onNavigateToRecipientListForDeliveryConditions() { + appState.navController.navigate( + SettingRoute.RecipientListRoute(selectForDeliveryConditions = true), + ) } override fun onRecipientListBack() { @@ -267,8 +273,16 @@ fun rememberSettingNavActions(appState: AppState): SettingNavActions = appState.navController.popBackStack() } - override fun onNavigateToAfterDelivery() { - appState.navController.navigate(SettingRoute.AfterDeliveryRoute) + override fun onNavigateToRecipientEdit(receiverId: Long) { + appState.navController.navigate(SettingRoute.RecipientEditRoute(receiverId)) + } + + override fun onRecipientEditBack() { + appState.navController.popBackStack() + } + + override fun onNavigateToAfterDelivery(receiverId: Long) { + appState.navController.navigate(SettingRoute.AfterDeliveryRoute(receiverId)) } override fun onAfterDeliveryBack() { diff --git a/app/src/test/java/com/afternote/afternote_fe/screen/HomeTabViewModelTest.kt b/app/src/test/java/com/afternote/afternote_fe/screen/HomeTabViewModelTest.kt index c8ccdb0a03..e9e7c9de71 100644 --- a/app/src/test/java/com/afternote/afternote_fe/screen/HomeTabViewModelTest.kt +++ b/app/src/test/java/com/afternote/afternote_fe/screen/HomeTabViewModelTest.kt @@ -7,8 +7,6 @@ import com.afternote.core.domain.repository.UserRepository import com.afternote.core.model.MindRecordCategory import com.afternote.core.model.delivery.DeliveryConditionItem import com.afternote.core.model.delivery.ReceiverDeliveryConditions -import com.afternote.core.model.user.DeliveryCondition -import com.afternote.core.model.user.DeliveryConditionType import com.afternote.core.model.user.Receiver import com.afternote.core.model.user.ReceiverCreated import com.afternote.core.model.user.ReceiverDetail @@ -406,14 +404,6 @@ private class FakeUserRepository : UserRepository { override suspend fun unlinkConnectedAccount(provider: String): UserConnectedAccount = unexpected("unlinkConnectedAccount") - override suspend fun getDeliveryCondition(): DeliveryCondition = unexpected("getDeliveryCondition") - - override suspend fun updateDeliveryCondition( - conditionType: DeliveryConditionType, - inactivityPeriodDays: Int?, - specificDate: String?, - ): DeliveryCondition = unexpected("updateDeliveryCondition") - override suspend fun getReceiverDeliveryConditions(receiverId: Long): ReceiverDeliveryConditions = unexpected("getReceiverDeliveryConditions") diff --git a/core/data/src/main/java/com/afternote/core/data/mapper/user/UserMapper.kt b/core/data/src/main/java/com/afternote/core/data/mapper/user/UserMapper.kt index 8502ae3a1e..2cdacecdf8 100644 --- a/core/data/src/main/java/com/afternote/core/data/mapper/user/UserMapper.kt +++ b/core/data/src/main/java/com/afternote/core/data/mapper/user/UserMapper.kt @@ -1,15 +1,11 @@ package com.afternote.core.data.mapper.user -import com.afternote.core.model.user.DeliveryCondition -import com.afternote.core.model.user.DeliveryConditionType import com.afternote.core.model.user.Receiver import com.afternote.core.model.user.ReceiverCreated import com.afternote.core.model.user.ReceiverDetail import com.afternote.core.model.user.User import com.afternote.core.model.user.UserConnectedAccount import com.afternote.core.model.user.UserPushSetting -import com.afternote.core.network.dto.DeliveryConditionDto -import com.afternote.core.network.dto.DeliveryConditionTypeDto import com.afternote.core.network.dto.ReceiverDetailDto import com.afternote.core.network.dto.ReceiverListDto import com.afternote.core.network.dto.UserConnectedAccountDto @@ -22,20 +18,6 @@ import com.afternote.core.network.dto.UserPushSettingDto // Enum Mapper // ======================================== -fun DeliveryConditionTypeDto.toDomain(): DeliveryConditionType = - when (this) { - DeliveryConditionTypeDto.NONE -> DeliveryConditionType.NONE - DeliveryConditionTypeDto.INACTIVITY -> DeliveryConditionType.INACTIVITY - DeliveryConditionTypeDto.SPECIFIC_DATE -> DeliveryConditionType.SPECIFIC_DATE - } - -fun DeliveryConditionType.toDto(): DeliveryConditionTypeDto = - when (this) { - DeliveryConditionType.NONE -> DeliveryConditionTypeDto.NONE - DeliveryConditionType.INACTIVITY -> DeliveryConditionTypeDto.INACTIVITY - DeliveryConditionType.SPECIFIC_DATE -> DeliveryConditionTypeDto.SPECIFIC_DATE - } - // ======================================== // Response Mapper (DTO → Domain) // ======================================== @@ -104,12 +86,3 @@ fun UserConnectedAccountDto.toDomain(): UserConnectedAccount = kakaoEmail = kakaoEmail, appleEmail = appleEmail, ) - -fun DeliveryConditionDto.toDomain(): DeliveryCondition = - DeliveryCondition( - conditionType = conditionType.toDomain(), - inactivityPeriodDays = inactivityPeriodDays, - specificDate = specificDate, - conditionFulfilled = conditionFulfilled, - conditionMet = conditionMet, - ) diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt index 2218220d51..2f56b0d9eb 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserRepositoryImpl.kt @@ -3,20 +3,16 @@ package com.afternote.core.data.repoimpl import android.util.Log import com.afternote.core.data.mapper.delivery.toRequestDto import com.afternote.core.data.mapper.user.toDomain -import com.afternote.core.data.mapper.user.toDto import com.afternote.core.domain.repository.UserRepository import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.model.delivery.DeliveryConditionItem import com.afternote.core.model.delivery.ReceiverDeliveryConditions -import com.afternote.core.model.user.DeliveryCondition -import com.afternote.core.model.user.DeliveryConditionType import com.afternote.core.model.user.Receiver import com.afternote.core.model.user.ReceiverCreated import com.afternote.core.model.user.ReceiverDetail import com.afternote.core.model.user.User import com.afternote.core.model.user.UserConnectedAccount import com.afternote.core.model.user.UserPushSetting -import com.afternote.core.network.dto.DeliveryConditionRequestDto import com.afternote.core.network.dto.SocialAccountLinkRequestDto import com.afternote.core.network.dto.UserCreateReceiverRequestDto import com.afternote.core.network.dto.UserPatchReceiverRequestDto @@ -203,27 +199,6 @@ class UserRepositoryImpl .requireData() .toDomain() - override suspend fun getDeliveryCondition(): DeliveryCondition = - userApiService - .getDeliveryCondition() - .requireData() - .toDomain() - - override suspend fun updateDeliveryCondition( - conditionType: DeliveryConditionType, - inactivityPeriodDays: Int?, - specificDate: String?, - ): DeliveryCondition = - userApiService - .updateDeliveryCondition( - DeliveryConditionRequestDto( - conditionType = conditionType.toDto(), - inactivityPeriodDays = inactivityPeriodDays, - specificDate = specificDate, - ), - ).requireData() - .toDomain() - override suspend fun getReceiverDeliveryConditions(receiverId: Long): ReceiverDeliveryConditions = userApiService .getReceiverDeliveryConditions(receiverId) diff --git a/core/data/src/test/java/com/afternote/core/data/mapper/user/UserMapperTest.kt b/core/data/src/test/java/com/afternote/core/data/mapper/user/UserMapperTest.kt deleted file mode 100644 index d12b1eece5..0000000000 --- a/core/data/src/test/java/com/afternote/core/data/mapper/user/UserMapperTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.afternote.core.data.mapper.user - -import com.afternote.core.model.user.DeliveryConditionType -import com.afternote.core.network.dto.DeliveryConditionTypeDto -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * [UserMapper] enum 매핑 회귀 가드. - * 서버 enum 추가/누락 시 컴파일은 통과해도 매핑이 비어 새 케이스가 도메인에 누락되는 사고를 막는다. - * (정의: `DeliveryConditionTypeDto` ↔ `DeliveryConditionType`) - */ -class UserMapperTest { - @Test - fun `DeliveryConditionTypeDto toDomain - 전체 케이스 매핑`() { - // enum 전체 순회 — 새 DTO 케이스 추가 시 when 분기 누락이 컴파일에서 실패. - // expected 산출: DTO 상수의 선언 이름(`dto.name`, 예: "NONE") 으로 Domain enum 의 동일 - // 이름 상수를 `valueOf` 조회. UserMapper.toDomain() 이 `when` 분기로 매핑한 결과가 - // 동일 이름끼리 짝지어지는지 검증 — 두 구현이 독립이라 자기검증이 아님. - DeliveryConditionTypeDto.entries.forEach { dto -> - val expected = DeliveryConditionType.valueOf(dto.name) - assertEquals("DTO $dto → Domain 매핑", expected, dto.toDomain()) - } - } - - @Test - fun `DeliveryConditionType toDto - 전체 케이스 매핑`() { - // 역방향. 위와 동일 패턴 — Domain 상수 이름으로 DTO enum 의 동일 이름 상수 조회 후 - // mapper 결과와 비교. - DeliveryConditionType.entries.forEach { domain -> - val expected = DeliveryConditionTypeDto.valueOf(domain.name) - assertEquals("Domain $domain → DTO 매핑", expected, domain.toDto()) - } - } - - @Test - fun `DeliveryConditionType 매핑은 양방향 round-trip 보존`() { - DeliveryConditionType.entries.forEach { domain -> - assertEquals(domain, domain.toDto().toDomain()) - } - DeliveryConditionTypeDto.entries.forEach { dto -> - assertEquals(dto, dto.toDomain().toDto()) - } - } - - @Test - fun `DeliveryConditionType domain - DTO enum 개수 동기화`() { - // 한 쪽에만 case 가 추가되면 round-trip 으로는 못 잡는 경우가 있어 명시적 카운트 확인. - assertEquals(DeliveryConditionTypeDto.entries.size, DeliveryConditionType.entries.size) - } -} diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/UserRepositoryImplTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/UserRepositoryImplTest.kt index 0ac0e38d3c..8ad8c05430 100644 --- a/core/data/src/test/java/com/afternote/core/data/repoimpl/UserRepositoryImplTest.kt +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/UserRepositoryImplTest.kt @@ -4,8 +4,6 @@ import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.model.Session import com.afternote.core.model.TokenBundle import com.afternote.core.model.user.Receiver -import com.afternote.core.network.dto.DeliveryConditionDto -import com.afternote.core.network.dto.DeliveryConditionRequestDto import com.afternote.core.network.dto.ReceiverDetailDto import com.afternote.core.network.dto.ReceiverListDto import com.afternote.core.network.dto.SocialAccountLinkRequestDto @@ -235,11 +233,6 @@ private class FakeUserApiService( override suspend fun unlinkConnectedAccount(provider: String): BaseResponse = TODO("이 테스트 미사용") - override suspend fun getDeliveryCondition(): BaseResponse = TODO("이 테스트 미사용") - - override suspend fun updateDeliveryCondition(request: DeliveryConditionRequestDto): BaseResponse = - TODO("이 테스트 미사용") - override suspend fun getReceiverDeliveryConditions(receiverId: Long): BaseResponse = TODO("이 테스트 미사용") override suspend fun updateReceiverDeliveryConditions( diff --git a/core/domain/src/main/java/com/afternote/core/domain/repository/UserRepository.kt b/core/domain/src/main/java/com/afternote/core/domain/repository/UserRepository.kt index cf03d35b99..7792c6d0ff 100644 --- a/core/domain/src/main/java/com/afternote/core/domain/repository/UserRepository.kt +++ b/core/domain/src/main/java/com/afternote/core/domain/repository/UserRepository.kt @@ -2,8 +2,6 @@ package com.afternote.core.domain.repository import com.afternote.core.model.delivery.DeliveryConditionItem import com.afternote.core.model.delivery.ReceiverDeliveryConditions -import com.afternote.core.model.user.DeliveryCondition -import com.afternote.core.model.user.DeliveryConditionType import com.afternote.core.model.user.Receiver import com.afternote.core.model.user.ReceiverCreated import com.afternote.core.model.user.ReceiverDetail @@ -83,16 +81,6 @@ interface UserRepository { // 소셜 계정 연결 해제 suspend fun unlinkConnectedAccount(provider: String): UserConnectedAccount - // 전달 조건 조회 - suspend fun getDeliveryCondition(): DeliveryCondition - - // 전달 조건 수정 - suspend fun updateDeliveryCondition( - conditionType: DeliveryConditionType, - inactivityPeriodDays: Int?, - specificDate: String?, - ): DeliveryCondition - // 수신자별 전달조건 조회 (콘텐츠별) suspend fun getReceiverDeliveryConditions(receiverId: Long): ReceiverDeliveryConditions diff --git a/core/model/src/main/kotlin/com/afternote/core/model/setting/SettingModel.kt b/core/model/src/main/kotlin/com/afternote/core/model/setting/SettingModel.kt index 6d48363fce..92c409ec4d 100644 --- a/core/model/src/main/kotlin/com/afternote/core/model/setting/SettingModel.kt +++ b/core/model/src/main/kotlin/com/afternote/core/model/setting/SettingModel.kt @@ -5,19 +5,3 @@ data class PushSettings( val mindRecord: Boolean, val afterNote: Boolean, ) - -enum class DeliveryConditionType { - NONE, - DEATH_CERTIFICATE, - INACTIVITY, - SPECIFIC_DATE, -} - -data class DeliveryCondition( - val conditionType: DeliveryConditionType, - val inactivityPeriodDays: Int?, - val specificDate: String?, - val leaveMessage: String? = null, - val conditionFulfilled: Boolean, - val conditionMet: Boolean, -) diff --git a/core/model/src/main/kotlin/com/afternote/core/model/user/UserModel.kt b/core/model/src/main/kotlin/com/afternote/core/model/user/UserModel.kt index e5282c889c..3e025e94fe 100644 --- a/core/model/src/main/kotlin/com/afternote/core/model/user/UserModel.kt +++ b/core/model/src/main/kotlin/com/afternote/core/model/user/UserModel.kt @@ -54,17 +54,3 @@ data class UserConnectedAccount( val kakaoEmail: String?, val appleEmail: String?, ) - -data class DeliveryCondition( - val conditionType: DeliveryConditionType, - val inactivityPeriodDays: Int?, - val specificDate: String?, - val conditionFulfilled: Boolean, - val conditionMet: Boolean, -) - -enum class DeliveryConditionType { - NONE, - INACTIVITY, - SPECIFIC_DATE, -} diff --git a/core/network/src/main/kotlin/com/afternote/core/network/dto/UserDto.kt b/core/network/src/main/kotlin/com/afternote/core/network/dto/UserDto.kt index e647112d76..5638be7448 100644 --- a/core/network/src/main/kotlin/com/afternote/core/network/dto/UserDto.kt +++ b/core/network/src/main/kotlin/com/afternote/core/network/dto/UserDto.kt @@ -3,18 +3,6 @@ package com.afternote.core.network.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -@Serializable -enum class DeliveryConditionTypeDto { - @SerialName("NONE") - NONE, - - @SerialName("INACTIVITY") - INACTIVITY, - - @SerialName("SPECIFIC_DATE") - SPECIFIC_DATE, -} - // ======================================== // Request // ======================================== @@ -60,13 +48,6 @@ data class SocialAccountLinkRequestDto( @SerialName("accessToken") val accessToken: String, ) -@Serializable -data class DeliveryConditionRequestDto( - @SerialName("conditionType") val conditionType: DeliveryConditionTypeDto, - @SerialName("inactivityPeriodDays") val inactivityPeriodDays: Int? = null, - @SerialName("specificDate") val specificDate: String? = null, -) - // ======================================== // Response // ======================================== @@ -137,15 +118,6 @@ data class UserConnectedAccountDto( @SerialName("appleEmail") val appleEmail: String? = null, ) -@Serializable -data class DeliveryConditionDto( - @SerialName("conditionType") val conditionType: DeliveryConditionTypeDto, - @SerialName("inactivityPeriodDays") val inactivityPeriodDays: Int? = null, - @SerialName("specificDate") val specificDate: String? = null, - @SerialName("conditionFulfilled") val conditionFulfilled: Boolean, - @SerialName("conditionMet") val conditionMet: Boolean, -) - // --- GET/POST/DELETE /users/connected-accounts (소셜 계정 연동) --- /** diff --git a/core/network/src/main/kotlin/com/afternote/core/network/service/UserApiService.kt b/core/network/src/main/kotlin/com/afternote/core/network/service/UserApiService.kt index b9a53ebeeb..3a5a6f26f6 100644 --- a/core/network/src/main/kotlin/com/afternote/core/network/service/UserApiService.kt +++ b/core/network/src/main/kotlin/com/afternote/core/network/service/UserApiService.kt @@ -1,7 +1,5 @@ package com.afternote.core.network.service -import com.afternote.core.network.dto.DeliveryConditionDto -import com.afternote.core.network.dto.DeliveryConditionRequestDto import com.afternote.core.network.dto.ReceiverDetailDto import com.afternote.core.network.dto.ReceiverListDto import com.afternote.core.network.dto.SocialAccountLinkRequestDto @@ -109,16 +107,6 @@ interface UserApiService { @Path("provider") provider: String, ): BaseResponse - // 전달 조건 조회 - @GET("users/delivery-condition") - suspend fun getDeliveryCondition(): BaseResponse - - // 전달 조건 수정 - @PATCH("users/delivery-condition") - suspend fun updateDeliveryCondition( - @Body request: DeliveryConditionRequestDto, - ): BaseResponse - /** * 수신자별 전달조건 조회 — 특정 수신자(receiverId)에게 **콘텐츠 종류마다** 다르게 건 전달 조건 목록. * diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupCard.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupCard.kt index bce82a7ba4..6400865cf9 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupCard.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/component/RadioGroupCard.kt @@ -64,8 +64,18 @@ fun RadioGroupCard( Column(verticalArrangement = spacedBy(4.dp)) { Text( text = item.title, - color = AfternoteDesign.colors.gray9, - style = AfternoteDesign.typography.primaryButton, + color = + if (selected) { + AfternoteDesign.colors.gray9 + } else { + AfternoteDesign.colors.gray8 + }, + style = + if (selected) { + AfternoteDesign.typography.primaryButton + } else { + AfternoteDesign.typography.bodyBase + }, ) Text( text = item.description, diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavActions.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavActions.kt index 00dc8037ef..44bef11258 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavActions.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavActions.kt @@ -29,13 +29,19 @@ interface SettingNavActions { fun onNavigateToRecipientList() + fun onNavigateToRecipientListForDeliveryConditions() + fun onRecipientListBack() fun onNavigateToRecipientRegister() fun onRecipientRegisterBack() - fun onNavigateToAfterDelivery() + fun onNavigateToRecipientEdit(receiverId: Long) + + fun onRecipientEditBack() + + fun onNavigateToAfterDelivery(receiverId: Long) fun onAfterDeliveryBack() diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavGraph.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavGraph.kt index eede44e1a8..c47d60b995 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavGraph.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavGraph.kt @@ -8,6 +8,7 @@ import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.navigation +import androidx.navigation.toRoute import com.afternote.core.ui.Route import com.afternote.feature.setting.presentation.component.PinSetupStep import com.afternote.feature.setting.presentation.screen.AppLockSetupScreen @@ -20,6 +21,7 @@ import com.afternote.feature.setting.presentation.screen.PassKeyPasswordScreen import com.afternote.feature.setting.presentation.screen.PassKeyScreen import com.afternote.feature.setting.presentation.screen.ProfileEditScreen import com.afternote.feature.setting.presentation.screen.PushNotificationScreen +import com.afternote.feature.setting.presentation.screen.ReceiverEditScreen import com.afternote.feature.setting.presentation.screen.ReceiverListScreen import com.afternote.feature.setting.presentation.screen.ReceiverRegisterScreen import com.afternote.feature.setting.presentation.screen.SettingScreen @@ -44,7 +46,9 @@ fun NavGraphBuilder.settingNavGraph( onNotificationClick = actions::onNavigateToNotification, onRecipientListClick = actions::onNavigateToRecipientList, onRecipientRegisterClick = actions::onNavigateToRecipientRegister, - onAfterDeliveryClick = actions::onNavigateToAfterDelivery, + onAfterDeliveryClick = { + actions.onNavigateToRecipientListForDeliveryConditions() + }, onPasskeyClick = actions::onNavigateToPasskey, onAppLockClick = actions::onNavigateToAppLock, onFaqClick = {}, @@ -101,12 +105,19 @@ fun NavGraphBuilder.settingNavGraph( } composable { + val route = it.toRoute() val viewModel: ReceiverListViewModel = hiltViewModel() val receivers by viewModel.receivers.collectAsStateWithLifecycle() ReceiverListScreen( receivers = receivers, onBackClick = actions::onRecipientListBack, - onConfirmClick = { _ -> actions.onRecipientListBack() }, + onConfirmClick = { receiver -> + if (route.selectForDeliveryConditions) { + actions.onNavigateToAfterDelivery(receiver.receiverId) + } else { + actions.onRecipientListBack() + } + }, ) } @@ -117,10 +128,21 @@ fun NavGraphBuilder.settingNavGraph( ) } + composable { + ReceiverEditScreen( + onBackClick = actions::onRecipientEditBack, + onEditSuccess = actions::onRecipientEditBack, + ) + } + composable { + val route = it.toRoute() DeliveryConditionScreen( onBack = actions::onAfterDeliveryBack, onSaveSuccess = actions::onAfterDeliveryBack, + onLastGreetingEditClick = { + actions.onNavigateToRecipientEdit(route.receiverId) + }, ) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRoute.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRoute.kt index de5a58ee39..898cb15077 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRoute.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingRoute.kt @@ -22,13 +22,22 @@ sealed interface SettingRoute { data object NotificationRoute : SettingRoute @Serializable - data object RecipientListRoute : SettingRoute + data class RecipientListRoute( + val selectForDeliveryConditions: Boolean = false, + ) : SettingRoute @Serializable data object RecipientRegisterRoute : SettingRoute @Serializable - data object AfterDeliveryRoute : SettingRoute + data class RecipientEditRoute( + val receiverId: Long, + ) : SettingRoute + + @Serializable + data class AfterDeliveryRoute( + val receiverId: Long, + ) : SettingRoute @Serializable data object PasskeyRoute : SettingRoute diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/DeliveryConditionScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/DeliveryConditionScreen.kt index 36f283f5bd..7c636c2ec9 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/DeliveryConditionScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/DeliveryConditionScreen.kt @@ -1,12 +1,16 @@ package com.afternote.feature.setting.presentation.screen +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable 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.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold @@ -16,44 +20,35 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.model.delivery.DeliveryConditionType import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R -import com.afternote.feature.setting.presentation.component.DatePickerBottomSheet import com.afternote.feature.setting.presentation.component.RadioGroup import com.afternote.feature.setting.presentation.component.RadioGroupItem +import com.afternote.feature.setting.presentation.viewmodel.DeliveryConditionError import com.afternote.feature.setting.presentation.viewmodel.DeliveryConditionUiState import com.afternote.feature.setting.presentation.viewmodel.DeliveryConditionViewModel -import com.afternote.feature.setting.presentation.viewmodel.ProcessingConditionOption -import java.time.format.DateTimeFormatter @Composable fun DeliveryConditionScreen( onBack: () -> Unit, onSaveSuccess: () -> Unit, + onLastGreetingEditClick: () -> Unit, viewModel: DeliveryConditionViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val currentOnSaveSuccess by rememberUpdatedState(onSaveSuccess) - val isFormValid = - when (uiState.processingOption) { - ProcessingConditionOption.INACTIVITY -> true - ProcessingConditionOption.SPECIFIC_DATE -> uiState.specificDate != null - ProcessingConditionOption.RECIPIENT_REQUEST -> false - } LaunchedEffect(Unit) { viewModel.saveSuccess.collect { currentOnSaveSuccess() } @@ -61,13 +56,9 @@ fun DeliveryConditionScreen( DeliveryConditionContent( uiState = uiState, - isFormValid = isFormValid, onBack = onBack, - onSelectDeliveryMethodIndex = viewModel::onSelectDeliveryMethodIndex, onConditionTypeSelect = viewModel::onConditionTypeSelected, - onDateSelect = viewModel::onDateSelected, - onDatePickerToggle = viewModel::onDatePickerToggle, - onFarewellMessageChange = viewModel::onFarewellMessageChange, + onLastGreetingEditClick = onLastGreetingEditClick, onSave = viewModel::onSave, ) } @@ -75,72 +66,36 @@ fun DeliveryConditionScreen( @Composable private fun DeliveryConditionContent( uiState: DeliveryConditionUiState, - isFormValid: Boolean, onBack: () -> Unit, - onSelectDeliveryMethodIndex: (Int) -> Unit, - onConditionTypeSelect: (ProcessingConditionOption) -> Unit, - onDateSelect: (java.time.LocalDate) -> Unit, - onDatePickerToggle: (Boolean) -> Unit, - onFarewellMessageChange: (String) -> Unit, + onConditionTypeSelect: (Int) -> Unit, + onLastGreetingEditClick: () -> Unit, onSave: () -> Unit, ) { - val farewellMessageState = rememberTextFieldState(initialText = uiState.farewellMessage) - val currentOnFarewellMessageChange by rememberUpdatedState(onFarewellMessageChange) - LaunchedEffect(farewellMessageState) { - snapshotFlow { farewellMessageState.text.toString() } - .collect { currentOnFarewellMessageChange(it) } - } - - val deliveryMethodItems = - listOf( - RadioGroupItem( - title = stringResource(R.string.delivery_method_auto_title), - description = stringResource(R.string.delivery_method_auto_description), - ), - RadioGroupItem( - title = stringResource(R.string.delivery_method_recipient_approval_title), - description = stringResource(R.string.delivery_method_recipient_approval_description), - ), - ) - val processingMethodItems = listOf( RadioGroupItem( title = stringResource(R.string.processing_method_inactive_title), description = stringResource(R.string.processing_method_inactive_description), ), - RadioGroupItem( - title = stringResource(R.string.processing_method_specific_date_title), - description = stringResource(R.string.processing_method_specific_date_description), - ), RadioGroupItem( title = stringResource(R.string.processing_method_recipient_request_title), description = stringResource(R.string.processing_method_recipient_request_description), ), ) - val processingMethodIndex = - when (uiState.processingOption) { - ProcessingConditionOption.INACTIVITY -> 0 - ProcessingConditionOption.SPECIFIC_DATE -> 1 - ProcessingConditionOption.RECIPIENT_REQUEST -> 2 - } - Scaffold( topBar = { DetailTopBar( title = stringResource(R.string.settings_recipient_after_delivery), onBackClick = onBack, actions = { - TextButton( - onClick = onSave, - enabled = isFormValid && !uiState.isSaving, - ) { + val isSaveEnabled = uiState.isInitialized && !uiState.isLoading && !uiState.isSaving + TextButton(onClick = onSave, enabled = isSaveEnabled) { Text( - text = "등록", + text = stringResource(R.string.delivery_condition_save), style = AfternoteDesign.typography.bodySmallB, color = - if (isFormValid && !uiState.isSaving) { + if (isSaveEnabled) { AfternoteDesign.colors.gray9 } else { AfternoteDesign.colors.gray2 @@ -160,80 +115,66 @@ private fun DeliveryConditionContent( .verticalScroll(rememberScrollState()) .padding(horizontal = 20.dp, vertical = 16.dp), ) { - SectionLabel(text = stringResource(R.string.delivery_method_section_title)) - Spacer(Modifier.height(12.dp)) - RadioGroup( - items = deliveryMethodItems, - selectedIndex = uiState.selectedDeliveryMethodIndex, - onSelectIndex = onSelectDeliveryMethodIndex, - ) - - Spacer(Modifier.height(28.dp)) - - SectionLabel(text = stringResource(R.string.processing_method_section_title)) - - if (uiState.processingOption == ProcessingConditionOption.SPECIFIC_DATE && uiState.specificDate != null) { - Spacer(Modifier.height(4.dp)) - val dateStr = uiState.specificDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd")) + val errorMessageRes = + when (uiState.error) { + DeliveryConditionError.LOAD_FAILED -> R.string.delivery_condition_load_error + DeliveryConditionError.SAVE_FAILED -> R.string.delivery_condition_save_error + null -> null + } + if (errorMessageRes != null) { Text( - text = - buildAnnotatedString { - withStyle(SpanStyle(color = AfternoteDesign.colors.b1)) { - append(dateStr) - } - withStyle(SpanStyle(color = AfternoteDesign.colors.gray6)) { - append("에 전달된 내용입니다.") - } - }, - style = AfternoteDesign.typography.captionLargeR, + text = stringResource(errorMessageRes), + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.error, ) + Spacer(Modifier.height(16.dp)) } - - Spacer(Modifier.height(12.dp)) + SectionLabel(text = stringResource(R.string.processing_method_section_title)) + Spacer(Modifier.height(28.dp)) RadioGroup( items = processingMethodItems, - selectedIndex = processingMethodIndex, - onSelectIndex = { index -> - val option = - when (index) { - 0 -> ProcessingConditionOption.INACTIVITY - 1 -> ProcessingConditionOption.SPECIFIC_DATE - 2 -> ProcessingConditionOption.RECIPIENT_REQUEST - else -> ProcessingConditionOption.INACTIVITY - } - onConditionTypeSelect(option) - }, + selectedIndex = if (uiState.conditionType == DeliveryConditionType.INACTIVITY) 0 else 1, + onSelectIndex = onConditionTypeSelect, ) - Spacer(Modifier.height(28.dp)) - - SectionLabel(text = stringResource(R.string.farewell_message_section_title)) - Spacer(Modifier.height(12.dp)) - AfternoteTextField( - state = farewellMessageState, - placeholder = stringResource(R.string.farewell_message_hint), + Spacer(Modifier.height(32.dp)) + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onLastGreetingEditClick), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.last_greeting_edit_section_title), + style = AfternoteDesign.typography.bodyBase, + color = AfternoteDesign.colors.gray9, + ) + Spacer(Modifier.weight(1f)) + Image( + painter = painterResource(R.drawable.ic_right_arrow), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } + Spacer(Modifier.height(32.dp)) + HorizontalDivider( + thickness = 0.8.dp, + color = AfternoteDesign.colors.gray3, ) - Spacer(modifier = Modifier.height(32.dp)) - HorizontalDivider(thickness = 0.8.dp, color = AfternoteDesign.colors.gray3) - Spacer(modifier = Modifier.height(24.dp)) + Spacer(Modifier.height(24.dp)) Text( - text = stringResource(R.string.after_delivery_notice_1), + text = stringResource(R.string.after_delivery_notice_condition), style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray9, ) Text( - text = stringResource(R.string.after_delivery_notice_2), + text = stringResource(R.string.after_delivery_notice_safety), style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray9, ) } } - - if (uiState.isDatePickerVisible) { - DatePickerBottomSheet( - onDismiss = { onDatePickerToggle(false) }, - onDateSelect = onDateSelect, - initialDate = uiState.specificDate, - ) - } } @Composable @@ -255,13 +196,9 @@ private fun DeliveryConditionContentPreview() { AfternoteTheme { DeliveryConditionContent( uiState = DeliveryConditionUiState(), - isFormValid = true, onBack = {}, - onSelectDeliveryMethodIndex = {}, onConditionTypeSelect = {}, - onDateSelect = {}, - onDatePickerToggle = {}, - onFarewellMessageChange = {}, + onLastGreetingEditClick = {}, onSave = {}, ) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverEditScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverEditScreen.kt new file mode 100644 index 0000000000..45d64d2f0b --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverEditScreen.kt @@ -0,0 +1,85 @@ +package com.afternote.feature.setting.presentation.screen + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.setting.presentation.viewmodel.ReceiverEditEvent +import com.afternote.feature.setting.presentation.viewmodel.ReceiverEditViewModel + +@Composable +fun ReceiverEditScreen( + onBackClick: () -> Unit, + onEditSuccess: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ReceiverEditViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val currentOnEditSuccess by rememberUpdatedState(onEditSuccess) + + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + when (event) { + ReceiverEditEvent.EditSuccess -> currentOnEditSuccess() + } + } + } + + val receiver = uiState.receiver + if (receiver != null) { + ReceiverRegisterContent( + title = "수신자 수정", + actionText = "수정", + isLoading = uiState.isSaving, + errorMessage = uiState.errorMessage, + onBackClick = onBackClick, + onRegister = viewModel::update, + modifier = modifier, + initialName = receiver.name, + initialRelation = receiver.relation, + initialPhone = receiver.phone.orEmpty(), + initialEmail = receiver.email.orEmpty(), + initialMessage = receiver.message.orEmpty(), + ) + } else { + Scaffold( + modifier = modifier, + topBar = { + DetailTopBar( + title = "수신자 수정", + onBackClick = onBackClick, + ) + }, + ) { innerPadding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + if (uiState.isLoading) { + CircularProgressIndicator(color = AfternoteDesign.colors.gray9) + } else { + Text( + text = uiState.errorMessage.orEmpty(), + style = AfternoteDesign.typography.bodyBase, + color = AfternoteDesign.colors.error, + ) + } + } + } + } +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt index 15a964da22..7d7918a810 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/screen/ReceiverRegisterScreen.kt @@ -2,9 +2,9 @@ package com.afternote.feature.setting.presentation.screen import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -14,11 +14,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -35,18 +35,22 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color 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.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.AfternoteTextField import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.setting.presentation.R import com.afternote.feature.setting.presentation.viewmodel.ReceiverRegisterEvent import com.afternote.feature.setting.presentation.viewmodel.ReceiverRegisterViewModel import com.afternote.core.ui.R as CoreR -private val relationOptions = listOf("어머니", "아버지", "아들", "딸", "직접 추가하기") +private const val CUSTOM_RELATION_OPTION = "직접 추가하기" +private val relationOptions = listOf("어머니", "아버지", "아들", "딸", CUSTOM_RELATION_OPTION) @Composable fun ReceiverRegisterScreen( @@ -56,13 +60,6 @@ fun ReceiverRegisterScreen( viewModel: ReceiverRegisterViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val nameState = rememberTextFieldState() - val phoneState = rememberTextFieldState() - val emailState = rememberTextFieldState() - var selectedRelation by remember { mutableStateOf(null) } - var relationExpanded by remember { mutableStateOf(false) } - - val isFormValid = nameState.text.isNotBlank() && selectedRelation != null val currentOnRegisterSuccess by rememberUpdatedState(onRegisterSuccess) LaunchedEffect(Unit) { @@ -73,24 +70,76 @@ fun ReceiverRegisterScreen( } } + ReceiverRegisterContent( + title = "수신자 등록", + actionText = "등록", + isLoading = uiState.isLoading, + errorMessage = uiState.errorMessage, + onBackClick = onBackClick, + onRegister = viewModel::register, + modifier = modifier, + ) +} + +@Composable +internal fun ReceiverRegisterContent( + title: String, + actionText: String, + isLoading: Boolean, + errorMessage: String?, + onBackClick: () -> Unit, + onRegister: (name: String, relation: String, phone: String, email: String, message: String) -> Unit, + modifier: Modifier = Modifier, + initialName: String = "", + initialRelation: String = "", + initialPhone: String = "", + initialEmail: String = "", + initialMessage: String = "", +) { + val isPresetRelation = initialRelation in relationOptions + val nameState = rememberTextFieldState(initialText = initialName) + val phoneState = rememberTextFieldState(initialText = initialPhone) + val emailState = rememberTextFieldState(initialText = initialEmail) + val messageState = rememberTextFieldState(initialText = initialMessage) + val customRelationState = rememberTextFieldState(initialText = initialRelation.takeUnless { isPresetRelation }.orEmpty()) + var selectedRelation by + remember(initialRelation) { + mutableStateOf( + when { + initialRelation.isBlank() -> null + isPresetRelation -> initialRelation + else -> CUSTOM_RELATION_OPTION + }, + ) + } + var relationExpanded by remember { mutableStateOf(false) } + + val relation = + when (selectedRelation) { + CUSTOM_RELATION_OPTION -> customRelationState.text.toString().trim() + else -> selectedRelation.orEmpty() + } + val isFormValid = nameState.text.isNotBlank() && relation.isNotBlank() + Scaffold( modifier = modifier, containerColor = Color.Transparent, topBar = { DetailTopBar( - title = "수신자 등록", + title = title, onBackClick = onBackClick, actions = { TextButton( onClick = { - viewModel.register( - name = nameState.text.toString(), - relation = selectedRelation!!, - phone = phoneState.text.toString(), - email = emailState.text.toString(), + onRegister( + nameState.text.toString(), + relation, + phoneState.text.toString(), + emailState.text.toString(), + messageState.text.toString(), ) }, - enabled = isFormValid && !uiState.isLoading, + enabled = isFormValid && !isLoading, colors = ButtonDefaults.textButtonColors( contentColor = AfternoteDesign.colors.gray9, @@ -98,7 +147,7 @@ fun ReceiverRegisterScreen( ), ) { Text( - text = "등록", + text = actionText, style = AfternoteDesign.typography.bodyLargeB, ) } @@ -154,32 +203,40 @@ fun ReceiverRegisterScreen( item { Spacer(modifier = Modifier.height(24.dp)) Text("관계", modifier = Modifier.fillMaxWidth()) - val shape = RoundedCornerShape(8.dp) Box(modifier = Modifier.fillMaxWidth()) { - Row( + Column( modifier = Modifier .fillMaxWidth() - .background(AfternoteDesign.colors.white, shape) - .border(1.dp, AfternoteDesign.colors.gray2, shape) .clickable { relationExpanded = true } - .padding(horizontal = 24.dp, vertical = 13.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(top = 13.dp), ) { - Text( - text = selectedRelation ?: "관계를 선택하세요", - style = AfternoteDesign.typography.textField, - color = if (selectedRelation != null) AfternoteDesign.colors.gray9 else AfternoteDesign.colors.gray4, - modifier = Modifier.weight(1f), - ) - Icon( - painter = painterResource(CoreR.drawable.core_ui_arrowdown), - contentDescription = null, - modifier = - Modifier - .size(18.dp) - .rotate(if (relationExpanded) 180f else 0f), - tint = AfternoteDesign.colors.gray6, + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = selectedRelation ?: "관계를 선택하세요", + style = AfternoteDesign.typography.bodyBase, + color = + if (selectedRelation == null) { + AfternoteDesign.colors.gray4 + } else { + AfternoteDesign.colors.gray9 + }, + ) + Spacer(modifier = Modifier.weight(1f)) + Icon( + painter = painterResource(CoreR.drawable.core_ui_arrowdown), + contentDescription = null, + modifier = + Modifier + .size(18.dp) + .rotate(if (relationExpanded) 180f else 0f), + tint = AfternoteDesign.colors.gray6, + ) + } + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider( + thickness = 0.8.dp, + color = AfternoteDesign.colors.gray3, ) } DropdownMenu( @@ -207,6 +264,13 @@ fun ReceiverRegisterScreen( } } } + if (selectedRelation == CUSTOM_RELATION_OPTION) { + Spacer(modifier = Modifier.height(8.dp)) + AfternoteTextField( + state = customRelationState, + placeholder = stringResource(R.string.receiver_custom_relation_placeholder), + ) + } } item { Spacer(modifier = Modifier.height(24.dp)) @@ -216,6 +280,43 @@ fun ReceiverRegisterScreen( placeholder = "afternote@email.com", ) } + item { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.receiver_last_greeting_label), + modifier = Modifier.fillMaxWidth(), + ) + AfternoteTextField( + state = messageState, + placeholder = stringResource(R.string.receiver_last_greeting_placeholder), + ) + } + if (errorMessage != null) { + item { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = errorMessage, + modifier = Modifier.fillMaxWidth(), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.error, + ) + } + } } } } + +@Preview(showBackground = true) +@Composable +private fun ReceiverRegisterContentPreview() { + AfternoteTheme { + ReceiverRegisterContent( + title = "수신자 등록", + actionText = "등록", + isLoading = false, + errorMessage = null, + onBackClick = {}, + onRegister = { _, _, _, _, _ -> }, + ) + } +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/DeliveryConditionUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/DeliveryConditionUiState.kt index 97c7b5a4bd..4d3b5e37e7 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/DeliveryConditionUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/DeliveryConditionUiState.kt @@ -1,16 +1,21 @@ package com.afternote.feature.setting.presentation.viewmodel -import java.time.LocalDate - -enum class ProcessingConditionOption { INACTIVITY, SPECIFIC_DATE, RECIPIENT_REQUEST } +import com.afternote.core.model.delivery.DeliveryConditionItem +import com.afternote.core.model.delivery.DeliveryConditionType +import com.afternote.core.model.delivery.DeliveryContentType +import com.afternote.core.model.delivery.InactivityPeriod data class DeliveryConditionUiState( val isLoading: Boolean = false, - val selectedDeliveryMethodIndex: Int = 0, - val processingOption: ProcessingConditionOption = ProcessingConditionOption.INACTIVITY, - val specificDate: LocalDate? = null, - val isDatePickerVisible: Boolean = false, - val errorMessage: String? = null, + val isInitialized: Boolean = false, + val conditionType: DeliveryConditionType = DeliveryConditionType.INACTIVITY, + val inactivityPeriod: InactivityPeriod = InactivityPeriod.ONE_YEAR, + val conditions: List = emptyList(), + val error: DeliveryConditionError? = null, val isSaving: Boolean = false, - val farewellMessage: String = "", ) + +enum class DeliveryConditionError { + LOAD_FAILED, + SAVE_FAILED, +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/DeliveryConditionViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/DeliveryConditionViewModel.kt index 9e4acb2b5e..13ec93769f 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/DeliveryConditionViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/DeliveryConditionViewModel.kt @@ -1,11 +1,18 @@ package com.afternote.feature.setting.presentation.viewmodel +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserRepository -import com.afternote.core.model.user.DeliveryConditionType +import com.afternote.core.model.delivery.ConditionState +import com.afternote.core.model.delivery.DeliveryConditionItem +import com.afternote.core.model.delivery.DeliveryConditionType +import com.afternote.core.model.delivery.DeliveryContentType +import com.afternote.core.model.delivery.InactivityPeriod +import com.afternote.feature.setting.presentation.navigation.SettingRoute import dagger.hilt.android.lifecycle.HiltViewModel -import jakarta.inject.Inject import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -13,14 +20,17 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import java.time.LocalDate +import javax.inject.Inject @HiltViewModel class DeliveryConditionViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, private val userRepository: UserRepository, ) : ViewModel() { + private val receiverId = savedStateHandle.toRoute().receiverId + private val _uiState = MutableStateFlow(DeliveryConditionUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -28,82 +38,95 @@ class DeliveryConditionViewModel val saveSuccess = _saveSuccess.receiveAsFlow() init { - loadDeliveryCondition() + loadDeliveryConditions() } - private fun loadDeliveryCondition() { + private fun loadDeliveryConditions() { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } - runCatching { userRepository.getDeliveryCondition() } - .onSuccess { condition -> - val option = - when (condition.conditionType) { - DeliveryConditionType.INACTIVITY -> ProcessingConditionOption.INACTIVITY - DeliveryConditionType.SPECIFIC_DATE -> ProcessingConditionOption.SPECIFIC_DATE - DeliveryConditionType.NONE -> ProcessingConditionOption.INACTIVITY + runCatchingCancellable { userRepository.getReceiverDeliveryConditions(receiverId) } + .onSuccess { response -> + val representative = + response.conditions.firstOrNull { + it.contentType == DeliveryContentType.TIME_LETTER } _uiState.update { it.copy( isLoading = false, - processingOption = option, - specificDate = condition.specificDate?.let { date -> LocalDate.parse(date) }, + isInitialized = true, + conditionType = representative?.conditionType ?: DeliveryConditionType.INACTIVITY, + inactivityPeriod = representative?.inactivityPeriod ?: InactivityPeriod.ONE_YEAR, + conditions = response.conditions, ) } }.onFailure { - _uiState.update { it.copy(isLoading = false) } + _uiState.update { it.copy(isLoading = false, error = DeliveryConditionError.LOAD_FAILED) } } } } - fun onSelectDeliveryMethodIndex(index: Int) = _uiState.update { it.copy(selectedDeliveryMethodIndex = index) } - - fun onConditionTypeSelected(option: ProcessingConditionOption) { - _uiState.update { - it.copy( - processingOption = option, - specificDate = if (option != ProcessingConditionOption.SPECIFIC_DATE) null else it.specificDate, - isDatePickerVisible = option == ProcessingConditionOption.SPECIFIC_DATE, - ) - } - } - - fun onDateSelected(date: LocalDate) { - _uiState.update { it.copy(specificDate = date, isDatePickerVisible = false) } - } - - fun onDatePickerToggle(visible: Boolean) { - _uiState.update { it.copy(isDatePickerVisible = visible) } + fun onConditionTypeSelected(index: Int) { + val conditionType = + if (index == 1) DeliveryConditionType.RECEIVER_REQUEST else DeliveryConditionType.INACTIVITY + _uiState.update { it.copy(conditionType = conditionType) } } - fun onFarewellMessageChange(message: String) = _uiState.update { it.copy(farewellMessage = message) } - fun onSave() { val state = _uiState.value - val conditionType = - when (state.processingOption) { - ProcessingConditionOption.INACTIVITY -> DeliveryConditionType.INACTIVITY - ProcessingConditionOption.SPECIFIC_DATE -> DeliveryConditionType.SPECIFIC_DATE - ProcessingConditionOption.RECIPIENT_REQUEST -> return - } + if (!state.isInitialized || state.isSaving) return + + val hasTimeLetterCondition = + state.conditions.any { it.contentType == DeliveryContentType.TIME_LETTER } + val updatedConditions = + state.conditions + .map { condition -> + if (condition.contentType == DeliveryContentType.TIME_LETTER) { + condition.copy( + conditionType = state.conditionType, + inactivityPeriod = + state.inactivityPeriod.takeIf { + state.conditionType == DeliveryConditionType.INACTIVITY + }, + ) + } else { + condition + } + }.let { conditions -> + if (hasTimeLetterCondition) { + conditions + } else { + conditions + + defaultCondition(DeliveryContentType.TIME_LETTER).copy( + conditionType = state.conditionType, + inactivityPeriod = + state.inactivityPeriod.takeIf { + state.conditionType == DeliveryConditionType.INACTIVITY + }, + ) + } + } + viewModelScope.launch { _uiState.update { it.copy(isSaving = true) } - runCatching { - userRepository.updateDeliveryCondition( - conditionType = conditionType, - inactivityPeriodDays = if (state.processingOption == ProcessingConditionOption.INACTIVITY) 365 else null, - specificDate = - if (state.processingOption == ProcessingConditionOption.SPECIFIC_DATE) { - state.specificDate?.toString() - } else { - null - }, - ) - }.onSuccess { - _uiState.update { it.copy(isSaving = false) } + runCatchingCancellable { + userRepository.updateReceiverDeliveryConditions(receiverId, updatedConditions) + }.onSuccess { response -> + _uiState.update { it.copy(isSaving = false, conditions = response.conditions) } _saveSuccess.send(Unit) - }.onFailure { e -> - _uiState.update { it.copy(isSaving = false, errorMessage = e.message) } + }.onFailure { + _uiState.update { it.copy(isSaving = false, error = DeliveryConditionError.SAVE_FAILED) } } } } + + private fun defaultCondition(contentType: DeliveryContentType) = + DeliveryConditionItem( + contentType = contentType, + conditionType = DeliveryConditionType.INACTIVITY, + inactivityPeriod = InactivityPeriod.ONE_YEAR, + state = ConditionState.ACTIVE, + fulfilled = false, + gracePeriodStartedAt = null, + fulfilledAt = null, + ) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverEditUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverEditUiState.kt new file mode 100644 index 0000000000..cf72523795 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverEditUiState.kt @@ -0,0 +1,14 @@ +package com.afternote.feature.setting.presentation.viewmodel + +import com.afternote.core.model.user.ReceiverDetail + +data class ReceiverEditUiState( + val isLoading: Boolean = true, + val isSaving: Boolean = false, + val receiver: ReceiverDetail? = null, + val errorMessage: String? = null, +) + +sealed interface ReceiverEditEvent { + data object EditSuccess : ReceiverEditEvent +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverEditViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverEditViewModel.kt new file mode 100644 index 0000000000..6eab069269 --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverEditViewModel.kt @@ -0,0 +1,97 @@ +package com.afternote.feature.setting.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.core.domain.repository.UserRepository +import com.afternote.feature.setting.presentation.navigation.SettingRoute +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ReceiverEditViewModel + @Inject + constructor( + savedStateHandle: SavedStateHandle, + private val userRepository: UserRepository, + ) : ViewModel() { + private val receiverId = savedStateHandle.toRoute().receiverId + + private val _uiState = MutableStateFlow(ReceiverEditUiState()) + val uiState = _uiState.asStateFlow() + + private val _events = Channel(Channel.BUFFERED) + val events = _events.receiveAsFlow() + + init { + loadReceiver() + } + + private fun loadReceiver() { + viewModelScope.launch { + runCatchingCancellable { userRepository.getReceiverDetail(receiverId) } + .onSuccess { receiver -> + _uiState.update { it.copy(isLoading = false, receiver = receiver) } + }.onFailure { + _uiState.update { + it.copy(isLoading = false, errorMessage = "수신자 정보를 불러오지 못했습니다.") + } + } + } + } + + fun update( + name: String, + relation: String, + phone: String, + email: String, + message: String, + ) { + if (_uiState.value.isSaving) return + + _uiState.update { it.copy(isSaving = true, errorMessage = null) } + viewModelScope.launch { + val receiverUpdateResult = + runCatchingCancellable { + userRepository.updateReceiver( + receiverId = receiverId, + name = name, + phone = phone, + relation = relation, + email = email, + ) + } + if (receiverUpdateResult.isFailure) { + _uiState.update { + it.copy(isSaving = false, errorMessage = "수신자 수정에 실패했습니다.") + } + return@launch + } + + runCatchingCancellable { + userRepository.updateReceiverMessage( + receiverId = receiverId, + message = message, + ) + }.onSuccess { + _uiState.update { it.copy(isSaving = false) } + _events.send(ReceiverEditEvent.EditSuccess) + }.onFailure { + _uiState.update { + it.copy( + isSaving = false, + errorMessage = "기본 정보는 수정됐지만 마지막 인사말 수정에 실패했습니다.", + ) + } + } + } + } + } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModel.kt index 0e00ac124c..546ebceda0 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/viewmodel/ReceiverRegisterViewModel.kt @@ -29,6 +29,7 @@ class ReceiverRegisterViewModel relation: String, phone: String?, email: String?, + message: String?, ) { _uiState.update { it.copy(isLoading = true, errorMessage = null) } viewModelScope.launch { @@ -38,7 +39,7 @@ class ReceiverRegisterViewModel relation = relation, phone = phone?.takeIf { it.isNotBlank() }, email = email?.takeIf { it.isNotBlank() }, - message = null, + message = message?.takeIf { it.isNotBlank() }, ) }.onSuccess { _events.send(ReceiverRegisterEvent.RegisterSuccess) diff --git a/feature/setting/presentation/src/main/res/values/strings.xml b/feature/setting/presentation/src/main/res/values/strings.xml index b38a040867..865dce9902 100644 --- a/feature/setting/presentation/src/main/res/values/strings.xml +++ b/feature/setting/presentation/src/main/res/values/strings.xml @@ -130,31 +130,26 @@ 패스키 목록 - 정보 전달 방법 - - 자동 전달 - 설정된 조건 충족 시, 별도의 요청 없이 지정한 수신자에게 자동으로 전달됩니다. - - 수신자 승인 후 전달 - 수신자의 요청과 확인 절차를 거친 후, 안전하게 정보가 전달됩니다. - 정보 처리 방법 일정 기간 앱 미사용 시 1년 이상의 기간 동안 앱 사용이 없을 경우 전달 조건이 자동으로 충족됩니다. - 특정 날짜에 전달 - 미리 정한 날짜에 맞추어 정보가 자동으로 전달됩니다. - 수신자 요청 이후 수신자의 요청이 접수된 후, 확인 절차를 거쳐 전달이 진행됩니다. - - 마지막 인삿말 - 마지막 인삿말을 입력해주세요 + + 마지막 인사말 수정하기 + + + • 해당 정보는 지정된 조건이 충족 시에만 전달됩니다. + • 예기치 않은 상황에서도 설정된 방식에 따라 안전하게 전달됩니다. - - 해당 정보는 지정된 조건이 충족 시에만 전달됩니다. - 예기치 않은 상황에서도 설정된 방식에 따라 안전하게 전달됩니다. + 저장 + 전달 조건을 불러오지 못했습니다. + 전달 조건을 저장하지 못했습니다. + 마지막 인사말 + 마지막 인사말을 입력해주세요 + 관계를 직접 입력해주세요 diff --git a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/dto/TimeLetterDto.kt b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/dto/TimeLetterDto.kt index 74cd7211b3..7cb0c18803 100644 --- a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/dto/TimeLetterDto.kt +++ b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/dto/TimeLetterDto.kt @@ -15,6 +15,15 @@ enum class TimeLetterStatusDto { SENT, } +@Serializable +enum class TimeLetterDeliveryModeDto { + @SerialName("DATE") + DATE, + + @SerialName("POST_DEATH") + POST_DEATH, +} + @Serializable enum class TimeLetterBlockTypeDto { @SerialName("TEXT") @@ -37,9 +46,9 @@ enum class TimeLetterBlockTypeDto { data class TimeLetterBlockRequestDto( @SerialName("blockType") val blockType: TimeLetterBlockTypeDto, @SerialName("blockOrder") val blockOrder: Int, - @SerialName("textContent") val textContent: String? = null, - @SerialName("url") val url: String? = null, - @SerialName("mimeType") val mimeType: String? = null, + @SerialName("textContent") val textContent: String?, + @SerialName("url") val url: String?, + @SerialName("mimeType") val mimeType: String?, ) @Serializable @@ -47,26 +56,28 @@ data class TimeLetterBlockDto( @SerialName("id") val id: Long, @SerialName("blockType") val blockType: TimeLetterBlockTypeDto, @SerialName("blockOrder") val blockOrder: Int, - @SerialName("textContent") val textContent: String? = null, - @SerialName("url") val url: String? = null, - @SerialName("mimeType") val mimeType: String? = null, + @SerialName("textContent") val textContent: String?, + @SerialName("url") val url: String?, + @SerialName("mimeType") val mimeType: String?, ) @Serializable data class TimeLetterCreateRequestDto( - @SerialName("title") val title: String? = null, - @SerialName("sendAt") val sendAt: String? = null, + @SerialName("title") val title: String?, + @SerialName("sendAt") val sendAt: String?, + @SerialName("deliveryMode") val deliveryMode: TimeLetterDeliveryModeDto, @SerialName("status") val status: TimeLetterStatusDto, - @SerialName("blocks") val blocks: List = emptyList(), - @SerialName("receiverIds") val receiverIds: List = emptyList(), + @SerialName("blocks") val blocks: List, + @SerialName("receiverIds") val receiverIds: List, ) @Serializable data class TimeLetterUpdateRequestDto( - @SerialName("title") val title: String? = null, - @SerialName("sendAt") val sendAt: String? = null, - @SerialName("status") val status: TimeLetterStatusDto? = null, - @SerialName("blocks") val blocks: List = emptyList(), + @SerialName("title") val title: String?, + @SerialName("sendAt") val sendAt: String?, + @SerialName("deliveryMode") val deliveryMode: TimeLetterDeliveryModeDto?, + @SerialName("status") val status: TimeLetterStatusDto?, + @SerialName("blocks") val blocks: List, ) @Serializable @@ -77,12 +88,12 @@ data class TimeLetterDeleteRequestDto( @Serializable data class TimeLetterDto( @SerialName("id") val id: Long, - @SerialName("title") val title: String? = null, - @SerialName("sendAt") val sendAt: String? = null, - @SerialName("deliveredAt") val deliveredAt: String? = null, + @SerialName("title") val title: String?, + @SerialName("sendAt") val sendAt: String?, + @SerialName("deliveredAt") val deliveredAt: String?, @SerialName("status") val status: TimeLetterStatusDto, - @SerialName("blocks") val blocks: List = emptyList(), - @SerialName("receiverIds") val receiverIds: List = emptyList(), + @SerialName("blocks") val blocks: List, + @SerialName("receiverIds") val receiverIds: List, ) @Serializable diff --git a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/mapper/TimeLetterMapper.kt b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/mapper/TimeLetterMapper.kt index 63b8552ec0..24b6a37ce3 100644 --- a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/mapper/TimeLetterMapper.kt +++ b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/mapper/TimeLetterMapper.kt @@ -3,6 +3,7 @@ package com.afternote.feature.timeletter.data.mapper import com.afternote.feature.timeletter.data.dto.TimeLetterBlockDto import com.afternote.feature.timeletter.data.dto.TimeLetterBlockRequestDto import com.afternote.feature.timeletter.data.dto.TimeLetterBlockTypeDto +import com.afternote.feature.timeletter.data.dto.TimeLetterDeliveryModeDto import com.afternote.feature.timeletter.data.dto.TimeLetterDto import com.afternote.feature.timeletter.data.dto.TimeLetterListDto import com.afternote.feature.timeletter.data.dto.TimeLetterStatusDto @@ -10,6 +11,7 @@ import com.afternote.feature.timeletter.domain.model.NewTimeLetterBlock import com.afternote.feature.timeletter.domain.model.TimeLetter import com.afternote.feature.timeletter.domain.model.TimeLetterBlock import com.afternote.feature.timeletter.domain.model.TimeLetterBlockType +import com.afternote.feature.timeletter.domain.model.TimeLetterDeliveryMode import com.afternote.feature.timeletter.domain.model.TimeLetterList import com.afternote.feature.timeletter.domain.model.TimeLetterStatus @@ -27,6 +29,12 @@ fun TimeLetterStatus.toDto(): TimeLetterStatusDto = TimeLetterStatus.SENT -> TimeLetterStatusDto.SENT } +fun TimeLetterDeliveryMode.toDto(): TimeLetterDeliveryModeDto = + when (this) { + TimeLetterDeliveryMode.DATE -> TimeLetterDeliveryModeDto.DATE + TimeLetterDeliveryMode.POST_DEATH -> TimeLetterDeliveryModeDto.POST_DEATH + } + fun TimeLetterBlockTypeDto.toDomain(): TimeLetterBlockType = when (this) { TimeLetterBlockTypeDto.TEXT -> TimeLetterBlockType.TEXT diff --git a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/repositoryImpl/TimeLetterRepositoryImpl.kt b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/repositoryImpl/TimeLetterRepositoryImpl.kt index d5deda9bb6..5726627acb 100644 --- a/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/repositoryImpl/TimeLetterRepositoryImpl.kt +++ b/feature/timeletter/data/src/main/kotlin/com/afternote/feature/timeletter/data/repositoryImpl/TimeLetterRepositoryImpl.kt @@ -10,6 +10,7 @@ import com.afternote.feature.timeletter.data.mapper.toDomain import com.afternote.feature.timeletter.data.mapper.toDto import com.afternote.feature.timeletter.domain.model.NewTimeLetterBlock import com.afternote.feature.timeletter.domain.model.TimeLetter +import com.afternote.feature.timeletter.domain.model.TimeLetterDeliveryMode import com.afternote.feature.timeletter.domain.model.TimeLetterList import com.afternote.feature.timeletter.domain.model.TimeLetterStatus import com.afternote.feature.timeletter.domain.repository.TimeLetterRepository @@ -42,17 +43,19 @@ class TimeLetterRepositoryImpl title: String?, blocks: List, sendAt: String?, + deliveryMode: TimeLetterDeliveryMode, status: TimeLetterStatus, - receiverIds: List?, + receiverIds: List, ): TimeLetter = timeLetterApiService .createTimeLetter( TimeLetterCreateRequestDto( title = title, sendAt = sendAt, + deliveryMode = deliveryMode.toDto(), status = status.toDto(), blocks = blocks.map { it.toDto() }, - receiverIds = receiverIds ?: emptyList(), + receiverIds = receiverIds, ), ).requireData() .toDomain() @@ -62,6 +65,7 @@ class TimeLetterRepositoryImpl title: String?, blocks: List, sendAt: String?, + deliveryMode: TimeLetterDeliveryMode?, status: TimeLetterStatus?, ): TimeLetter = timeLetterApiService @@ -71,6 +75,7 @@ class TimeLetterRepositoryImpl TimeLetterUpdateRequestDto( title = title, sendAt = sendAt, + deliveryMode = deliveryMode?.toDto(), status = status?.toDto(), blocks = blocks.map { it.toDto() }, ), diff --git a/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/model/TimeLetterModel.kt b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/model/TimeLetterModel.kt index b7d5f83952..271f1210e3 100644 --- a/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/model/TimeLetterModel.kt +++ b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/model/TimeLetterModel.kt @@ -41,6 +41,11 @@ enum class TimeLetterStatus { SENT, } +enum class TimeLetterDeliveryMode { + DATE, + POST_DEATH, +} + enum class TimeLetterBlockType { TEXT, IMAGE, diff --git a/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/repository/TimeLetterRepository.kt b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/repository/TimeLetterRepository.kt index e31877f443..43e859911b 100644 --- a/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/repository/TimeLetterRepository.kt +++ b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/repository/TimeLetterRepository.kt @@ -2,6 +2,7 @@ package com.afternote.feature.timeletter.domain.repository import com.afternote.feature.timeletter.domain.model.NewTimeLetterBlock import com.afternote.feature.timeletter.domain.model.TimeLetter +import com.afternote.feature.timeletter.domain.model.TimeLetterDeliveryMode import com.afternote.feature.timeletter.domain.model.TimeLetterList import com.afternote.feature.timeletter.domain.model.TimeLetterStatus @@ -16,8 +17,9 @@ interface TimeLetterRepository { title: String?, blocks: List, sendAt: String?, + deliveryMode: TimeLetterDeliveryMode, status: TimeLetterStatus, - receiverIds: List?, + receiverIds: List, ): TimeLetter suspend fun updateTimeLetter( @@ -25,6 +27,7 @@ interface TimeLetterRepository { title: String?, blocks: List, sendAt: String?, + deliveryMode: TimeLetterDeliveryMode?, status: TimeLetterStatus?, ): TimeLetter diff --git a/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/usecase/CreateTimeLetterUseCase.kt b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/usecase/CreateTimeLetterUseCase.kt index 41c62e1cf8..3567ee4ade 100644 --- a/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/usecase/CreateTimeLetterUseCase.kt +++ b/feature/timeletter/domain/src/main/kotlin/com/afternote/feature/timeletter/domain/usecase/CreateTimeLetterUseCase.kt @@ -2,8 +2,10 @@ package com.afternote.feature.timeletter.domain.usecase import com.afternote.feature.timeletter.domain.model.BlockInput import com.afternote.feature.timeletter.domain.model.TimeLetter +import com.afternote.feature.timeletter.domain.model.TimeLetterDeliveryMode import com.afternote.feature.timeletter.domain.model.TimeLetterStatus import com.afternote.feature.timeletter.domain.repository.TimeLetterRepository +import kotlinx.coroutines.CancellationException import javax.inject.Inject class CreateTimeLetterUseCase @@ -16,17 +18,25 @@ class CreateTimeLetterUseCase title: String?, blocks: List, sendAt: String?, + deliveryMode: TimeLetterDeliveryMode, status: TimeLetterStatus, - receiverIds: List?, + receiverIds: List, ): Result = - runCatching { + try { val newBlocks = resolveTimeLetterBlocksUseCase(blocks) - timeLetterRepository.createTimeLetter( - title = title, - blocks = newBlocks, - sendAt = sendAt, - status = status, - receiverIds = receiverIds, + Result.success( + timeLetterRepository.createTimeLetter( + title = title, + blocks = newBlocks, + sendAt = sendAt, + deliveryMode = deliveryMode, + status = status, + receiverIds = receiverIds, + ), ) + } catch (cancellationException: CancellationException) { + throw cancellationException + } catch (error: Throwable) { + Result.failure(error) } } diff --git a/feature/timeletter/presentation/build.gradle.kts b/feature/timeletter/presentation/build.gradle.kts index da868c49fe..91cff90d6c 100644 --- a/feature/timeletter/presentation/build.gradle.kts +++ b/feature/timeletter/presentation/build.gradle.kts @@ -21,10 +21,10 @@ dependencies { implementation(libs.compose.wheel.picker) implementation(libs.coil.compose) testImplementation(libs.coroutines.test) + testImplementation(libs.robolectric) testImplementation(platform(libs.androidx.compose.bom)) testImplementation(libs.junit) - testImplementation(libs.robolectric) testImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterWriteScreen.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterWriteScreen.kt index ef307db72b..882c48a956 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterWriteScreen.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/screen/sender/TimeLetterWriteScreen.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -77,6 +78,7 @@ import com.afternote.feature.timeletter.presentation.component.TimeLetterTextBut import com.afternote.feature.timeletter.presentation.component.TimeLetterTitleTextField import com.afternote.feature.timeletter.presentation.component.TimeWheelPicker import com.afternote.feature.timeletter.presentation.viewmodel.EditorBlock +import com.afternote.feature.timeletter.presentation.viewmodel.TimeLetterWriteError import com.afternote.feature.timeletter.presentation.viewmodel.TimeLetterWriteUiState import java.time.LocalDate import java.time.LocalTime @@ -139,9 +141,10 @@ fun TimeLetterWriteScreen( uri?.let { onAddFileBlock(it) } } - LaunchedEffect(uiState.errorMessage) { - val msg = uiState.errorMessage ?: return@LaunchedEffect - snackbarHostState.showSnackbar(msg) + val errorMessage = uiState.error?.message() + LaunchedEffect(uiState.error) { + val message = errorMessage ?: return@LaunchedEffect + snackbarHostState.showSnackbar(message) onErrorShown() } @@ -469,6 +472,17 @@ internal fun collectTextBlockContents( block.id to (visibleTextContents[block.id] ?: draftTextContents[block.id].orEmpty()) } +@Composable +private fun TimeLetterWriteError.message(): String = + stringResource( + when (this) { + TimeLetterWriteError.SEND_DATE_REQUIRED -> R.string.timeletter_write_send_date_required + TimeLetterWriteError.LOAD_FAILED -> R.string.timeletter_write_load_failed + TimeLetterWriteError.RECIPIENT_REQUIRED -> R.string.timeletter_write_recipient_required + TimeLetterWriteError.SAVE_FAILED -> R.string.timeletter_write_save_failed + }, + ) + @Composable private fun TextBlockItem( blockId: Long, diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteUiState.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteUiState.kt index 895365d92a..547264359f 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteUiState.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteUiState.kt @@ -17,7 +17,7 @@ data class TimeLetterWriteUiState( val draftCount: Int = 0, val isSaving: Boolean = false, val textAlign: TextAlign = TextAlign.Start, - val errorMessage: String? = null, + val error: TimeLetterWriteError? = null, val editorBlocks: List = listOf(EditorBlock.Text(id = 0L)), val focusedBlockId: Long? = 0L, val nextBlockId: Long = 1L, @@ -26,6 +26,13 @@ data class TimeLetterWriteUiState( val showFreePlanLimitPopup: Boolean = false, ) +enum class TimeLetterWriteError { + SEND_DATE_REQUIRED, + LOAD_FAILED, + RECIPIENT_REQUIRED, + SAVE_FAILED, +} + sealed class EditorBlock { abstract val id: Long diff --git a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModel.kt b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModel.kt index ae3218606f..fe2b094355 100644 --- a/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModel.kt +++ b/feature/timeletter/presentation/src/main/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModel.kt @@ -6,10 +6,12 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute +import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserRepository import com.afternote.feature.timeletter.domain.model.BlockInput import com.afternote.feature.timeletter.domain.model.TimeLetter import com.afternote.feature.timeletter.domain.model.TimeLetterBlockType +import com.afternote.feature.timeletter.domain.model.TimeLetterDeliveryMode import com.afternote.feature.timeletter.domain.model.TimeLetterStatus import com.afternote.feature.timeletter.domain.repository.FileMetadataRepository import com.afternote.feature.timeletter.domain.repository.TimeLetterRepository @@ -55,7 +57,7 @@ class TimeLetterWriteViewModel init { viewModelScope.launch { receiverNameMap = - runCatching { userRepository.getReceivers() } + runCatchingCancellable { userRepository.getReceivers() } .getOrElse { emptyList() } .associate { it.receiverId to it.name } editingTimeLetterId?.let { loadEditingTimeLetter(it) } @@ -141,8 +143,12 @@ class TimeLetterWriteViewModel val state = _uiState.value if (state.isSaving || isCheckingRegisterLimit) return + if (state.recipientIds.isEmpty()) { + _uiState.update { it.copy(error = TimeLetterWriteError.RECIPIENT_REQUIRED) } + return + } if (state.sendAt == null) { - _uiState.update { it.copy(errorMessage = "발송 날짜를 선택해주세요.") } + _uiState.update { it.copy(error = TimeLetterWriteError.SEND_DATE_REQUIRED) } return } isCheckingRegisterLimit = true @@ -150,10 +156,10 @@ class TimeLetterWriteViewModel try { if (state.editingTimeLetterId == null) { val registeredCount = - runCatching { timeLetterRepository.getTimeLetters().totalCount } + runCatchingCancellable { timeLetterRepository.getTimeLetters().totalCount } .getOrElse { _uiState.update { current -> - current.copy(errorMessage = "타임레터를 불러올 수 없습니다.") + current.copy(error = TimeLetterWriteError.LOAD_FAILED) } return@launch } @@ -172,7 +178,7 @@ class TimeLetterWriteViewModel } fun clearError() { - _uiState.update { it.copy(errorMessage = null) } + _uiState.update { it.copy(error = null) } } fun onSavedAsDraftShown() { @@ -273,56 +279,67 @@ class TimeLetterWriteViewModel ) { val state = _uiState.value if (state.isSaving) return + if (state.recipientIds.isEmpty()) { + _uiState.update { it.copy(error = TimeLetterWriteError.RECIPIENT_REQUIRED) } + return + } viewModelScope.launch { _uiState.update { it.copy(isSaving = true) } - val sendAt = - state.sendAt?.let { date -> - "${date}T${ - state.sendHour.toString().padStart(2, '0') - }:${state.sendMinute.toString().padStart(2, '0')}:00" - } - val saveResult = - if (state.editingTimeLetterId == null) { - val blocks = mapToBlockInputs(state.editorBlocks, textContents) - createTimeLetterUseCase( - title = title.ifBlank { null }, - blocks = blocks, - sendAt = sendAt, - status = status, - receiverIds = state.recipientIds.ifEmpty { null }, - ) - } else { - runCatching { - timeLetterRepository.updateTimeLetter( - timeLetterId = state.editingTimeLetterId, + try { + val sendAt = + state.sendAt?.let { date -> + "${date}T${ + state.sendHour.toString().padStart(2, '0') + }:${state.sendMinute.toString().padStart(2, '0')}:00" + } + val saveResult = + if (state.editingTimeLetterId == null) { + val blocks = mapToBlockInputs(state.editorBlocks, textContents) + createTimeLetterUseCase( title = title.ifBlank { null }, - blocks = - resolveTimeLetterBlocksUseCase( - mapToBlockInputs(state.editorBlocks, textContents), - ), + blocks = blocks, sendAt = sendAt, + deliveryMode = TimeLetterDeliveryMode.DATE, status = status, + receiverIds = state.recipientIds, ) - } - } - saveResult - .onSuccess { - if (status == TimeLetterStatus.DRAFT) { - loadDraftCount() - _uiState.update { it.copy(savedAsDraft = true) } } else { - _uiState.update { it.copy(registered = true) } + runCatchingCancellable { + timeLetterRepository.updateTimeLetter( + timeLetterId = state.editingTimeLetterId, + title = title.ifBlank { null }, + blocks = + resolveTimeLetterBlocksUseCase( + mapToBlockInputs(state.editorBlocks, textContents), + ), + sendAt = sendAt, + deliveryMode = TimeLetterDeliveryMode.DATE, + status = status, + ) + } } - }.onFailure { - _uiState.update { it.copy(errorMessage = "저장에 실패했어요. 다시 시도해주세요.") } - } - _uiState.update { it.copy(isSaving = false) } + saveResult + .onSuccess { + if (status == TimeLetterStatus.DRAFT) { + loadDraftCount() + _uiState.update { it.copy(savedAsDraft = true) } + } else { + _uiState.update { it.copy(registered = true) } + } + }.onFailure { + _uiState.update { + it.copy(error = TimeLetterWriteError.SAVE_FAILED) + } + } + } finally { + _uiState.update { it.copy(isSaving = false) } + } } } private suspend fun loadEditingTimeLetter(timeLetterId: Long) { - runCatching { timeLetterRepository.getTimeLetter(timeLetterId) } + runCatchingCancellable { timeLetterRepository.getTimeLetter(timeLetterId) } .onSuccess { letter -> val editorBlocks = letter.toEditorBlocks() val sendAtDate = letter.sendAt?.take(10) @@ -362,7 +379,7 @@ class TimeLetterWriteViewModel } }.onFailure { _uiState.update { it.copy(isLoadingEditingLetter = false) } - _uiState.update { it.copy(errorMessage = "타임레터를 불러올 수 없습니다.") } + _uiState.update { it.copy(error = TimeLetterWriteError.LOAD_FAILED) } } } @@ -494,7 +511,7 @@ class TimeLetterWriteViewModel private fun loadDraftCount() { viewModelScope.launch { - runCatching { timeLetterRepository.getTemporaryTimeLetters() } + runCatchingCancellable { timeLetterRepository.getTemporaryTimeLetters() } .onSuccess { result -> _uiState.update { it.copy(draftCount = result.totalCount) } } diff --git a/feature/timeletter/presentation/src/main/res/values/strings.xml b/feature/timeletter/presentation/src/main/res/values/strings.xml index d92e62ff76..7f44e760bb 100644 --- a/feature/timeletter/presentation/src/main/res/values/strings.xml +++ b/feature/timeletter/presentation/src/main/res/values/strings.xml @@ -1,5 +1,9 @@ + 발송 날짜를 선택해주세요. + 타임레터를 불러올 수 없습니다. + 수신자를 선택해주세요. + 저장에 실패했어요. 다시 시도해주세요. 타임레터를 삭제하시겠습니까? 예 아니오 diff --git a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModelTest.kt b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModelTest.kt new file mode 100644 index 0000000000..0a42383931 --- /dev/null +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeLetterWriteViewModelTest.kt @@ -0,0 +1,274 @@ +package com.afternote.feature.timeletter.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.domain.repository.PhotoUploadRepository +import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.model.delivery.DeliveryConditionItem +import com.afternote.core.model.delivery.ReceiverDeliveryConditions +import com.afternote.core.model.user.Receiver +import com.afternote.core.model.user.ReceiverCreated +import com.afternote.core.model.user.ReceiverDetail +import com.afternote.core.model.user.User +import com.afternote.core.model.user.UserConnectedAccount +import com.afternote.core.model.user.UserPushSetting +import com.afternote.feature.timeletter.domain.model.NewTimeLetterBlock +import com.afternote.feature.timeletter.domain.model.TimeLetter +import com.afternote.feature.timeletter.domain.model.TimeLetterDeliveryMode +import com.afternote.feature.timeletter.domain.model.TimeLetterList +import com.afternote.feature.timeletter.domain.model.TimeLetterStatus +import com.afternote.feature.timeletter.domain.repository.FileMetadataRepository +import com.afternote.feature.timeletter.domain.repository.TimeLetterRepository +import com.afternote.feature.timeletter.domain.usecase.CreateTimeLetterUseCase +import com.afternote.feature.timeletter.domain.usecase.ResolveTimeLetterBlocksUseCase +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class TimeLetterWriteViewModelTest { + private val dispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `수신자 없는 임시저장 - 생성 API를 호출하지 않고 필수 안내를 표시`() = + runTest { + val repository = FakeTimeLetterRepository() + val viewModel = viewModel(repository) + + viewModel.saveDraft(title = "제목", textContents = emptyMap()) + advanceUntilIdle() + + assertEquals(0, repository.createCallCount) + assertEquals(TimeLetterWriteError.RECIPIENT_REQUIRED, viewModel.uiState.value.error) + assertFalse(viewModel.uiState.value.savedAsDraft) + } + + @Test + fun `수신자 없는 정식 등록 - 조회와 생성 API를 호출하지 않고 필수 안내를 표시`() = + runTest { + val repository = FakeTimeLetterRepository() + val viewModel = viewModel(repository) + viewModel.setSendAt("2026-08-18") + + viewModel.register(title = "제목", textContents = emptyMap()) + advanceUntilIdle() + + assertEquals(0, repository.listCallCount) + assertEquals(0, repository.createCallCount) + assertEquals(TimeLetterWriteError.RECIPIENT_REQUIRED, viewModel.uiState.value.error) + assertFalse(viewModel.uiState.value.registered) + } + + @Test + fun `임시저장 API 실패 - 성공 상태를 만들지 않고 안전한 일반 오류로 매핑`() = + runTest { + val repository = FakeTimeLetterRepository(createFailure = Exception("internal SQL details")) + val viewModel = viewModel(repository) + viewModel.setRecipients(listOf(1L)) + + viewModel.saveDraft(title = "제목", textContents = emptyMap()) + advanceUntilIdle() + + assertEquals(1, repository.createCallCount) + assertEquals(TimeLetterWriteError.SAVE_FAILED, viewModel.uiState.value.error) + assertFalse(viewModel.uiState.value.savedAsDraft) + } + + @Test + fun `등록 개수 조회 취소 - 사용자 오류로 변환하지 않음`() = + runTest { + val repository = FakeTimeLetterRepository(listFailure = CancellationException("cancelled")) + val viewModel = viewModel(repository) + viewModel.setRecipients(listOf(1L)) + viewModel.setSendAt("2026-08-16") + + viewModel.register(title = "제목", textContents = emptyMap()) + advanceUntilIdle() + + assertEquals(1, repository.listCallCount) + assertNull(viewModel.uiState.value.error) + assertFalse(viewModel.uiState.value.registered) + } + + @Test + fun `저장 취소 - isSaving을 false로 복구하고 오류로 변환하지 않음`() = + runTest { + val repository = FakeTimeLetterRepository(createFailure = CancellationException("cancelled")) + val viewModel = viewModel(repository) + viewModel.setRecipients(listOf(1L)) + + viewModel.saveDraft(title = "제목", textContents = emptyMap()) + advanceUntilIdle() + + assertEquals(1, repository.createCallCount) + assertFalse(viewModel.uiState.value.isSaving) + assertNull(viewModel.uiState.value.error) + assertFalse(viewModel.uiState.value.savedAsDraft) + } + + private fun viewModel(repository: FakeTimeLetterRepository): TimeLetterWriteViewModel { + val resolveUseCase = ResolveTimeLetterBlocksUseCase(FakePhotoUploadRepository) + return TimeLetterWriteViewModel( + createTimeLetterUseCase = CreateTimeLetterUseCase(repository, resolveUseCase), + resolveTimeLetterBlocksUseCase = resolveUseCase, + timeLetterRepository = repository, + userRepository = FakeUserRepository, + fileMetadataRepository = FakeFileMetadataRepository, + savedStateHandle = SavedStateHandle(mapOf("timeLetterId" to null)), + ) + } +} + +private class FakeTimeLetterRepository( + private val createFailure: Throwable? = null, + private val listFailure: Throwable? = null, +) : TimeLetterRepository { + var createCallCount: Int = 0 + private set + + var listCallCount: Int = 0 + private set + + override suspend fun getTemporaryTimeLetters(): TimeLetterList = TimeLetterList(emptyList(), 0) + + override suspend fun createTimeLetter( + title: String?, + blocks: List, + sendAt: String?, + deliveryMode: TimeLetterDeliveryMode, + status: TimeLetterStatus, + receiverIds: List, + ): TimeLetter { + createCallCount++ + createFailure?.let { throw it } + return TimeLetter(1L, title, sendAt, null, status, emptyList(), receiverIds) + } + + override suspend fun getTimeLetters(): TimeLetterList { + listCallCount++ + listFailure?.let { throw it } + return TimeLetterList(emptyList(), 0) + } + + override suspend fun getTimeLetter(timeLetterId: Long): TimeLetter = error("getTimeLetter should not be called") + + override suspend fun updateTimeLetter( + timeLetterId: Long, + title: String?, + blocks: List, + sendAt: String?, + deliveryMode: TimeLetterDeliveryMode?, + status: TimeLetterStatus?, + ): TimeLetter = error("updateTimeLetter should not be called") + + override suspend fun deleteTimeLetters(timeLetterIds: List) = error("deleteTimeLetters should not be called") + + override suspend fun deleteAllTemporary() = error("deleteAllTemporary should not be called") +} + +private object FakePhotoUploadRepository : PhotoUploadRepository { + override suspend fun upload( + uriString: String, + directory: String, + ): Result = error("upload should not be called") +} + +private object FakeFileMetadataRepository : FileMetadataRepository { + override suspend fun getFileName(uriString: String): String = error("getFileName should not be called") + + override suspend fun getMimeType(uriString: String): String? = error("getMimeType should not be called") +} + +private object FakeUserRepository : UserRepository { + override val receiverListFlow: Flow> = flowOf(emptyList()) + + override suspend fun getReceivers(): List = emptyList() + + override suspend fun createReceiver( + name: String, + relation: String, + phone: String?, + email: String?, + message: String?, + ): ReceiverCreated = error("createReceiver should not be called") + + override suspend fun getReceiverDetail(receiverId: Long): ReceiverDetail = error("getReceiverDetail should not be called") + + override suspend fun updateReceiver( + receiverId: Long, + name: String, + phone: String, + relation: String, + email: String, + ): Receiver = error("updateReceiver should not be called") + + override suspend fun updateReceiverMessage( + receiverId: Long, + message: String, + ) = error("updateReceiverMessage should not be called") + + override suspend fun getMyProfile(): User = error("getMyProfile should not be called") + + override suspend fun updateMyProfile( + name: String?, + phone: String?, + profileImageUrl: String?, + ): User = error("updateMyProfile should not be called") + + override suspend fun deleteAccount() = error("deleteAccount should not be called") + + override suspend fun logActivity() = error("logActivity should not be called") + + override suspend fun getMyPushSettings(): UserPushSetting = error("getMyPushSettings should not be called") + + override suspend fun updateMyPushSettings( + timeLetter: Boolean?, + mindRecord: Boolean?, + afterNote: Boolean?, + ): UserPushSetting = error("updateMyPushSettings should not be called") + + override suspend fun getConnectedAccounts(): UserConnectedAccount = error("getConnectedAccounts should not be called") + + override suspend fun linkConnectedAccount( + provider: String, + accessToken: String, + ): UserConnectedAccount = error("linkConnectedAccount should not be called") + + override suspend fun unlinkConnectedAccount(provider: String): UserConnectedAccount = + error("unlinkConnectedAccount should not be called") + + override suspend fun getReceiverDeliveryConditions(receiverId: Long): ReceiverDeliveryConditions = + error("getReceiverDeliveryConditions should not be called") + + override suspend fun updateReceiverDeliveryConditions( + receiverId: Long, + conditions: List, + ): ReceiverDeliveryConditions = error("updateReceiverDeliveryConditions should not be called") +} diff --git a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt index a09458bf70..908e36d771 100644 --- a/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt +++ b/feature/timeletter/presentation/src/test/kotlin/com/afternote/feature/timeletter/presentation/viewmodel/TimeletterViewModelTest.kt @@ -3,6 +3,7 @@ package com.afternote.feature.timeletter.presentation.viewmodel import com.afternote.core.domain.repository.UserRepository import com.afternote.feature.timeletter.domain.model.NewTimeLetterBlock import com.afternote.feature.timeletter.domain.model.TimeLetter +import com.afternote.feature.timeletter.domain.model.TimeLetterDeliveryMode import com.afternote.feature.timeletter.domain.model.TimeLetterList import com.afternote.feature.timeletter.domain.model.TimeLetterStatus import com.afternote.feature.timeletter.domain.repository.TimeLetterRepository @@ -169,8 +170,9 @@ class TimeletterViewModelTest { title: String?, blocks: List, sendAt: String?, + deliveryMode: TimeLetterDeliveryMode, status: TimeLetterStatus, - receiverIds: List?, + receiverIds: List, ): TimeLetter = error("Unexpected call") override suspend fun updateTimeLetter( @@ -178,6 +180,7 @@ class TimeletterViewModelTest { title: String?, blocks: List, sendAt: String?, + deliveryMode: TimeLetterDeliveryMode?, status: TimeLetterStatus?, ): TimeLetter = error("Unexpected call")