diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e62a75f9..5ea77adb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -52,7 +52,7 @@ android { buildTypes { debug { isMinifyEnabled = false - buildConfigField("String", "CLODY_BASE_URL", properties["clody.base.url"].toString()) + buildConfigField("String", "CLODY_BASE_URL", properties["clody.test.url"].toString()) } release { @@ -77,6 +77,11 @@ android { buildConfig = true compose = true } + testOptions { + unitTests.all { + it.useJUnitPlatform() + } + } } dependencies { @@ -123,6 +128,11 @@ dependencies { // Mavericks implementation(libs.bundles.mavericks) + // Kotest + testImplementation(libs.bundles.kotest) + testImplementation(libs.mockk) + testImplementation(libs.coroutines.test) + // Play Store implementation(libs.bundles.plays) diff --git a/app/src/main/java/com/sopt/clody/data/remote/api/DiaryService.kt b/app/src/main/java/com/sopt/clody/data/remote/api/DiaryService.kt index 8a702f80..bd8981d7 100644 --- a/app/src/main/java/com/sopt/clody/data/remote/api/DiaryService.kt +++ b/app/src/main/java/com/sopt/clody/data/remote/api/DiaryService.kt @@ -1,9 +1,11 @@ package com.sopt.clody.data.remote.api import com.sopt.clody.data.remote.dto.base.ApiResponse +import com.sopt.clody.data.remote.dto.request.SaveDraftDiaryRequestDto import com.sopt.clody.data.remote.dto.request.WriteDiaryRequestDto import com.sopt.clody.data.remote.dto.response.DailyDiariesResponseDto import com.sopt.clody.data.remote.dto.response.DiaryTimeResponseDto +import com.sopt.clody.data.remote.dto.response.DraftDiariesResponseDto import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto import com.sopt.clody.data.remote.dto.response.MonthlyDiaryResponseDto import com.sopt.clody.data.remote.dto.response.ReplyDiaryResponseDto @@ -59,4 +61,16 @@ interface DiaryService { @Query("month") month: Int, @Query("date") date: Int, ): ApiResponse + + @GET("api/v1/draft") + suspend fun fetchDraftDiary( + @Query("year") year: Int, + @Query("month") month: Int, + @Query("date") date: Int, + ): ApiResponse + + @POST("api/v1/draft") + suspend fun saveDraftDiary( + @Body request: SaveDraftDiaryRequestDto, + ): ApiResponse } diff --git a/app/src/main/java/com/sopt/clody/data/remote/datasource/DiaryRemoteDataSource.kt b/app/src/main/java/com/sopt/clody/data/remote/datasource/DiaryRemoteDataSource.kt index 19dae1a7..26c0d844 100644 --- a/app/src/main/java/com/sopt/clody/data/remote/datasource/DiaryRemoteDataSource.kt +++ b/app/src/main/java/com/sopt/clody/data/remote/datasource/DiaryRemoteDataSource.kt @@ -1,8 +1,10 @@ package com.sopt.clody.data.remote.datasource import com.sopt.clody.data.remote.dto.base.ApiResponse +import com.sopt.clody.data.remote.dto.request.SaveDraftDiaryRequestDto import com.sopt.clody.data.remote.dto.response.DailyDiariesResponseDto import com.sopt.clody.data.remote.dto.response.DiaryTimeResponseDto +import com.sopt.clody.data.remote.dto.response.DraftDiariesResponseDto import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto import com.sopt.clody.data.remote.dto.response.MonthlyDiaryResponseDto import com.sopt.clody.data.remote.dto.response.ReplyDiaryResponseDto @@ -16,4 +18,6 @@ interface DiaryRemoteDataSource { suspend fun getMonthlyCalendarData(year: Int, month: Int): ApiResponse suspend fun getMonthlyDiary(year: Int, month: Int): ApiResponse suspend fun getReplyDiary(year: Int, month: Int, date: Int): ApiResponse + suspend fun fetchDraftDiary(year: Int, month: Int, date: Int): ApiResponse + suspend fun saveDraftDiary(request: SaveDraftDiaryRequestDto): ApiResponse } diff --git a/app/src/main/java/com/sopt/clody/data/remote/datasourceimpl/DiaryRemoteDataSourceImpl.kt b/app/src/main/java/com/sopt/clody/data/remote/datasourceimpl/DiaryRemoteDataSourceImpl.kt index cbbb7b53..f5e35d2a 100644 --- a/app/src/main/java/com/sopt/clody/data/remote/datasourceimpl/DiaryRemoteDataSourceImpl.kt +++ b/app/src/main/java/com/sopt/clody/data/remote/datasourceimpl/DiaryRemoteDataSourceImpl.kt @@ -3,9 +3,11 @@ package com.sopt.clody.data.remote.datasourceimpl import com.sopt.clody.data.remote.api.DiaryService import com.sopt.clody.data.remote.datasource.DiaryRemoteDataSource import com.sopt.clody.data.remote.dto.base.ApiResponse +import com.sopt.clody.data.remote.dto.request.SaveDraftDiaryRequestDto import com.sopt.clody.data.remote.dto.request.WriteDiaryRequestDto import com.sopt.clody.data.remote.dto.response.DailyDiariesResponseDto import com.sopt.clody.data.remote.dto.response.DiaryTimeResponseDto +import com.sopt.clody.data.remote.dto.response.DraftDiariesResponseDto import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto import com.sopt.clody.data.remote.dto.response.MonthlyDiaryResponseDto import com.sopt.clody.data.remote.dto.response.ReplyDiaryResponseDto @@ -35,4 +37,10 @@ class DiaryRemoteDataSourceImpl @Inject constructor( override suspend fun getReplyDiary(year: Int, month: Int, date: Int): ApiResponse = diaryService.getReplyDiary(year = year, month = month, date = date) + + override suspend fun fetchDraftDiary(year: Int, month: Int, date: Int): ApiResponse = + diaryService.fetchDraftDiary(year = year, month = month, date = date) + + override suspend fun saveDraftDiary(request: SaveDraftDiaryRequestDto): ApiResponse = + diaryService.saveDraftDiary(request) } diff --git a/app/src/main/java/com/sopt/clody/data/remote/dto/request/SaveDraftDiaryRequestDto.kt b/app/src/main/java/com/sopt/clody/data/remote/dto/request/SaveDraftDiaryRequestDto.kt new file mode 100644 index 00000000..0fc706af --- /dev/null +++ b/app/src/main/java/com/sopt/clody/data/remote/dto/request/SaveDraftDiaryRequestDto.kt @@ -0,0 +1,10 @@ +package com.sopt.clody.data.remote.dto.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SaveDraftDiaryRequestDto( + @SerialName("date") val date: String, + @SerialName("draftDiaries") val draftDiaries: List, +) diff --git a/app/src/main/java/com/sopt/clody/data/remote/dto/response/DraftDiariesResponseDto.kt b/app/src/main/java/com/sopt/clody/data/remote/dto/response/DraftDiariesResponseDto.kt new file mode 100644 index 00000000..6bb522b5 --- /dev/null +++ b/app/src/main/java/com/sopt/clody/data/remote/dto/response/DraftDiariesResponseDto.kt @@ -0,0 +1,14 @@ +package com.sopt.clody.data.remote.dto.response + +import com.sopt.clody.domain.model.DraftDiaryContents +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DraftDiariesResponseDto( + @SerialName("draftDiaries") val draftDiaries: List, +) { + fun toDomain() = DraftDiaryContents( + draftDiaries = draftDiaries ?: emptyList(), + ) +} diff --git a/app/src/main/java/com/sopt/clody/data/repositoryimpl/DiaryRepositoryImpl.kt b/app/src/main/java/com/sopt/clody/data/repositoryimpl/DiaryRepositoryImpl.kt index 20d14634..febe3024 100644 --- a/app/src/main/java/com/sopt/clody/data/repositoryimpl/DiaryRepositoryImpl.kt +++ b/app/src/main/java/com/sopt/clody/data/repositoryimpl/DiaryRepositoryImpl.kt @@ -1,6 +1,7 @@ package com.sopt.clody.data.repositoryimpl import com.sopt.clody.data.remote.datasource.DiaryRemoteDataSource +import com.sopt.clody.data.remote.dto.request.SaveDraftDiaryRequestDto import com.sopt.clody.data.remote.dto.response.DailyDiariesResponseDto import com.sopt.clody.data.remote.dto.response.DiaryTimeResponseDto import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto @@ -8,6 +9,7 @@ import com.sopt.clody.data.remote.dto.response.MonthlyDiaryResponseDto import com.sopt.clody.data.remote.dto.response.ReplyDiaryResponseDto import com.sopt.clody.data.remote.dto.response.WriteDiaryResponseDto import com.sopt.clody.data.remote.util.handleApiResponse +import com.sopt.clody.domain.model.DraftDiaryContents import com.sopt.clody.domain.repository.DiaryRepository import com.sopt.clody.presentation.utils.network.ErrorMessages import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_TEMPORARY_MESSAGE @@ -80,4 +82,21 @@ class DiaryRepositoryImpl @Inject constructor( } response } + + override suspend fun fetchDraftDiary(year: Int, month: Int, date: Int): Result = + runCatching { + diaryRemoteDataSource + .fetchDraftDiary(year, month, date) + .handleApiResponse() + .getOrThrow() + .toDomain() + } + + override suspend fun saveDraftDiary(date: String, contents: List): Result = + runCatching { + diaryRemoteDataSource + .saveDraftDiary(SaveDraftDiaryRequestDto(date = date, draftDiaries = contents)) + .handleApiResponse() + .getOrThrow() + } } diff --git a/app/src/main/java/com/sopt/clody/domain/model/DraftDiaryContents.kt b/app/src/main/java/com/sopt/clody/domain/model/DraftDiaryContents.kt new file mode 100644 index 00000000..45ddcbd1 --- /dev/null +++ b/app/src/main/java/com/sopt/clody/domain/model/DraftDiaryContents.kt @@ -0,0 +1,5 @@ +package com.sopt.clody.domain.model + +data class DraftDiaryContents( + val draftDiaries: List, +) diff --git a/app/src/main/java/com/sopt/clody/domain/repository/DiaryRepository.kt b/app/src/main/java/com/sopt/clody/domain/repository/DiaryRepository.kt index 9c95565e..3666ed18 100644 --- a/app/src/main/java/com/sopt/clody/domain/repository/DiaryRepository.kt +++ b/app/src/main/java/com/sopt/clody/domain/repository/DiaryRepository.kt @@ -6,6 +6,7 @@ import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto import com.sopt.clody.data.remote.dto.response.MonthlyDiaryResponseDto import com.sopt.clody.data.remote.dto.response.ReplyDiaryResponseDto import com.sopt.clody.data.remote.dto.response.WriteDiaryResponseDto +import com.sopt.clody.domain.model.DraftDiaryContents interface DiaryRepository { suspend fun writeDiary(date: String, content: List): Result @@ -15,4 +16,6 @@ interface DiaryRepository { suspend fun getMonthlyCalendarData(year: Int, month: Int): Result suspend fun getMonthlyDiary(year: Int, month: Int): Result suspend fun getReplyDiary(year: Int, month: Int, date: Int): Result + suspend fun fetchDraftDiary(year: Int, month: Int, date: Int): Result + suspend fun saveDraftDiary(date: String, contents: List): Result } diff --git a/app/src/main/java/com/sopt/clody/domain/usecase/FetchDraftDiaryUseCase.kt b/app/src/main/java/com/sopt/clody/domain/usecase/FetchDraftDiaryUseCase.kt new file mode 100644 index 00000000..929c2e1f --- /dev/null +++ b/app/src/main/java/com/sopt/clody/domain/usecase/FetchDraftDiaryUseCase.kt @@ -0,0 +1,13 @@ +package com.sopt.clody.domain.usecase + +import com.sopt.clody.domain.model.DraftDiaryContents +import com.sopt.clody.domain.repository.DiaryRepository +import javax.inject.Inject + +class FetchDraftDiaryUseCase @Inject constructor( + private val repository: DiaryRepository, +) { + suspend operator fun invoke(year: Int, month: Int, day: Int): Result { + return repository.fetchDraftDiary(year, month, day) + } +} diff --git a/app/src/main/java/com/sopt/clody/domain/usecase/SaveDraftDiaryUseCase.kt b/app/src/main/java/com/sopt/clody/domain/usecase/SaveDraftDiaryUseCase.kt new file mode 100644 index 00000000..a1b2ffa6 --- /dev/null +++ b/app/src/main/java/com/sopt/clody/domain/usecase/SaveDraftDiaryUseCase.kt @@ -0,0 +1,12 @@ +package com.sopt.clody.domain.usecase + +import com.sopt.clody.domain.repository.DiaryRepository +import javax.inject.Inject + +class SaveDraftDiaryUseCase @Inject constructor( + private val diaryRepository: DiaryRepository, +) { + suspend operator fun invoke(date: String, contents: List): Result { + return diaryRepository.saveDraftDiary(date, contents) + } +} diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/ClodyDialog.kt b/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/ClodyDialog.kt index 6d45a195..c0846c94 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/ClodyDialog.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/ClodyDialog.kt @@ -43,6 +43,7 @@ fun ClodyDialog( confirmButtonColor: Color, confirmButtonTextColor: Color, onDismiss: () -> Unit, + onDismissButtonClick: (() -> Unit)? = null, ) { var isButtonClicked by remember { mutableStateOf(false) } @@ -68,8 +69,7 @@ fun ClodyDialog( .wrapContentHeight(), ) { Column( - modifier = Modifier - .padding(28.dp), + modifier = Modifier.padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -92,21 +92,21 @@ fun ClodyDialog( Spacer(modifier = Modifier.height(32.dp)) Row( - modifier = Modifier - .fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), ) { Button( onClick = { if (!isButtonClicked) { isButtonClicked = true - onDismiss() + // dismiss 버튼 클릭 시: 우선순위는 onDismissButtonClick + onDismissButtonClick?.invoke() ?: onDismiss() } }, modifier = Modifier .weight(1f) .background( color = ClodyTheme.colors.gray07, - shape = RoundedCornerShape(size = 8.dp), + shape = RoundedCornerShape(8.dp), ), colors = ButtonDefaults.buttonColors(ClodyTheme.colors.gray07), ) { @@ -130,7 +130,7 @@ fun ClodyDialog( .weight(1f) .background( color = confirmButtonColor, - shape = RoundedCornerShape(size = 8.dp), + shape = RoundedCornerShape(8.dp), ), colors = ButtonDefaults.buttonColors(confirmButtonColor), ) { diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/textfield/WriteDiaryTextField.kt b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/textfield/WriteDiaryTextField.kt index e3a24cde..e2593bde 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/textfield/WriteDiaryTextField.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/textfield/WriteDiaryTextField.kt @@ -163,7 +163,7 @@ fun WriteDiaryTextField( .fillMaxWidth() .padding(start = 8.dp, top = 6.dp), ) { - if ((showWarning && !isTextValid && text.isNotEmpty()) || isTextTooLong) { + if ((showWarning && !isTextValid) || isTextTooLong) { Text( text = "2~50자 까지 입력할 수 있어요.", color = ClodyTheme.colors.red, diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/screen/WriteDiaryScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/screen/WriteDiaryScreen.kt index da7ec6bd..3af5e77e 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/screen/WriteDiaryScreen.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/screen/WriteDiaryScreen.kt @@ -1,5 +1,6 @@ package com.sopt.clody.presentation.ui.writediary.screen +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -48,6 +49,7 @@ import com.sopt.clody.presentation.utils.amplitude.AmplitudeConstraints import com.sopt.clody.presentation.utils.amplitude.AmplitudeUtils import com.sopt.clody.presentation.utils.base.BasePreview import com.sopt.clody.presentation.utils.base.ClodyPreview +import com.sopt.clody.presentation.utils.extension.LaunchedEffectWhenStarted import com.sopt.clody.presentation.utils.extension.getDayOfWeek import com.sopt.clody.presentation.utils.extension.heightForScreenPercentage import com.sopt.clody.ui.theme.ClodyTheme @@ -74,6 +76,10 @@ fun WriteDiaryRoute( val showDialog by viewModel::showDialog val showExitDialog by viewModel::showExitDialog + LaunchedEffectWhenStarted { + viewModel.fetchDraftDiary(year, month, date) + } + LaunchedEffect(writeDiaryState) { when (writeDiaryState) { is WriteDiaryState.Success -> navigateToReplyLoading(year, month, date) @@ -83,6 +89,19 @@ fun WriteDiaryRoute( } } + BackHandler { + if (showExitDialog) { + viewModel.updateShowExitDialog(false) + } else { + if (viewModel.hasChangedFromInitial()) { + AmplitudeUtils.trackEvent(AmplitudeConstraints.WRITING_DIARY_BACK) + viewModel.updateShowExitDialog(true) + } else { + navigateToPrevious() + } + } + } + WriteDiaryScreen( isLoading = writeDiaryState is WriteDiaryState.Loading, entries = entries, @@ -95,7 +114,7 @@ fun WriteDiaryRoute( failureMessage = failureMessage, showExitDialog = showExitDialog, onClickBack = { - if (entries.any { it.isNotBlank() }) { + if (viewModel.hasChangedFromInitial()) { AmplitudeUtils.trackEvent(AmplitudeConstraints.WRITING_DIARY_BACK) viewModel.updateShowExitDialog(true) } else { @@ -138,9 +157,11 @@ fun WriteDiaryRoute( onDismissLimitMessage = { viewModel.updateShowLimitMessage(false) }, onDismissEmptyFieldsMessage = { viewModel.updateShowEmptyFieldsMessage(false) }, onDismissFailureDialog = { viewModel.resetFailureDialog() }, + onDismiss = { viewModel.updateShowExitDialog(false) }, onDismissExitDialog = { viewModel.updateDraftUsage() viewModel.updateShowExitDialog(false) + viewModel.saveDraftDiary(year, month, date) navigateToHome(year, month) }, onConfirmExitDialog = { @@ -177,6 +198,7 @@ fun WriteDiaryScreen( failureMessage: String, showExitDialog: Boolean, onDismissFailureDialog: () -> Unit, + onDismiss: () -> Unit, onDismissExitDialog: () -> Unit, onConfirmExitDialog: () -> Unit, year: Int, @@ -285,7 +307,8 @@ fun WriteDiaryScreen( confirmAction = onConfirmExitDialog, confirmButtonColor = ClodyTheme.colors.red, confirmButtonTextColor = ClodyTheme.colors.white, - onDismiss = onDismissExitDialog, + onDismiss = onDismiss, + onDismissButtonClick = onDismissExitDialog, ) } } @@ -382,6 +405,7 @@ private fun WriteDiaryScreenPreview() { failureMessage = "", showExitDialog = false, onDismissFailureDialog = {}, + onDismiss = {}, onDismissExitDialog = {}, onConfirmExitDialog = {}, year = 2023, diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/screen/WriteDiaryViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/screen/WriteDiaryViewModel.kt index c1818f07..5b27cbc1 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/screen/WriteDiaryViewModel.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/screen/WriteDiaryViewModel.kt @@ -10,6 +10,9 @@ import androidx.lifecycle.viewModelScope import com.sopt.clody.data.remote.util.NetworkUtil import com.sopt.clody.domain.repository.DiaryRepository import com.sopt.clody.domain.repository.DraftRepository +import com.sopt.clody.domain.usecase.FetchDraftDiaryUseCase +import com.sopt.clody.domain.usecase.SaveDraftDiaryUseCase +import com.sopt.clody.presentation.utils.network.ErrorMessages import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_NETWORK_MESSAGE import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_TEMPORARY_MESSAGE import com.sopt.clody.presentation.utils.network.ErrorMessages.UNKNOWN_ERROR @@ -22,6 +25,8 @@ import javax.inject.Inject @HiltViewModel class WriteDiaryViewModel @Inject constructor( private val diaryRepository: DiaryRepository, + private val fetchDraftDiaryUseCase: FetchDraftDiaryUseCase, + private val saveDraftDiaryUseCase: SaveDraftDiaryUseCase, private val networkUtil: NetworkUtil, private val draftRepository: DraftRepository, ) : ViewModel() { @@ -59,6 +64,8 @@ class WriteDiaryViewModel @Inject constructor( var showExitDialog by mutableStateOf(false) private set + private var initialEntries: List = emptyList() + fun writeDiary(year: Int, month: Int, day: Int, contents: List) { viewModelScope.launch { if (!networkUtil.isNetworkAvailable()) { @@ -171,6 +178,55 @@ class WriteDiaryViewModel @Inject constructor( showExitDialog = show } + fun hasChangedFromInitial(): Boolean { + return entries != initialEntries + } + + fun fetchDraftDiary(year: Int, month: Int, day: Int) { + viewModelScope.launch { + _entries.clear() + _showWarnings.clear() + + val result = fetchDraftDiaryUseCase(year, month, day) + result.onSuccess { response -> + val drafts = response.draftDiaries.ifEmpty { listOf("") } + _entries.addAll(drafts) + initialEntries = drafts.toList() + + _showWarnings.addAll(List(_entries.size) { false }) + checkLimitMessage() + checkEmptyFieldsMessage() + }.onFailure { + ensureDefaultEntry() + _failureMessage.value = ErrorMessages.FETCH_TEMP_DIARY_FAILED + _showFailureDialog.value = true + } + } + } + + fun saveDraftDiary(year: Int, month: Int, day: Int) { + viewModelScope.launch { + val date = String.format("%04d-%02d-%02d", year, month, day) + val result = saveDraftDiaryUseCase(date, _entries.toList()) + result.onSuccess { + _failureMessage.value = "" + _showFailureDialog.value = false + }.onFailure { e -> + _failureMessage.value = e.localizedMessage ?: UNKNOWN_ERROR + _showFailureDialog.value = true + } + } + } + + private fun ensureDefaultEntry() { + _entries.clear() + _entries.add("") + _showWarnings.clear() + _showWarnings.add(false) + checkLimitMessage() + checkEmptyFieldsMessage() + } + fun updateDraftUsage() { if (!draftRepository.getIsDraftUsed()) { draftRepository.setIsDraftUsed(true) diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/extension/LifeCycle.kt b/app/src/main/java/com/sopt/clody/presentation/utils/extension/LifeCycle.kt index 61458078..8e0208ab 100644 --- a/app/src/main/java/com/sopt/clody/presentation/utils/extension/LifeCycle.kt +++ b/app/src/main/java/com/sopt/clody/presentation/utils/extension/LifeCycle.kt @@ -1,7 +1,10 @@ package com.sopt.clody.presentation.utils.extension +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.CoroutineScope @@ -25,3 +28,28 @@ fun LifecycleOwner.repeatOnStarted(block: suspend CoroutineScope.() -> Unit) { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED, block) } } + +/** + * [LifecycleOwner]가 [lifecycleState] 이상일 때만 [block]을 실행하는 [LaunchedEffect] 기반의 Composable 유틸함수. + * 내부적으로 [Lifecycle.repeatOnLifecycle]을 사용하여 생명주기 안전성을 보장. + * SideEffect 처리를 lifecycle-aware하게 실행하고 싶을 때 사용하면 됨. + * + * @param key [LaunchedEffect]를 트리거할 key. 일반적으로 의존성이 되는 상태나 객체. + * @param lifecycleState 반복 실행을 시작할 최소 생명주기 상태 (기본값: STARTED) + * @param block 지정한 생명주기 상태 이상일 때만 실행할 suspend 블록 + */ + +@Composable +fun LaunchedEffectWhenStarted( + key: Any? = Unit, + lifecycleState: Lifecycle.State = Lifecycle.State.STARTED, + block: suspend CoroutineScope.() -> Unit, +) { + val lifecycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(key, lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(lifecycleState) { + block() + } + } +} diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/network/ErrorMessages.kt b/app/src/main/java/com/sopt/clody/presentation/utils/network/ErrorMessages.kt index 3aa8a1a2..3db50f7e 100644 --- a/app/src/main/java/com/sopt/clody/presentation/utils/network/ErrorMessages.kt +++ b/app/src/main/java/com/sopt/clody/presentation/utils/network/ErrorMessages.kt @@ -4,5 +4,6 @@ object ErrorMessages { const val FAILURE_NETWORK_MESSAGE = "서비스 접속이 원활하지 않아요.\n네트워크 연결을 확인해주세요." const val FAILURE_TEMPORARY_MESSAGE = "일시적인 오류가 발생했어요.\n잠시 후 다시 시도해주세요." const val FAILURE_SERVER_MESSAGE = "서버 오류가 발생했어요.\n잠시 후 다시 시도해주세요." + const val FETCH_TEMP_DIARY_FAILED = "임시저장 불러오기에 실패했어요.\n잠시 후 다시 시도해주세요." const val UNKNOWN_ERROR = "알수없는 에러" } diff --git a/app/src/test/java/com/sopt/clody/FakeDiaryRepository.kt b/app/src/test/java/com/sopt/clody/FakeDiaryRepository.kt new file mode 100644 index 00000000..9355bd74 --- /dev/null +++ b/app/src/test/java/com/sopt/clody/FakeDiaryRepository.kt @@ -0,0 +1,55 @@ +package com.sopt.clody + +import com.sopt.clody.data.remote.dto.response.DailyDiariesResponseDto +import com.sopt.clody.data.remote.dto.response.DiaryTimeResponseDto +import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto +import com.sopt.clody.data.remote.dto.response.MonthlyDiaryResponseDto +import com.sopt.clody.data.remote.dto.response.ReplyDiaryResponseDto +import com.sopt.clody.data.remote.dto.response.WriteDiaryResponseDto +import com.sopt.clody.domain.model.CreatedDraftDiaryInfo +import com.sopt.clody.domain.model.DraftDiaryContents +import com.sopt.clody.domain.repository.DiaryRepository + +class FakeDiaryRepository : DiaryRepository { + var draftDiaryResult: Result? = null + var saveDraftResult: Result? = null + private var draftDiaryContents: DraftDiaryContents? = null + + override suspend fun writeDiary(date: String, content: List): Result { + throw NotImplementedError("This method is not implemented in FakeDiaryRepository") + } + + override suspend fun deleteDailyDiary(year: Int, month: Int, day: Int): Result { + throw NotImplementedError("This method is not implemented in FakeDiaryRepository") + } + + override suspend fun getDailyDiariesData(year: Int, month: Int, date: Int): Result { + throw NotImplementedError("This method is not implemented in FakeDiaryRepository") + } + + override suspend fun getDiaryTime(year: Int, month: Int, date: Int): Result { + throw NotImplementedError("This method is not implemented in FakeDiaryRepository") + } + + override suspend fun getMonthlyCalendarData(year: Int, month: Int): Result { + throw NotImplementedError("This method is not implemented in FakeDiaryRepository") + } + + override suspend fun getMonthlyDiary(year: Int, month: Int): Result { + throw NotImplementedError("This method is not implemented in FakeDiaryRepository") + } + + override suspend fun getReplyDiary(year: Int, month: Int, date: Int): Result { + throw NotImplementedError("This method is not implemented in FakeDiaryRepository") + } + + override suspend fun fetchDraftDiary(year: Int, month: Int, date: Int): Result { + return draftDiaryContents?.let { Result.success(it) } + ?: Result.failure(IllegalStateException("No draft found")) + } + + override suspend fun saveDraftDiary(contents: List): Result { + draftDiaryContents = DraftDiaryContents(contents) + return Result.success(CreatedDraftDiaryInfo(createdAt = "2024-06-01T00:00:00")) + } +} diff --git a/app/src/test/java/com/sopt/clody/WriteDiaryViewModelTest.kt b/app/src/test/java/com/sopt/clody/WriteDiaryViewModelTest.kt new file mode 100644 index 00000000..31c1a2bc --- /dev/null +++ b/app/src/test/java/com/sopt/clody/WriteDiaryViewModelTest.kt @@ -0,0 +1,86 @@ +package com.sopt.clody + +import com.sopt.clody.domain.model.CreatedDraftDiaryInfo +import com.sopt.clody.domain.model.DraftDiaryContents +import com.sopt.clody.domain.usecase.FetchDraftDiaryUseCase +import com.sopt.clody.domain.usecase.SaveDraftDiaryUseCase +import com.sopt.clody.presentation.ui.writediary.screen.WriteDiaryViewModel +import io.kotest.assertions.nondeterministic.eventually +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class WriteDiaryViewModelTest : BehaviorSpec( + { + + val testDispatcher = UnconfinedTestDispatcher() + + beforeTest { + Dispatchers.setMain(testDispatcher) + } + + afterTest { + Dispatchers.resetMain() + } + + Given("fetchDraftDiary 호출 시") { + val mockContents = listOf("entry1", "entry2", "entry3") + val fakeRepo = FakeDiaryRepository().apply { + draftDiaryResult = Result.success(DraftDiaryContents(mockContents)) + } + + val viewModel = WriteDiaryViewModel( + diaryRepository = fakeRepo, + fetchDraftDiaryUseCase = FetchDraftDiaryUseCase(fakeRepo), + saveDraftDiaryUseCase = SaveDraftDiaryUseCase(fakeRepo), + networkUtil = mockk(relaxed = true), + ) + + When("fetchDraftDiaryUseCase가 성공하면") { + viewModel.fetchDraftDiary(2025, 6, 1) + + Then("entries와 showWarnings가 초기화된다") { + eventually(duration = 2.seconds) { + viewModel.entries shouldContainExactly mockContents + viewModel.showWarnings shouldContainExactly List(mockContents.size) { false } + } + } + } + } + + Given("saveDraftDiary 호출 시") { + val fakeRepo = FakeDiaryRepository().apply { + saveDraftResult = Result.success(CreatedDraftDiaryInfo("2025-06-01T00:00:00.000Z")) + } + + val viewModel = WriteDiaryViewModel( + diaryRepository = fakeRepo, + fetchDraftDiaryUseCase = FetchDraftDiaryUseCase(fakeRepo), + saveDraftDiaryUseCase = SaveDraftDiaryUseCase(fakeRepo), + networkUtil = mockk(relaxed = true), + ) + + viewModel.updateEntry(0, "entry1") + viewModel.addEntry() + viewModel.updateEntry(1, "entry2") + + viewModel.saveDraftDiary() + + Then("에러 메시지가 설정되지 않는다") { + eventually(duration = 3.seconds) { + viewModel.showFailureDialog.value shouldBe false + viewModel.failureMessage.value shouldBe "" + println("dialog = ${viewModel.showFailureDialog.value}, message = ${viewModel.failureMessage.value}") + } + } + } + }, +) diff --git a/app/src/test/java/com/sopt/clody/datasource/FakeDiaryRemoteDataSource.kt b/app/src/test/java/com/sopt/clody/datasource/FakeDiaryRemoteDataSource.kt new file mode 100644 index 00000000..4829638c --- /dev/null +++ b/app/src/test/java/com/sopt/clody/datasource/FakeDiaryRemoteDataSource.kt @@ -0,0 +1,78 @@ +package com.sopt.clody.datasource + +import com.sopt.clody.data.remote.datasource.DiaryRemoteDataSource +import com.sopt.clody.data.remote.dto.base.ApiResponse +import com.sopt.clody.data.remote.dto.request.SaveDraftDiaryRequestDto +import com.sopt.clody.data.remote.dto.response.DailyDiariesResponseDto +import com.sopt.clody.data.remote.dto.response.DiaryTimeResponseDto +import com.sopt.clody.data.remote.dto.response.DraftDiariesResponseDto +import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto +import com.sopt.clody.data.remote.dto.response.MonthlyDiaryResponseDto +import com.sopt.clody.data.remote.dto.response.ReplyDiaryResponseDto +import com.sopt.clody.data.remote.dto.response.WriteDiaryResponseDto + +class FakeDiaryRemoteDataSource : DiaryRemoteDataSource { + + var draftDiariesResponse: ApiResponse? = null + var saveDraftResponse: ApiResponse? = null + + override suspend fun writeDiary(date: String, content: List): ApiResponse { + throw NotImplementedError() + } + + override suspend fun deleteDailyDiary(year: Int, month: Int, date: Int): ApiResponse { + throw NotImplementedError() + } + + override suspend fun getDailyDiariesData(year: Int, month: Int, date: Int): ApiResponse { + throw NotImplementedError() + } + + override suspend fun getDiaryTime(year: Int, month: Int, date: Int): ApiResponse { + throw NotImplementedError() + } + + override suspend fun getMonthlyCalendarData(year: Int, month: Int): ApiResponse { + throw NotImplementedError() + } + + override suspend fun getMonthlyDiary(year: Int, month: Int): ApiResponse { + throw NotImplementedError() + } + + override suspend fun getReplyDiary(year: Int, month: Int, date: Int): ApiResponse { + throw NotImplementedError() + } + + override suspend fun fetchDraftDiary( + year: Int, + month: Int, + date: Int, + ): ApiResponse { + return draftDiariesResponse + ?: throw IllegalStateException("draftDiariesResponse not set") + } + + override suspend fun saveDraftDiary( + request: SaveDraftDiaryRequestDto, + ): ApiResponse { + return saveDraftResponse + ?: throw IllegalStateException("saveDraftResponse not set") + } + + fun setDraftDiariesResponse(list: List) { + draftDiariesResponse = ApiResponse( + status = 200, + message = "성공", + data = DraftDiariesResponseDto(draftDiaries = list), + ) + } + + fun setSaveDraftDiaryResponse(createdAt: String) { + saveDraftResponse = ApiResponse( + status = 201, + message = "성공", + data = Unit, + ) + } +} diff --git a/app/src/test/java/com/sopt/clody/repositoryimpl/DiaryRepositoryImplTest.kt b/app/src/test/java/com/sopt/clody/repositoryimpl/DiaryRepositoryImplTest.kt new file mode 100644 index 00000000..6acd516d --- /dev/null +++ b/app/src/test/java/com/sopt/clody/repositoryimpl/DiaryRepositoryImplTest.kt @@ -0,0 +1,59 @@ +package com.sopt.clody.repositoryimpl + +import com.sopt.clody.data.repositoryimpl.DiaryRepositoryImpl +import com.sopt.clody.datasource.FakeDiaryRemoteDataSource +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe + +class DiaryRepositoryImplTest : BehaviorSpec({ + + Given("임시저장 다이어리 조회 기능") { + + val fakeDiaryRemoteDataSource = FakeDiaryRemoteDataSource() + val diaryRepository = DiaryRepositoryImpl(fakeDiaryRemoteDataSource) + + When("유효한 날짜를 전달받으면") { + + Then("해당 날짜의 임시저장 데이터를 정상적으로 반환한다") { + // arrange + val mockData = listOf("오늘도 고생했어", "하루 정리 완료") + fakeDiaryRemoteDataSource.setDraftDiariesResponse(mockData) + + // act + val result = diaryRepository.fetchDraftDiary(2025, 5, 31) + + // assert + println("fetchDraftDiary result: $result") + println("fetchDraftDiary error: ${result.exceptionOrNull()?.message}") + + result.isSuccess shouldBe true + result.getOrNull()?.draftDiaries shouldBe mockData + } + } + } + + Given("임시저장 다이어리 저장 기능") { + + val fakeDiaryRemoteDataSource = FakeDiaryRemoteDataSource() + val diaryRepository = DiaryRepositoryImpl(fakeDiaryRemoteDataSource) + + When("유효한 데이터로 저장 요청을 하면") { + + Then("정상적으로 저장된 시간 정보를 반환한다") { + // arrange + val createdAt = "2025-05-31T20:43:20.696606" + fakeDiaryRemoteDataSource.setSaveDraftDiaryResponse(createdAt) + + // act + val result = diaryRepository.saveDraftDiary(listOf("Test")) + + // assert + println("saveDraftDiary result: $result") + println("saveDraftDiary error: ${result.exceptionOrNull()?.message}") + + result.isSuccess shouldBe true + result.getOrNull()?.createdAt shouldBe createdAt + } + } + } +},) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fb65e83e..1e3c001a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,8 +47,9 @@ accompanist = "0.25.1" accompanist-insets = "0.28.0" firebase-config-ktx = "22.1.0" - mavericks = "3.0.9" +kotestVersion = "5.9.0" +mockk = "1.13.10" play-review = "2.0.2" @@ -124,6 +125,13 @@ mavericks = { module = "com.airbnb.android:mavericks", version.ref = "mavericks" mavericks-compose = { module = "com.airbnb.android:mavericks-compose", version.ref = "mavericks" } mavericks-hilt = { module = "com.airbnb.android:mavericks-hilt", version.ref = "mavericks" } +# Kotest +kotest-runner = { group = "io.kotest", name = "kotest-runner-junit5", version.ref = "kotestVersion" } +kotest-assertions = { group = "io.kotest", name = "kotest-assertions-core", version.ref = "kotestVersion" } +kotest-property = { group = "io.kotest", name = "kotest-property", version.ref = "kotestVersion" } + +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } + # PlayStore In App Review play-review = { group = "com.google.android.play", name = "review", version.ref = "play-review"} play-review-ktx = { group = "com.google.android.play", name = "review-ktx", version.ref = "play-review"} @@ -165,7 +173,6 @@ test = [ "androidx-junit", "espresso-core", "ui-test-junit4", - "coroutines-test" ] debug = [ @@ -200,6 +207,12 @@ mavericks = [ "mavericks-hilt" ] +kotest = [ + "kotest-runner", + "kotest-assertions", + "kotest-property" +] + plays = [ "play-review", "play-review-ktx"