diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 63c23985f0..96a0fbbe10 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -308,6 +308,7 @@ dependencies { androidTestImplementation(testFixtures(projects.core.ui)) androidTestImplementation(projects.feature.afternote.domain) androidTestImplementation(testFixtures(projects.feature.afternote.domain)) + androidTestImplementation(testFixtures(projects.feature.afternote.presentation)) androidTestImplementation(projects.feature.mindrecord.domain) androidTestImplementation(testFixtures(projects.feature.mindrecord.domain)) androidTestImplementation(projects.feature.receiver.domain) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt index 3d20af2511..eba20e2b69 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt @@ -25,8 +25,7 @@ import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.author.ListItem import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository -import com.afternote.feature.afternote.presentation.home.AfternoteHomeEntry -import com.afternote.feature.afternote.presentation.home.AfternoteHomeViewModel +import com.afternote.feature.afternote.presentation.testing.AfternoteHomeScreenFixture import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -66,17 +65,16 @@ class AfternoteAuthorExtendedAndroidTest { listFlows[null] = Pager(PagingConfig(pageSize = 20)) { pagingSource }.flow listFlows[AfternoteType.SOCIAL_NETWORK] = flowOf(PagingData.empty()) - val viewModel = AfternoteHomeViewModel(repository, FakeErrorReporter()) + val homeFixture = AfternoteHomeScreenFixture(repository, FakeErrorReporter()) val detailRoutes = mutableListOf() val addRoutes = mutableListOf() composeRule.setContent { AfternoteTheme { - AfternoteHomeEntry( + homeFixture.Content( navigateToDetail = detailRoutes::add, navigateToAdd = addRoutes::add, onSettingClick = {}, - viewModel = viewModel, ) } } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt index 20b113c583..1902fc2cbe 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt @@ -20,8 +20,7 @@ import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.author.ListItem import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository -import com.afternote.feature.afternote.presentation.home.AfternoteHomeEntry -import com.afternote.feature.afternote.presentation.home.AfternoteHomeViewModel +import com.afternote.feature.afternote.presentation.testing.AfternoteHomeScreenFixture import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -78,15 +77,14 @@ class AfternoteHomeRefreshBannerAndroidTest { }.flow } } - val viewModel = AfternoteHomeViewModel(repository, FakeErrorReporter()) + val homeFixture = AfternoteHomeScreenFixture(repository, FakeErrorReporter()) composeRule.setContent { AfternoteTheme { - AfternoteHomeEntry( + homeFixture.Content( navigateToDetail = {}, navigateToAdd = {}, onSettingClick = {}, - viewModel = viewModel, ) } } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt index 5865b76162..1f3b966bbf 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt @@ -45,9 +45,7 @@ import com.afternote.core.ui.Route import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock -import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailRoute -import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel -import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteRoute +import com.afternote.feature.afternote.presentation.testing.ReceivedAfternoteDetailScreenFixture import com.afternote.feature.home.presentation.HomeTabActions import com.afternote.feature.home.presentation.receiver.ReceiverHomeEvent import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository @@ -560,20 +558,19 @@ class ReceiverRuntimeCompletionAndroidTest { ), ), ) - val viewModel = - ReceivedAfternoteDetailViewModel( - route = ReceivedAfternoteRoute.DetailRoute(afternoteId = 202L), + val detailFixture = + ReceivedAfternoteDetailScreenFixture( + afternoteId = 202L, receiverRepository = repository, errorReporter = FakeErrorReporter(), ) composeRule.setContent { AfternoteTheme { - ReceivedAfternoteDetailRoute( + detailFixture.Content( onNavigateBack = {}, onNavigateToFullList = {}, onNavigateToPlaylist = {}, - viewModel = viewModel, ) } } @@ -610,9 +607,9 @@ class ReceiverRuntimeCompletionAndroidTest { ), ), ) - val viewModel = - ReceivedAfternoteDetailViewModel( - route = ReceivedAfternoteRoute.DetailRoute(afternoteId = 303L), + val detailFixture = + ReceivedAfternoteDetailScreenFixture( + afternoteId = 303L, receiverRepository = repository, errorReporter = FakeErrorReporter(), ) @@ -620,11 +617,10 @@ class ReceiverRuntimeCompletionAndroidTest { composeRule.setContent { AfternoteTheme { - ReceivedAfternoteDetailRoute( + detailFixture.Content( onNavigateBack = {}, onNavigateToFullList = {}, onNavigateToPlaylist = playlistRoutes::add, - viewModel = viewModel, ) } } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt index 8b52e0098f..aed9ab51fc 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt @@ -25,9 +25,7 @@ import com.afternote.afternote_fe.test.FakeErrorReporter import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock -import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailRoute -import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel -import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteRoute +import com.afternote.feature.afternote.presentation.testing.ReceivedAfternoteDetailScreenFixture import com.afternote.feature.receiver.domain.model.ReceivedAccountCredentials import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail import com.afternote.feature.receiver.domain.model.ReceivedExportBundle @@ -177,20 +175,19 @@ class ReceiverAdvancedAndroidTest { } detailResults.addLast(Result.failure(IllegalStateException("offline"))) detailResults.addLast(Result.success(receivedSocialDetail())) - val viewModel = - ReceivedAfternoteDetailViewModel( - route = ReceivedAfternoteRoute.DetailRoute(afternoteId = 91L), + val detailFixture = + ReceivedAfternoteDetailScreenFixture( + afternoteId = 91L, receiverRepository = repository, errorReporter = FakeErrorReporter(), ) composeRule.setContent { AfternoteTheme { - ReceivedAfternoteDetailRoute( + detailFixture.Content( onNavigateBack = {}, onNavigateToFullList = {}, onNavigateToPlaylist = {}, - viewModel = viewModel, ) } } diff --git a/feature/afternote/presentation/build.gradle.kts b/feature/afternote/presentation/build.gradle.kts index 0912d42ae5..dbf1a4332b 100644 --- a/feature/afternote/presentation/build.gradle.kts +++ b/feature/afternote/presentation/build.gradle.kts @@ -9,6 +9,7 @@ plugins { android { namespace = "com.afternote.feature.afternote.presentation" resourcePrefix = "afternote_" + testFixtures.enable = true experimentalProperties["android.experimental.enableScreenshotTest"] = true buildFeatures { buildConfig = true @@ -41,6 +42,15 @@ dependencies { implementation(libs.androidx.paging.compose) implementation(libs.androidx.core.ktx) + // 앱 기기 테스트는 fixture를 통해 실제 화면을 열며 production ViewModel을 공개하지 않는다. + testFixturesImplementation(platform(libs.androidx.compose.bom)) + testFixturesImplementation(libs.androidx.compose.runtime) + testFixturesImplementation(projects.feature.afternote.domain) + testFixturesImplementation(projects.feature.receiver.domain) + testFixturesImplementation(projects.core.common) + testFixturesImplementation(projects.core.ui) + testFixturesImplementation(libs.androidx.navigation3.runtime) + testImplementation(libs.coroutines.test) testImplementation(testFixtures(projects.core.domain)) testImplementation(testFixtures(projects.feature.afternote.domain)) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostContract.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostContract.kt new file mode 100644 index 0000000000..89e0148dac --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostContract.kt @@ -0,0 +1,21 @@ +package com.afternote.feature.afternote.presentation + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent +import com.afternote.core.ui.mvi.UiState + +internal data class AfternoteHostUiState( + val isPasskeyRegistered: Boolean? = null, +) : UiState + +internal sealed interface AfternoteHostIntent : MviIntent { + data object ObserveProfile : AfternoteHostIntent + + data object StopObservingProfile : AfternoteHostIntent +} + +internal sealed interface AfternoteHostReducerEvent : ReducerEvent { + data class PasskeyRegistrationChanged( + val registered: Boolean, + ) : AfternoteHostReducerEvent +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostViewModel.kt index c2ae400e8e..2fa9243d76 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostViewModel.kt @@ -1,12 +1,12 @@ package com.afternote.feature.afternote.presentation -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.domain.repository.UserProfileCacheRepository +import com.afternote.core.ui.mvi.MviViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import javax.inject.Inject /** @@ -17,19 +17,46 @@ import javax.inject.Inject * 에디터 flow 상태는 flow-scoped * [com.afternote.feature.afternote.presentation.editor.AfternoteEditorViewModel]이 담당한다. * 본 ViewModel은 애프터노트 로컬 스택 전체에서 공유하는 사용자 상태만 보유한다. + * 프로필 구독은 마지막 화면이 떠난 뒤 기존 WhileSubscribed와 같은 5초 유예를 둔다. */ @HiltViewModel -class AfternoteHostViewModel +internal class AfternoteHostViewModel @Inject constructor( - userProfileRepository: UserProfileCacheRepository, - ) : ViewModel() { - val isPasskeyRegistered: StateFlow = - userProfileRepository - .isPasskeyRegisteredFlow() - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = null, - ) + private val userProfileRepository: UserProfileCacheRepository, + ) : MviViewModel(AfternoteHostUiState()) { + private var profileJob: Job? = null + private var stopJob: Job? = null + + override fun onIntent(intent: AfternoteHostIntent) { + when (intent) { + AfternoteHostIntent.ObserveProfile -> { + stopJob?.cancel() + if (profileJob?.isActive == true) return + profileJob = + viewModelScope.launch { + userProfileRepository.isPasskeyRegisteredFlow().collect { + dispatch(AfternoteHostReducerEvent.PasskeyRegistrationChanged(it)) + } + } + } + + AfternoteHostIntent.StopObservingProfile -> { + stopJob?.cancel() + stopJob = + viewModelScope.launch { + delay(5_000) + profileJob?.cancel() + } + } + } + } + + override fun reduce( + state: AfternoteHostUiState, + event: AfternoteHostReducerEvent, + ): AfternoteHostUiState = + when (event) { + is AfternoteHostReducerEvent.PasskeyRegistrationChanged -> state.copy(isPasskeyRegistered = event.registered) + } } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContent.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContent.kt new file mode 100644 index 0000000000..ec48b68095 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContent.kt @@ -0,0 +1,161 @@ +package com.afternote.feature.afternote.presentation.detail + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import com.afternote.core.ui.loading.LoadingBody +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.presentation.R +import com.afternote.feature.afternote.presentation.detail.account.AccountDetailScreen +import com.afternote.feature.afternote.presentation.shared.detail.DesignPendingDetailContent +import com.afternote.feature.afternote.presentation.shared.detail.DetailLoadErrorContent + +/** 상세 상태의 모든 렌더 분기. 상태 수집과 일회성 결과 소비는 Navigation이 담당한다. */ +@Composable +internal fun AfternoteDetailContent( + uiState: AfternoteDetailUiState, + onIntent: (AfternoteDetailIntent) -> Unit, + snackbarHostState: SnackbarHostState, + onNavigateBack: () -> Unit, + onNavigateToEditor: (Long, AfternoteType) -> Unit, + onVideoClick: (String) -> Unit, +) { + when (val state = uiState) { + AfternoteDetailUiState.Loading -> { + LoadingBody() + } + + is AfternoteDetailUiState.Error -> { + DetailLoadErrorContent( + messageRes = state.messageRes, + onBackClick = onNavigateBack, + onRetryClick = { onIntent(AfternoteDetailIntent.Retry) }, + ) + } + + is AfternoteDetailUiState.Success -> { + AfternoteDetailSuccessContent( + state = state, + snackbarHostState = snackbarHostState, + onBackClick = onNavigateBack, + onNavigateToEditor = onNavigateToEditor, + onDeleteConfirm = { onIntent(AfternoteDetailIntent.Delete) }, + onVideoClick = onVideoClick, + ) + } + } +} + +@Composable +private fun AfternoteDetailSuccessContent( + state: AfternoteDetailUiState.Success, + snackbarHostState: SnackbarHostState, + onBackClick: () -> Unit, + onNavigateToEditor: (itemId: Long, type: AfternoteType) -> Unit, + onDeleteConfirm: () -> Unit, + onVideoClick: (String) -> Unit, +) { + Box { + when (val model = state.contentUiModel) { + is DetailContentUiModel.SocialNetwork -> { + AccountDetailScreen( + content = model.content, + snackbarHostState = snackbarHostState, + onBackClick = onBackClick, + onEditClick = { + onNavigateToEditor(state.detailId, model.type) + }, + onDeleteConfirm = onDeleteConfirm, + ) + } + + is DetailContentUiModel.Business -> { + AccountDetailScreen( + content = model.content, + snackbarHostState = snackbarHostState, + onBackClick = onBackClick, + onEditClick = { + onNavigateToEditor(state.detailId, model.type) + }, + onDeleteConfirm = onDeleteConfirm, + ) + } + + is DetailContentUiModel.Gallery -> { + GalleryDetailScreen( + content = model.content, + snackbarHostState = snackbarHostState, + onBackClick = onBackClick, + onEditClick = { + onNavigateToEditor(state.detailId, model.type) + }, + onDeleteConfirm = onDeleteConfirm, + ) + } + + is DetailContentUiModel.Memorial -> { + MemorialDetailScreen( + content = model.content, + userName = state.authorDisplayName, + snackbarHostState = snackbarHostState, + onBackClick = onBackClick, + onEditClick = { + onNavigateToEditor(state.detailId, model.type) + }, + onDeleteConfirm = onDeleteConfirm, + onVideoClick = onVideoClick, + ) + } + + DetailContentUiModel.Unimplemented -> { + DesignPendingDetailContent(onBackClick = onBackClick) + } + } + + if (state.isDeleting) { + DeleteInProgressOverlay() + } + } +} + +/** + * 삭제 진행([AfternoteDetailUiState.Success.isDeleting]) 동안 상세 화면 위에 겹쳐 그리는 오버레이. + * + * 반투명 스크림 + 중앙 진행 인디케이터. 중복 delete 호출은 ViewModel 이 이미 가드하므로 + * 여기서는 시각 표시와 입력 차단만 담당한다. + * + * 포인터 차단만으로는 부족하다 — 스크림에 semantics 가 없으면 접근성 트리에서 잘리지 않아 + * 아래 상세 화면의 뒤로·수정·삭제 버튼이 스크린리더로는 그대로 탐색·활성화된다. + * 그래서 스크림 자체를 병합 노드로 만들어 모달성을 유지한다. + */ +@Composable +private fun DeleteInProgressOverlay() { + val deletingDescription = stringResource(R.string.afternote_detail_deleting) + Box( + modifier = + Modifier + .fillMaxSize() + .background(AfternoteDesign.colors.black.copy(alpha = 0.3f)) + .pointerInput(Unit) { detectTapGestures {} } + .semantics(mergeDescendants = true) { + contentDescription = deletingDescription + liveRegion = LiveRegionMode.Polite + }, + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContract.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContract.kt new file mode 100644 index 0000000000..deb0e200b1 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContract.kt @@ -0,0 +1,40 @@ +package com.afternote.feature.afternote.presentation.detail + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +internal sealed interface AfternoteDetailIntent : MviIntent { + data object Retry : AfternoteDetailIntent + + data object RefreshOnReturn : AfternoteDetailIntent + + data object Delete : AfternoteDetailIntent + + data object ConsumeDeleteResult : AfternoteDetailIntent +} + +internal sealed interface AfternoteDetailReducerEvent : ReducerEvent { + data object Loading : AfternoteDetailReducerEvent + + data class ContentLoaded( + val id: Long, + val content: DetailContentUiModel, + val authorName: String, + ) : AfternoteDetailReducerEvent + + data class LoadFailed( + val keepsContent: Boolean, + ) : AfternoteDetailReducerEvent + + data object DeleteStarted : AfternoteDetailReducerEvent + + data class DeleteFinished( + val result: AfternoteDetailDeleteResult, + ) : AfternoteDetailReducerEvent + + data object DeleteResultConsumed : AfternoteDetailReducerEvent + + data class AuthorNameChanged( + val name: String, + ) : AfternoteDetailReducerEvent +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailRoute.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailRoute.kt index 75540b9afd..30b971f198 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailRoute.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailRoute.kt @@ -1,80 +1,21 @@ package com.afternote.feature.afternote.presentation.detail -import androidx.annotation.StringRes -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.LiveRegionMode -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.liveRegion -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.common.media.launchMemorialVideo -import com.afternote.core.ui.loading.LoadingBody -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.presentation.R -import com.afternote.feature.afternote.presentation.detail.account.AccountDetailScreen -import com.afternote.feature.afternote.presentation.shared.detail.DesignPendingDetailContent -import com.afternote.feature.afternote.presentation.shared.detail.DetailLoadErrorContent import kotlinx.coroutines.launch -/** - * 삭제 진행([AfternoteDetailUiState.Success.isDeleting]) 동안 상세 화면 위에 겹쳐 그리는 오버레이. - * - * 반투명 스크림 + 중앙 진행 인디케이터. 중복 delete 호출은 ViewModel 이 이미 가드하므로 - * 여기서는 시각 표시와 입력 차단만 담당한다. - * - * 포인터 차단만으로는 부족하다 — 스크림에 semantics 가 없으면 접근성 트리에서 잘리지 않아 - * 아래 상세 화면의 뒤로·수정·삭제 버튼이 스크린리더로는 그대로 탐색·활성화된다. - * 그래서 스크림 자체를 병합 노드로 만들어 모달성을 유지한다. - */ -@Composable -internal fun DeleteInProgressOverlay() { - val deletingDescription = stringResource(R.string.afternote_detail_deleting) - Box( - modifier = - Modifier - .fillMaxSize() - .background(AfternoteDesign.colors.black.copy(alpha = 0.3f)) - .pointerInput(Unit) { detectTapGestures {} } - .semantics(mergeDescendants = true) { - contentDescription = deletingDescription - liveRegion = LiveRegionMode.Polite - }, - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } -} - /** * 삭제 실패 메시지를 [snackbarHostState] Snackbar 로 표출하는 [ObserveDeleteResult] 용 `onDeleteFailed` 핸들러. * @@ -113,7 +54,7 @@ internal fun rememberDeleteFailedHandler(snackbarHostState: SnackbarHostState): * - [AfternoteDetailDeleteResult.Failed] → [onDeleteFailed] (에러 UI는 화면별 Snackbar/Dialog 책임. * 보통 [rememberDeleteFailedHandler] 를 전달한다. 무음 삼킴 방지를 위해 필수 파라미터.) * - * 처리 후 [onConsumed] 콜백으로 ViewModel 의 [AfternoteDetailViewModel.onDeleteResultConsumed] + * 처리 후 [onConsumed] 콜백으로 ViewModel 의 [AfternoteDetailIntent.ConsumeDeleteResult] * 호출 → state reset (재합성 시 중복 처리 방지). */ @Composable @@ -158,112 +99,24 @@ internal fun AfternoteDetailNavigation( // 로딩을 방출하지 않는 refreshOnReturn() 을 쓴다. 첫 진입의 ON_RESUME 스킵(진입은 init // 로드가 담당)과 실행 중 로드와의 중복 차단은 VM 이 판단한다 — 결선부는 무조건 부른다. LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - viewModel.refreshOnReturn() + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) } ObserveDeleteResult( deleteResult = (uiState as? AfternoteDetailUiState.Success)?.deleteResult, - onConsumed = viewModel::onDeleteResultConsumed, + onConsumed = { viewModel.onIntent(AfternoteDetailIntent.ConsumeDeleteResult) }, onDeleteSucceeded = onNavigateBack, onDeleteFailed = rememberDeleteFailedHandler(snackbarHostState), ) - when (val state = uiState) { - AfternoteDetailUiState.Loading -> { - LoadingBody() - } - - is AfternoteDetailUiState.Error -> { - DetailLoadErrorContent( - messageRes = state.messageRes, - onBackClick = onNavigateBack, - onRetryClick = viewModel::retry, - ) - } - - is AfternoteDetailUiState.Success -> { - AfternoteDetailSuccessContent( - state = state, - snackbarHostState = snackbarHostState, - onBackClick = onNavigateBack, - onNavigateToEditor = onNavigateToEditor, - onDeleteConfirm = viewModel::deleteAfternote, - onVideoClick = onVideoClick, - ) - } - } -} - -@Composable -private fun AfternoteDetailSuccessContent( - state: AfternoteDetailUiState.Success, - snackbarHostState: SnackbarHostState, - onBackClick: () -> Unit, - onNavigateToEditor: (itemId: Long, type: AfternoteType) -> Unit, - onDeleteConfirm: () -> Unit, - onVideoClick: (String) -> Unit, -) { - Box { - when (val model = state.contentUiModel) { - is DetailContentUiModel.SocialNetwork -> { - AccountDetailScreen( - content = model.content, - snackbarHostState = snackbarHostState, - onBackClick = onBackClick, - onEditClick = { - onNavigateToEditor(state.detailId, model.type) - }, - onDeleteConfirm = onDeleteConfirm, - ) - } - - is DetailContentUiModel.Business -> { - AccountDetailScreen( - content = model.content, - snackbarHostState = snackbarHostState, - onBackClick = onBackClick, - onEditClick = { - onNavigateToEditor(state.detailId, model.type) - }, - onDeleteConfirm = onDeleteConfirm, - ) - } - - is DetailContentUiModel.Gallery -> { - GalleryDetailScreen( - content = model.content, - snackbarHostState = snackbarHostState, - onBackClick = onBackClick, - onEditClick = { - onNavigateToEditor(state.detailId, model.type) - }, - onDeleteConfirm = onDeleteConfirm, - ) - } - - is DetailContentUiModel.Memorial -> { - MemorialDetailScreen( - content = model.content, - userName = state.authorDisplayName, - snackbarHostState = snackbarHostState, - onBackClick = onBackClick, - onEditClick = { - onNavigateToEditor(state.detailId, model.type) - }, - onDeleteConfirm = onDeleteConfirm, - onVideoClick = onVideoClick, - ) - } - - DetailContentUiModel.Unimplemented -> { - DesignPendingDetailContent(onBackClick = onBackClick) - } - } - - if (state.isDeleting) { - DeleteInProgressOverlay() - } - } + AfternoteDetailContent( + uiState = uiState, + onIntent = viewModel::onIntent, + snackbarHostState = snackbarHostState, + onNavigateBack = onNavigateBack, + onNavigateToEditor = onNavigateToEditor, + onVideoClick = onVideoClick, + ) } @Composable diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailUiState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailUiState.kt index bfee97b9af..52ea6d5c3b 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailUiState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailUiState.kt @@ -1,6 +1,7 @@ package com.afternote.feature.afternote.presentation.detail import androidx.annotation.StringRes +import com.afternote.core.ui.mvi.UiState /** * 애프터노트 상세 화면 UI 상태. @@ -10,13 +11,13 @@ import androidx.annotation.StringRes * 작성자 표시명은 상세와 다른 출처(내 프로필)라 [DetailContentUiModel] 안에 섞지 않고 나란히 둔다 — * 섞으면 늦게 도착한 이름을 반영할 때 상세 원본으로 되돌아가 다시 매핑해야 한다. * 삭제 결과(성공/실패)는 [Success.deleteResult] 에 흡수 — UI 가 LaunchedEffect 로 소비 후 - * [AfternoteDetailViewModel.onDeleteResultConsumed] 로 reset. + * [AfternoteDetailIntent.ConsumeDeleteResult] 로 reset. * - [Error] 상세 데이터 조회 실패. 표시 문구는 [Error.messageRes] 하나로만 운반한다. * 예외 원문(`Throwable.message`)은 UI 에 싣지 않는다 — 서버 5xx 본문에 내부 SQL 이, * 역직렬화 실패 메시지에 응답 원문 발췌·DTO 클래스명이 섞여 오기 때문이다 * (`ApiException` 도 `message` 는 로그 전용이고 사용자 노출용으로 `serverMessage` 를 따로 둔다). */ -sealed interface AfternoteDetailUiState { +internal sealed interface AfternoteDetailUiState : UiState { data object Loading : AfternoteDetailUiState data class Success( @@ -28,7 +29,7 @@ sealed interface AfternoteDetailUiState { * 그때 화면은 이름 세그먼트를 생략해 렌더한다 (현재 소비처는 추억 노트 상세 제목 하나). */ val authorDisplayName: String = "", - /** 삭제 결과 신호 — UI 가 LaunchedEffect 로 소비 후 [AfternoteDetailViewModel.onDeleteResultConsumed] 로 reset. */ + /** 삭제 결과 신호 — UI 가 LaunchedEffect 로 소비 후 [AfternoteDetailIntent.ConsumeDeleteResult] 로 reset. */ val deleteResult: AfternoteDetailDeleteResult? = null, ) : AfternoteDetailUiState diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModel.kt index ede9505a1d..53f79ec3db 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModel.kt @@ -1,11 +1,11 @@ package com.afternote.feature.afternote.presentation.detail -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.repository.MyProfileRepository import com.afternote.core.domain.repository.UserProfileCacheRepository import com.afternote.core.domain.result.runCatchingCancellable +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute @@ -17,10 +17,6 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -35,14 +31,14 @@ import kotlinx.coroutines.launch * * [AfternoteDetailUiState] 를 그대로 들고 [uiState] 로 노출한다 — Loading/Success/Error 3분기. * 삭제 결과(성공/실패)는 [AfternoteDetailUiState.Success.deleteResult] nullable 필드에 흡수한다 — - * UI 가 LaunchedEffect 로 소비한 뒤 [onDeleteResultConsumed] 로 reset. + * UI 가 LaunchedEffect 로 소비한 뒤 [AfternoteDetailIntent.ConsumeDeleteResult] 로 reset. * * 사용자 가시 메시지는 VM 에 하드코딩하지 않고 [androidx.annotation.StringRes] id 로만 노출한다. * 실패 시 예외 원문(`Throwable.message`)은 UI 로 넘기지 않는다 — 서버 5xx 본문·역직렬화 예외에 * 내부 SQL·응답 원문 발췌가 섞여 오므로 사용자에게 노출하면 안 된다. */ @HiltViewModel(assistedFactory = AfternoteDetailViewModel.Factory::class) -class AfternoteDetailViewModel +internal class AfternoteDetailViewModel @AssistedInject constructor( @Assisted private val route: AfternoteRoute.DetailRoute, @@ -50,12 +46,66 @@ class AfternoteDetailViewModel private val myProfileRepository: MyProfileRepository, private val userProfileRepository: UserProfileCacheRepository, private val errorReporter: ErrorReporter, - ) : ViewModel() { + ) : MviViewModel(AfternoteDetailUiState.Loading) { private val afternoteIdFromNav: Long = route.itemId - private val _uiState = MutableStateFlow(AfternoteDetailUiState.Loading) - val uiState: StateFlow = _uiState.asStateFlow() + override fun onIntent(intent: AfternoteDetailIntent) { + when (intent) { + AfternoteDetailIntent.Retry -> retry() + AfternoteDetailIntent.RefreshOnReturn -> refreshOnReturn() + AfternoteDetailIntent.Delete -> deleteAfternote() + AfternoteDetailIntent.ConsumeDeleteResult -> dispatch(AfternoteDetailReducerEvent.DeleteResultConsumed) + } + } + + override fun reduce( + state: AfternoteDetailUiState, + event: AfternoteDetailReducerEvent, + ): AfternoteDetailUiState = + when (event) { + AfternoteDetailReducerEvent.Loading -> { + AfternoteDetailUiState.Loading + } + + is AfternoteDetailReducerEvent.ContentLoaded -> { + if (state is AfternoteDetailUiState.Success) { + state.copy(detailId = event.id, contentUiModel = event.content, authorDisplayName = event.authorName) + } else { + AfternoteDetailUiState.Success( + detailId = event.id, + contentUiModel = event.content, + authorDisplayName = event.authorName, + ) + } + } + + is AfternoteDetailReducerEvent.LoadFailed -> { + if (event.keepsContent && + state is AfternoteDetailUiState.Success + ) { + state + } else { + AfternoteDetailUiState.Error(R.string.afternote_detail_load_error) + } + } + + AfternoteDetailReducerEvent.DeleteStarted -> { + state.withSuccess { it.copy(isDeleting = true) } + } + + is AfternoteDetailReducerEvent.DeleteFinished -> { + state.withSuccess { it.copy(isDeleting = false, deleteResult = event.result) } + } + + AfternoteDetailReducerEvent.DeleteResultConsumed -> { + state.withSuccess { it.copy(deleteResult = null) } + } + + is AfternoteDetailReducerEvent.AuthorNameChanged -> { + state.withSuccess { it.copy(authorDisplayName = event.name) } + } + } /** * 작성자 표시명 — 상세와 별도로 조회된다. 도착 전에는 빈 문자열이고, 도착하면 보고 있는 Success 와 @@ -116,7 +166,7 @@ class AfternoteDetailViewModel * [refreshOnReturn] 과 달리 사용자가 직접 누른 동작이라, 진행 중인 로드가 있어도 건너뛰지 않는다 — * 누른 결과가 조용히 사라지면 안 되므로 그 로드를 자르고 새로 시작한다. */ - fun retry() { + private fun retry() { loadDetail(afternoteIdFromNav, LoadTrigger.UserRequested) } @@ -131,7 +181,7 @@ class AfternoteDetailViewModel * 첫 ON_RESUME(진입 자체)은 [isFirstResume] 로 스킵하고, 그 이후의 resume 이 실행 중인 * 로드와 겹치면(빠른 resume 연타 등) 진행 중인 Job 으로 건너뛴다. */ - fun refreshOnReturn() { + private fun refreshOnReturn() { if (isFirstResume) { isFirstResume = false return @@ -171,7 +221,7 @@ class AfternoteDetailViewModel loadJob = viewModelScope.launch { if (showsLoading) { - _uiState.value = AfternoteDetailUiState.Loading + dispatch(AfternoteDetailReducerEvent.Loading) } val result = afternoteRepository.getDetail(id = afternoteId) // 잘린 로드가 값을 들고 돌아와 새 화면을 덮는 창을 닫는다. repository 는 취소를 다시 @@ -181,37 +231,12 @@ class AfternoteDetailViewModel .onSuccess { detail -> // 매핑은 update 밖에서 한 번만 한다 — update 의 람다는 경합 시 재실행된다. val contentUiModel = detail.toDetailContentUiModel() - _uiState.update { current -> - // 갱신은 «상세 부분만» 바꾼다. 진행 중인 삭제(isDeleting)와 미소비 삭제 - // 결과(deleteResult)는 이 로드와 무관한 다른 작업의 상태라, 새 Success 로 - // 덮으면 그 기본값이 삭제 진행 표시를 풀고 결과 안내를 지운다. - if (current is AfternoteDetailUiState.Success) { - current.copy( - detailId = detail.id, - contentUiModel = contentUiModel, - authorDisplayName = authorDisplayName, - ) - } else { - AfternoteDetailUiState.Success( - detailId = detail.id, - contentUiModel = contentUiModel, - authorDisplayName = authorDisplayName, - ) - } - } + dispatch(AfternoteDetailReducerEvent.ContentLoaded(detail.id, contentUiModel, authorDisplayName)) }.onFailure { e -> // 화면을 유지하는 자동 갱신 실패도 기록한다 — 사용자에게 안 보이는 만큼 // 콘솔이 유일한 관측 지점이다. errorReporter.recordAfternoteFailure(AfternoteFailureStage.DETAIL_LOAD, e) - _uiState.update { current -> - if (keepsStateOnFailure && current is AfternoteDetailUiState.Success) { - current - } else { - AfternoteDetailUiState.Error( - messageRes = R.string.afternote_detail_load_error, - ) - } - } + dispatch(AfternoteDetailReducerEvent.LoadFailed(keepsStateOnFailure)) } } } @@ -219,7 +244,7 @@ class AfternoteDetailViewModel /** * 삭제는 상세를 보고 있을 때만 뜻이 있다 — 그 전제를 상태에서 직접 확인한다. * - * 상태 갱신은 [updateSuccess] 가 Success 밖에서 no-op 이라 이미 안전하지만, **서버 호출은 아니다.** + * 상태 갱신은 [reduce] 가 Success 밖에서 no-op 이라 이미 안전하지만, **서버 호출은 아니다.** * Success 가 아닐 때 이 함수가 불리면 노트는 지워지는데 UI 는 아무것도 모른다(진행 표시도, * 결과 안내도, 화면 pop 도 없다). 중복 호출 가드도 non-Success 에서는 늘 통과한다. * 지금은 결선상 Success 에서만 호출되지만, 그 불변식은 VM 밖(내비게이션 분기)에 있다. @@ -227,40 +252,27 @@ class AfternoteDetailViewModel * 지울 id 도 인자로 받지 않고 그 Success 에서 꺼낸다 — 화면에 보이는 것과 다른 항목을 지우는 * 경우를 시그니처에서 없앤다. 호출부가 넘기던 값도 같은 Success 의 `detailId` 였다. */ - fun deleteAfternote() { - val current = _uiState.value as? AfternoteDetailUiState.Success ?: return + private fun deleteAfternote() { + val current = currentState as? AfternoteDetailUiState.Success ?: return if (current.isDeleting) return val afternoteId = current.detailId viewModelScope.launch { - updateSuccess { it.copy(isDeleting = true) } + dispatch(AfternoteDetailReducerEvent.DeleteStarted) afternoteRepository .delete(id = afternoteId) .onSuccess { - updateSuccess { - it.copy( - isDeleting = false, - deleteResult = AfternoteDetailDeleteResult.Succeeded(afternoteId), - ) - } + dispatch(AfternoteDetailReducerEvent.DeleteFinished(AfternoteDetailDeleteResult.Succeeded(afternoteId))) }.onFailure { e -> errorReporter.recordAfternoteFailure(AfternoteFailureStage.DETAIL_DELETE, e) - updateSuccess { - it.copy( - isDeleting = false, - deleteResult = - AfternoteDetailDeleteResult.Failed( - messageRes = R.string.afternote_detail_delete_failed, - ), - ) - } + dispatch( + AfternoteDetailReducerEvent.DeleteFinished( + AfternoteDetailDeleteResult.Failed(R.string.afternote_detail_delete_failed), + ), + ) } } } - fun onDeleteResultConsumed() { - updateSuccess { it.copy(deleteResult = null) } - } - // endregion // region State shaping @@ -268,18 +280,7 @@ class AfternoteDetailViewModel /** 늦게 도착한 작성자 표시명을 반영한다. 아직 Loading 이면 필드에만 남아 다음 Success 에 실린다. */ private fun applyAuthorDisplayName(name: String) { authorDisplayName = name - updateSuccess { it.copy(authorDisplayName = name) } - } - - /** 삭제 진행·결과는 Success 에만 존재하므로, 그 외 상태에서는 갱신하지 않는다. */ - private fun updateSuccess(transform: (AfternoteDetailUiState.Success) -> AfternoteDetailUiState) { - _uiState.update { current -> - if (current is AfternoteDetailUiState.Success) { - transform(current) - } else { - current - } - } + dispatch(AfternoteDetailReducerEvent.AuthorNameChanged(name)) } // endregion @@ -289,3 +290,8 @@ class AfternoteDetailViewModel fun create(route: AfternoteRoute.DetailRoute): AfternoteDetailViewModel } } + +/** 상세 재조회가 삭제 진행·미소비 결과를 덮지 않도록 Success의 해당 필드만 바꾼다. */ +private inline fun AfternoteDetailUiState.withSuccess( + transform: (AfternoteDetailUiState.Success) -> AfternoteDetailUiState, +): AfternoteDetailUiState = if (this is AfternoteDetailUiState.Success) transform(this) else this diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongContract.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongContract.kt new file mode 100644 index 0000000000..6a0e9a2fd9 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongContract.kt @@ -0,0 +1,29 @@ +package com.afternote.feature.afternote.presentation.editor.memorial + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent +import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDisplay + +internal sealed interface AddSongIntent : MviIntent { + data class Search( + val query: String, + ) : AddSongIntent + + data object ConsumeError : AddSongIntent +} + +internal sealed interface AddSongReducerEvent : ReducerEvent { + data class QueryChanged( + val query: String, + ) : AddSongReducerEvent + + data object SearchStarted : AddSongReducerEvent + + data class SongsLoaded( + val songs: List, + ) : AddSongReducerEvent + + data object SearchFailed : AddSongReducerEvent + + data object ErrorConsumed : AddSongReducerEvent +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongEntry.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongEntry.kt index c6c8b708c7..0a5665f424 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongEntry.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongEntry.kt @@ -1,9 +1,15 @@ package com.afternote.feature.afternote.presentation.editor.memorial +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.mvi.ObserveSignal +import kotlinx.coroutines.launch /** * 노래 추가 Entry. @@ -11,7 +17,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle * ViewModel에서 데이터를 로드·가공하고, Screen에 전달만 합니다. */ @Composable -fun AddSongEntry( +internal fun AddSongEntry( viewModel: AddSongViewModel, onBackClick: () -> Unit, onSongsAdded: (List) -> Unit, @@ -19,10 +25,21 @@ fun AddSongEntry( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val resources = LocalResources.current + val scope = rememberCoroutineScope() + ObserveSignal( + signal = uiState.errorRes, + consumed = AddSongIntent.ConsumeError, + onIntent = viewModel::onIntent, + ) { errorRes -> + scope.launch { snackbarHostState.showSnackbar(resources.getString(errorRes), withDismissAction = true) } + } + AddSongScreen( uiState = uiState, - onSearchQueryChange = viewModel::onSearchQueryChange, - onErrorConsumed = viewModel::onErrorConsumed, + onIntent = viewModel::onIntent, + snackbarHostState = snackbarHostState, onBackClick = onBackClick, onSongsAdded = onSongsAdded, modifier = modifier, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreen.kt index a90bc843d2..0822c5699e 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreen.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreen.kt @@ -7,10 +7,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -21,39 +18,16 @@ import com.afternote.feature.afternote.presentation.shared.detail.SongPlaylistSc import com.afternote.feature.afternote.presentation.shared.detail.SongSearchSection import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDisplay -/** - * 노래 추가하기 화면 (API 검색 연동). - * - * 공용 부품([SongPlaylistScaffold] + selectable 본문 [SelectableSongListBody])을 직접 조립하고, 이 기능 - * 고유의 것(VM 상태·검색 실패 Snackbar·PlaylistSongDisplay↔Song 매핑)을 얹는 소비자 계층이다. - * - * ViewModel 의존성 없이 순수하게 UI만 그립니다. [AddSongUiState.errorRes] 는 문자열 리소스 ID 라 - * UI 레이어가 [stringResource] 로 해석한 뒤 Snackbar 표출 → [onErrorConsumed] 로 VM 에 nullify - * 신호. VM 이 Android Framework (Context/Resources) 를 의존하지 않도록 string resolve 는 본 - * 레이어에서만 수행 (#267). 예외 원문을 실어 오던 갈래는 제거했다 — 검색 실패는 원인과 무관하게 - * 고정 안내 문구로만 노출한다 (#664). Snackbar 채택은 repo convention (Toast 2건 vs Snackbar 19건) + - * `showSnackbar` suspend 큐 의미로 같은 리소스 ID 가 연속 발화해도 표출 누락 회피. - */ +/** 검색 결과를 그린다. 상태 수집과 실패 신호 소비는 [AddSongEntry]가 담당한다. */ @Composable -fun AddSongScreen( +internal fun AddSongScreen( uiState: AddSongUiState, - onSearchQueryChange: (String) -> Unit, - onErrorConsumed: () -> Unit, + onIntent: (AddSongIntent) -> Unit, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, onBackClick: () -> Unit, onSongsAdded: (List) -> Unit, modifier: Modifier = Modifier, ) { - val currentOnErrorConsumed by rememberUpdatedState(onErrorConsumed) - val snackbarHostState = remember { SnackbarHostState() } - - uiState.errorRes?.let { errorRes -> - val message = stringResource(errorRes) - LaunchedEffect(errorRes) { - snackbarHostState.showSnackbar(message = message, withDismissAction = true) - currentOnErrorConsumed() - } - } - Box(modifier = modifier.fillMaxSize()) { SongPlaylistScaffold( title = stringResource(R.string.afternote_editor_playlist_add_screen_title), @@ -70,7 +44,7 @@ fun AddSongScreen( header = { SongSearchSection( searchQuery = uiState.searchQuery, - onSearchQueryChange = onSearchQueryChange, + onSearchQueryChange = { onIntent(AddSongIntent.Search(it)) }, ) }, initialSelectedSongKeys = emptySet(), diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongUiState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongUiState.kt index 261448b412..db588e1ecf 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongUiState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongUiState.kt @@ -1,6 +1,7 @@ package com.afternote.feature.afternote.presentation.editor.memorial import androidx.annotation.StringRes +import com.afternote.core.ui.mvi.UiState import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDisplay /** @@ -10,9 +11,9 @@ import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDis * 예외 원문·서버 응답 본문 같은 임의 문자열이 화면까지 실려 갈 경로를 타입 차원에서 없앤다 (#664). * Composable 측에서 `stringResource` 로 변환. */ -data class AddSongUiState( +internal data class AddSongUiState( val songs: List = emptyList(), val searchQuery: String = "", val isLoading: Boolean = false, @param:StringRes val errorRes: Int? = null, -) +) : UiState diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongViewModel.kt index 01fa2704ce..f92b900f4c 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongViewModel.kt @@ -1,8 +1,8 @@ package com.afternote.feature.afternote.presentation.editor.memorial -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.afternote.domain.repository.author.MusicSearchRepository import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage @@ -10,10 +10,7 @@ import com.afternote.feature.afternote.presentation.reporting.recordAfternoteFai import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds @@ -21,56 +18,72 @@ import kotlin.time.Duration.Companion.milliseconds private const val SEARCH_DEBOUNCE_MS = 300L @HiltViewModel -class AddSongViewModel +internal class AddSongViewModel @Inject constructor( private val musicSearchRepository: MusicSearchRepository, private val errorReporter: ErrorReporter, - ) : ViewModel() { - private val _uiState = MutableStateFlow(AddSongUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - + ) : MviViewModel(AddSongUiState()) { private var searchJob: Job? = null - fun onSearchQueryChange(query: String) { - _uiState.update { it.copy(searchQuery = query, errorRes = null) } + override fun onIntent(intent: AddSongIntent) { + when (intent) { + is AddSongIntent.Search -> search(intent.query) + AddSongIntent.ConsumeError -> dispatch(AddSongReducerEvent.ErrorConsumed) + } + } + + private fun search(query: String) { + dispatch(AddSongReducerEvent.QueryChanged(query)) searchJob?.cancel() searchJob = viewModelScope.launch { delay(SEARCH_DEBOUNCE_MS.milliseconds) val trimmed = query.trim() if (trimmed.isEmpty()) { - _uiState.update { it.copy(songs = emptyList(), isLoading = false) } + dispatch(AddSongReducerEvent.SongsLoaded(emptyList())) return@launch } - _uiState.update { it.copy(isLoading = true) } - musicSearchRepository - .search(trimmed) + dispatch(AddSongReducerEvent.SearchStarted) + val result = musicSearchRepository.search(trimmed) + ensureActive() + result .onSuccess { list -> - _uiState.update { - it.copy( - songs = list.map { item -> item.toDisplay() }, - isLoading = false, - errorRes = null, - ) - } - }.onFailure { e -> - // 예외 원문은 서버 5xx 본문(내부 SQL)·역직렬화 예외(응답 원문 발췌·DTO 클래스명)를 - // 그대로 실어 오므로 화면에 싣지 않는다. 사용자에겐 원인과 무관하게 고정 안내만 노출. - errorReporter.recordAfternoteFailure(AfternoteFailureStage.MUSIC_SEARCH, e) - _uiState.update { - it.copy( - songs = emptyList(), - isLoading = false, - errorRes = R.string.afternote_editor_search_failed_generic, - ) - } + dispatch(AddSongReducerEvent.SongsLoaded(list.map { it.toDisplay() })) + }.onFailure { error -> + errorReporter.recordAfternoteFailure(AfternoteFailureStage.MUSIC_SEARCH, error) + dispatch(AddSongReducerEvent.SearchFailed) } } } - /** UI 가 [AddSongUiState.errorRes] 를 사용자에게 노출 직후 호출 → state nullify. */ - fun onErrorConsumed() { - _uiState.update { it.copy(errorRes = null) } - } + override fun reduce( + state: AddSongUiState, + event: AddSongReducerEvent, + ): AddSongUiState = + when (event) { + is AddSongReducerEvent.QueryChanged -> { + state.copy(searchQuery = event.query, errorRes = null) + } + + AddSongReducerEvent.SearchStarted -> { + state.copy(isLoading = true) + } + + is AddSongReducerEvent.SongsLoaded -> { + state.copy(songs = event.songs, isLoading = false, errorRes = null) + } + + AddSongReducerEvent.SearchFailed -> { + state.copy( + songs = emptyList(), + isLoading = false, + errorRes = R.string.afternote_editor_search_failed_generic, + ) + } + + AddSongReducerEvent.ErrorConsumed -> { + state.copy(errorRes = null) + } + } } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverContract.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverContract.kt new file mode 100644 index 0000000000..73c4fdefe9 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverContract.kt @@ -0,0 +1,34 @@ +package com.afternote.feature.afternote.presentation.editor.receiver + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +internal sealed interface SelectReceiverIntent : MviIntent { + data class ApplyPreselection( + val receiverIds: List, + ) : SelectReceiverIntent + + data object Refresh : SelectReceiverIntent + + data class ToggleReceiver( + val receiverId: Long, + ) : SelectReceiverIntent +} + +internal sealed interface SelectReceiverReducerEvent : ReducerEvent { + data class PreselectionApplied( + val receiverIds: List, + ) : SelectReceiverReducerEvent + + data object Loading : SelectReceiverReducerEvent + + data class ReceiversLoaded( + val receivers: List, + ) : SelectReceiverReducerEvent + + data object LoadFailed : SelectReceiverReducerEvent + + data class ReceiverToggled( + val receiverId: Long, + ) : SelectReceiverReducerEvent +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverRoute.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverRoute.kt index d125152002..ef28cf2fd0 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverRoute.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverRoute.kt @@ -28,13 +28,13 @@ internal fun AfternoteSelectReceiverNavigation( // 진입 시점의 폼에 담겨 있던 수신자만 체크한다. 회전·재진입으로 컴포지션이 새로 만들어지면 이 효과도 // 다시 돌지만, ViewModel 이 최초 1회만 받아 사용자가 푼 체크가 되살아나지 않는다. - LaunchedEffect(Unit) { viewModel.applyPreselection(preselectedReceiverIds) } + LaunchedEffect(Unit) { viewModel.onIntent(SelectReceiverIntent.ApplyPreselection(preselectedReceiverIds)) } SelectReceiverScreen( uiState = uiState, onBackClick = onPopBackStack, - onReceiverToggle = viewModel::toggleReceiverSelection, - onRetryClick = viewModel::refresh, + onReceiverToggle = { viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(it)) }, + onRetryClick = { viewModel.onIntent(SelectReceiverIntent.Refresh) }, onConfirmClick = onReceiversConfirmed, ) } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModel.kt index 3606627b51..05c6b972f0 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModel.kt @@ -1,24 +1,21 @@ package com.afternote.feature.afternote.presentation.editor.receiver import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.domain.result.runCatchingCancellable +import com.afternote.core.ui.mvi.MviViewModel +import com.afternote.core.ui.mvi.UiState import com.afternote.feature.afternote.presentation.editor.mapper.toAfternoteEditorReceivers import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage import com.afternote.feature.afternote.presentation.reporting.recordAfternoteFailure import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject /** 수신자 선택 화면 UI 상태. */ -data class SelectReceiverUiState( +internal data class SelectReceiverUiState( val isLoading: Boolean = false, val loadFailed: Boolean = false, /** 서버에서 불러온 내 수신자 목록 — 설정 › 수신자 관리에서 등록한 그 사람들. 화면의 행이다. */ @@ -32,7 +29,7 @@ data class SelectReceiverUiState( * 빈 목록이 «아무도 선택하지 않음» 이다. null 로 없음을 표현하지 않는다 (#1426). */ val selectedReceiverIds: List = emptyList(), -) +) : UiState /** * 애프터노트 에디터의 수신자 선택 화면 ViewModel (#540). @@ -46,24 +43,71 @@ data class SelectReceiverUiState( * 화면에서 체크를 푼 수신자는 폼에서도 빠진다. * * 화면 내 선택과 «폼 수신자는 이미 넣었다» 는 사실은 [SavedStateHandle] 에 적어 둔다 (#1427). 백그라운드에서 - * 프로세스가 재생성돼도 선택이 그대로 복원되고, 복귀 시 다시 도는 [applyPreselection] 이 사용자가 푼 + * 프로세스가 재생성돼도 선택이 복원되고, 복귀 시 프리필이 사용자가 푼 * 폼 수신자를 되살리지 않는다. */ @HiltViewModel -class SelectReceiverViewModel +internal class SelectReceiverViewModel @Inject constructor( private val userReceiverRepository: UserReceiverRepository, private val errorReporter: ErrorReporter, private val savedStateHandle: SavedStateHandle, - ) : ViewModel() { - private val _uiState = - MutableStateFlow( - SelectReceiverUiState( - selectedReceiverIds = savedStateHandle.get(SELECTED_RECEIVER_IDS_STATE_KEY)?.toList().orEmpty(), - ), - ) - val uiState: StateFlow = _uiState.asStateFlow() + ) : MviViewModel( + SelectReceiverUiState( + selectedReceiverIds = savedStateHandle.get(SELECTED_RECEIVER_IDS_STATE_KEY)?.toList().orEmpty(), + ), + ) { + override fun onIntent(intent: SelectReceiverIntent) { + when (intent) { + is SelectReceiverIntent.ApplyPreselection -> applyPreselection(intent.receiverIds) + SelectReceiverIntent.Refresh -> refresh() + is SelectReceiverIntent.ToggleReceiver -> toggleReceiverSelection(intent.receiverId) + } + } + + override fun reduce( + state: SelectReceiverUiState, + event: SelectReceiverReducerEvent, + ): SelectReceiverUiState = + when (event) { + is SelectReceiverReducerEvent.PreselectionApplied -> { + state.copy( + selectedReceiverIds = + ( + event.receiverIds + + state.selectedReceiverIds + ).distinct(), + ) + } + + SelectReceiverReducerEvent.Loading -> { + state.copy(isLoading = true, loadFailed = false) + } + + is SelectReceiverReducerEvent.ReceiversLoaded -> { + state.copy( + isLoading = false, + receivers = event.receivers, + selectedReceiverIds = state.selectedReceiverIds.filter { selected -> event.receivers.any { it.id == selected } }, + ) + } + + SelectReceiverReducerEvent.LoadFailed -> { + state.copy(isLoading = false, loadFailed = true) + } + + is SelectReceiverReducerEvent.ReceiverToggled -> { + state.copy( + selectedReceiverIds = + if (event.receiverId in state.selectedReceiverIds) { + state.selectedReceiverIds - event.receiverId + } else { + state.selectedReceiverIds + event.receiverId + }, + ) + } + } private var isPreselectionApplied: Boolean get() = savedStateHandle[PRESELECTION_APPLIED_STATE_KEY] ?: false @@ -86,52 +130,33 @@ class SelectReceiverViewModel * 폼의 id 는 화면이 뜨자마자 오고 목록은 서버 응답이라 그보다 늦다. 목록에 없는 id 는 [refresh] 가 * 목록을 받으며 뺀다. */ - fun applyPreselection(formReceiverIds: List) { + private fun applyPreselection(formReceiverIds: List) { if (isPreselectionApplied) return isPreselectionApplied = true if (formReceiverIds.isEmpty()) return - _uiState.update { state -> - state.copy(selectedReceiverIds = (formReceiverIds + state.selectedReceiverIds).distinct()) - } - persistSelection(_uiState.value.selectedReceiverIds) + dispatch(SelectReceiverReducerEvent.PreselectionApplied(formReceiverIds)) + persistSelection(currentState.selectedReceiverIds) } /** 수신자 목록을 (재)조회한다. 실패 화면의 "다시 시도" 도 여기로 온다. */ - fun refresh() { + private fun refresh() { viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, loadFailed = false) } + dispatch(SelectReceiverReducerEvent.Loading) runCatchingCancellable { userReceiverRepository.getReceivers() } .onSuccess { receivers -> - _uiState.update { state -> - state.copy( - isLoading = false, - receivers = receivers.toAfternoteEditorReceivers(), - // 목록에 없는 수신자 선택은 해제한다 — 폼에서 넘어왔지만 설정에서 지워진 id, - // 재조회로 사라진 id. 남겨 두면 완료 버튼이 이미 없는 id 를 에디터로 돌려보낸다. - selectedReceiverIds = - state.selectedReceiverIds.filter { selected -> - receivers.any { it.receiverId == selected } - }, - ) - } - persistSelection(_uiState.value.selectedReceiverIds) + dispatch(SelectReceiverReducerEvent.ReceiversLoaded(receivers.toAfternoteEditorReceivers())) + persistSelection(currentState.selectedReceiverIds) }.onFailure { e -> errorReporter.recordAfternoteFailure(AfternoteFailureStage.RECEIVER_SELECT_LOAD, e) - _uiState.update { it.copy(isLoading = false, loadFailed = true) } + dispatch(SelectReceiverReducerEvent.LoadFailed) } } } /** 탭한 수신자를 선택 목록에 더하고, 이미 선택된 수신자를 다시 탭하면 그 항목만 뺀다 (#1426). */ - fun toggleReceiverSelection(receiverId: Long) { - _uiState.update { state -> - val selected = state.selectedReceiverIds - state.copy( - selectedReceiverIds = - if (receiverId in selected) selected - receiverId else selected + receiverId, - ) - } - persistSelection(_uiState.value.selectedReceiverIds) + private fun toggleReceiverSelection(receiverId: Long) { + dispatch(SelectReceiverReducerEvent.ReceiverToggled(receiverId)) + persistSelection(currentState.selectedReceiverIds) } private fun persistSelection(receiverIds: List) { diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeContract.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeContract.kt new file mode 100644 index 0000000000..12391bc0d2 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeContract.kt @@ -0,0 +1,28 @@ +package com.afternote.feature.afternote.presentation.home + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent +import com.afternote.core.ui.mvi.UiState +import com.afternote.feature.afternote.domain.AfternoteType + +internal data class AfternoteHomeUiState( + val selectedType: AfternoteType? = null, +) : UiState + +internal sealed interface AfternoteHomeIntent : MviIntent { + data class SelectType( + val type: AfternoteType?, + ) : AfternoteHomeIntent + + data class ListLoadFailed( + val throwable: Throwable, + ) : AfternoteHomeIntent + + data object ListLoadSucceeded : AfternoteHomeIntent +} + +internal sealed interface AfternoteHomeReducerEvent : ReducerEvent { + data class TypeSelected( + val type: AfternoteType?, + ) : AfternoteHomeReducerEvent +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeEntry.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeEntry.kt index c003437ab3..a9bcf4f309 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeEntry.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeEntry.kt @@ -26,13 +26,14 @@ import com.afternote.feature.afternote.presentation.R * 중복 억제는 VM 이 맡는다. */ @Composable -fun AfternoteHomeEntry( +internal fun AfternoteHomeEntry( navigateToDetail: (Long) -> Unit, navigateToAdd: (AfternoteType) -> Unit, onSettingClick: () -> Unit, viewModel: AfternoteHomeViewModel = hiltViewModel(), ) { - val selectedType by viewModel.selectedType.collectAsStateWithLifecycle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val selectedType = uiState.selectedType val items = viewModel.pagedAfternotes.collectAsLazyPagingItems() val snackbarHostState = remember { SnackbarHostState() } @@ -40,7 +41,7 @@ fun AfternoteHomeEntry( val appendErrorMessage = stringResource(R.string.afternote_home_append_error) LaunchedEffect(appendState) { if (appendState is LoadState.Error) { - viewModel.onListLoadFailed(appendState.error) + viewModel.onIntent(AfternoteHomeIntent.ListLoadFailed(appendState.error)) snackbarHostState.showSnackbar(message = appendErrorMessage) } } @@ -48,10 +49,10 @@ fun AfternoteHomeEntry( val refreshState = items.loadState.refresh LaunchedEffect(refreshState) { when (refreshState) { - is LoadState.Error -> viewModel.onListLoadFailed(refreshState.error) + is LoadState.Error -> viewModel.onIntent(AfternoteHomeIntent.ListLoadFailed(refreshState.error)) // 성공한 로드가 실패 구간을 닫는다 — 다음 실패는 새 사건으로 다시 기록된다. - is LoadState.NotLoading -> viewModel.onListLoadSucceeded() + is LoadState.NotLoading -> viewModel.onIntent(AfternoteHomeIntent.ListLoadSucceeded) is LoadState.Loading -> Unit } @@ -61,7 +62,7 @@ fun AfternoteHomeEntry( items = items, selectedType = selectedType, snackbarHostState = snackbarHostState, - onTypeSelected = viewModel::selectTab, + onTypeSelected = { viewModel.onIntent(AfternoteHomeIntent.SelectType(it)) }, onListItemClick = { id, type -> when (type) { AfternoteType.SOCIAL_NETWORK, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeViewModel.kt index d533f5a94b..c5b03ac398 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeViewModel.kt @@ -1,12 +1,11 @@ package com.afternote.feature.afternote.presentation.home -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.map import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.afternote.domain.model.author.ListItem import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage @@ -16,9 +15,7 @@ import com.afternote.feature.afternote.presentation.shared.util.getIconResForSer import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -28,30 +25,39 @@ import javax.inject.Inject * CUD 후 목록 갱신은 Repository 내부에서 자동 처리되므로 수동 refresh는 불필요하다. */ @HiltViewModel -class AfternoteHomeViewModel +internal class AfternoteHomeViewModel @Inject constructor( private val afternoteRepository: AfternoteRepository, private val errorReporter: ErrorReporter, - ) : ViewModel() { - /** 선택된 종류 필터. `null` 은 전체다. */ - private val _selectedType = MutableStateFlow(null) - val selectedType: StateFlow = _selectedType.asStateFlow() - + ) : MviViewModel(AfternoteHomeUiState()) { @OptIn(ExperimentalCoroutinesApi::class) val pagedAfternotes: Flow> = - _selectedType + uiState + .map { it.selectedType } + .distinctUntilChanged() .flatMapLatest { type -> afternoteRepository .getPagedAfternotes(type) .map { pagingData -> pagingData.map { it.toUiModel() } } }.cachedIn(viewModelScope) - fun selectTab(tab: AfternoteType?) { - if (_selectedType.value == tab) return - _selectedType.value = tab + override fun onIntent(intent: AfternoteHomeIntent) { + when (intent) { + is AfternoteHomeIntent.SelectType -> dispatch(AfternoteHomeReducerEvent.TypeSelected(intent.type)) + is AfternoteHomeIntent.ListLoadFailed -> onListLoadFailed(intent.throwable) + AfternoteHomeIntent.ListLoadSucceeded -> onListLoadSucceeded() + } } + override fun reduce( + state: AfternoteHomeUiState, + event: AfternoteHomeReducerEvent, + ): AfternoteHomeUiState = + when (event) { + is AfternoteHomeReducerEvent.TypeSelected -> state.copy(selectedType = event.type) + } + /** * 마지막으로 기록한 목록 실패의 예외 타입. 같은 장애가 이어지는 동안 재기록을 막는 기준이다 — * 예외 «인스턴스» 로 비교하면 Paging 이 재시도마다 새 예외를 만들어 중복 억제가 성립하지 않는다. @@ -67,7 +73,7 @@ class AfternoteHomeViewModel * 그래서 «장애가 이어지는 동안 한 번» 만 기록하고, 로드가 성공하면([onListLoadSucceeded]) * 다음 실패를 새 사건으로 다시 받는다. */ - fun onListLoadFailed(throwable: Throwable) { + private fun onListLoadFailed(throwable: Throwable) { val failureType = throwable.javaClass.name if (lastReportedFailureType == failureType) return lastReportedFailureType = failureType @@ -75,7 +81,7 @@ class AfternoteHomeViewModel } /** 목록 로드가 성공해 실패 구간이 끝났음을 알린다 — 다음 실패는 새 사건으로 기록된다. */ - fun onListLoadSucceeded() { + private fun onListLoadSucceeded() { lastReportedFailureType = null } } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteNavHost.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteNavHost.kt index 8e3aa08220..a2ef6e2a46 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteNavHost.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteNavHost.kt @@ -1,21 +1,27 @@ package com.afternote.feature.afternote.presentation.navigation import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import com.afternote.core.ui.navigation.FeatureNavDisplay import com.afternote.core.ui.navigation.FeatureNavigationCallbacks +import com.afternote.feature.afternote.presentation.AfternoteHostIntent import com.afternote.feature.afternote.presentation.AfternoteHostViewModel import com.afternote.feature.afternote.presentation.detail.AfternoteDetailNavigation import com.afternote.feature.afternote.presentation.detail.AfternoteDetailViewModel import com.afternote.feature.afternote.presentation.home.AfternoteHomeNavigation import com.afternote.feature.afternote.presentation.navigation.model.AfternoteRoute import com.afternote.feature.afternote.presentation.shared.fingerprint.AfternoteFingerprintLoginNavigation +import kotlinx.coroutines.awaitCancellation /** * 애프터노트 작성자 피처가 소유하는 로컬 Navigation 3 스택. @@ -55,7 +61,19 @@ public fun AfternoteNavHost( entryProvider { entry { AfternoteLightTheme { - val isPasskeyRegistered by hostViewModel.isPasskeyRegistered.collectAsStateWithLifecycle() + val hostState by hostViewModel.uiState.collectAsStateWithLifecycle() + val isPasskeyRegistered = hostState.isPasskeyRegistered + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(hostViewModel, lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + hostViewModel.onIntent(AfternoteHostIntent.ObserveProfile) + try { + awaitCancellation() + } finally { + hostViewModel.onIntent(AfternoteHostIntent.StopObservingProfile) + } + } + } AfternoteFingerprintLoginNavigation( isPasskeyRegistered = isPasskeyRegistered, onAuthenticationSuccess = actions::replaceFingerprintLoginWithAfternoteHome, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeContract.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeContract.kt new file mode 100644 index 0000000000..4c78df5f1f --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeContract.kt @@ -0,0 +1,22 @@ +package com.afternote.feature.afternote.presentation.receiver.afternotelist + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent +import com.afternote.core.ui.mvi.UiState +import com.afternote.feature.afternote.domain.AfternoteType + +internal data class ReceiverAfternoteHomeUiState( + val selectedTab: AfternoteType? = null, +) : UiState + +internal sealed interface ReceiverAfternoteHomeIntent : MviIntent { + data class SelectType( + val type: AfternoteType?, + ) : ReceiverAfternoteHomeIntent +} + +internal sealed interface ReceiverAfternoteHomeReducerEvent : ReducerEvent { + data class TypeSelected( + val type: AfternoteType?, + ) : ReceiverAfternoteHomeReducerEvent +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeEntry.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeEntry.kt index 4181b29014..cdfed6651b 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeEntry.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeEntry.kt @@ -7,9 +7,11 @@ import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.home.AfternoteHomeScreen +import com.afternote.feature.afternote.presentation.shared.component.ListItemUiModel import com.afternote.core.ui.R as CoreUiR /** @@ -28,13 +30,25 @@ import com.afternote.core.ui.R as CoreUiR * 수신자는 로그인 사용자가 아니고 남길 기록을 쓰는 주체도 아니다. */ @Composable -fun ReceiverAfternoteHomeEntry( +internal fun ReceiverAfternoteHomeEntry( navigateToDetail: (Long) -> Unit, modifier: Modifier = Modifier, viewModel: ReceiverAfternoteHomeViewModel = hiltViewModel(), ) { - val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val items = viewModel.pagedAfternotes.collectAsLazyPagingItems() + ReceiverAfternoteHomeContent(uiState, items, viewModel::onIntent, navigateToDetail, modifier) +} + +@Composable +private fun ReceiverAfternoteHomeContent( + state: ReceiverAfternoteHomeUiState, + items: LazyPagingItems, + onIntent: (ReceiverAfternoteHomeIntent) -> Unit, + navigateToDetail: (Long) -> Unit, + modifier: Modifier, +) { + val selectedTab = state.selectedTab // 이미 그려 둔 목록이 있으면 유지한다 — 전면 교체는 보여 줄 것이 전무할 때만 (작성자 화면과 같은 규칙). val refreshState = items.loadState.refresh val listError = @@ -70,7 +84,7 @@ fun ReceiverAfternoteHomeEntry( AfternoteHomeScreen( items = items, selectedType = selectedTab, - onTypeSelected = viewModel::selectTab, + onTypeSelected = { onIntent(ReceiverAfternoteHomeIntent.SelectType(it)) }, onListItemClick = { id, _ -> navigateToDetail(id) }, headerDescription = stringResource(R.string.afternote_receiver_afternote_list_header_description), // NEXT STEP 은 «내가 남길 기록» 을 재촉하는 발신자용 카드다. 수신자 목록에는 없다. diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeViewModel.kt index db3786537c..7e9b1148f8 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/afternotelist/ReceiverAfternoteHomeViewModel.kt @@ -1,12 +1,11 @@ package com.afternote.feature.afternote.presentation.receiver.afternotelist -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map -import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.afternote.presentation.shared.component.ListItemUiModel import com.afternote.feature.afternote.presentation.shared.util.getIconResForService import com.afternote.feature.receiver.domain.model.AfterNoteListItem @@ -14,9 +13,7 @@ import com.afternote.feature.receiver.domain.repository.ReceiverRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -29,18 +26,18 @@ import javax.inject.Inject * 서버 파라미터가 없어 클라이언트 사이드 [PagingData.filter]로 적용한다. */ @HiltViewModel -class ReceiverAfternoteHomeViewModel +internal class ReceiverAfternoteHomeViewModel @Inject constructor( private val receiverRepository: ReceiverRepository, - ) : ViewModel() { - /** 선택된 종류 필터. `null` 은 전체다. */ - private val _selectedTab = MutableStateFlow(null) - val selectedTab: StateFlow = _selectedTab.asStateFlow() - + ) : MviViewModel( + ReceiverAfternoteHomeUiState(), + ) { @OptIn(ExperimentalCoroutinesApi::class) val pagedAfternotes: Flow> = - _selectedTab + uiState + .map { it.selectedTab } + .distinctUntilChanged() .flatMapLatest { tab -> receiverRepository .getPagedReceivedAfternotes() @@ -51,10 +48,19 @@ class ReceiverAfternoteHomeViewModel } }.cachedIn(viewModelScope) - fun selectTab(tab: AfternoteType?) { - if (_selectedTab.value == tab) return - _selectedTab.value = tab + override fun onIntent(intent: ReceiverAfternoteHomeIntent) { + when (intent) { + is ReceiverAfternoteHomeIntent.SelectType -> dispatch(ReceiverAfternoteHomeReducerEvent.TypeSelected(intent.type)) + } } + + override fun reduce( + state: ReceiverAfternoteHomeUiState, + event: ReceiverAfternoteHomeReducerEvent, + ): ReceiverAfternoteHomeUiState = + when (event) { + is ReceiverAfternoteHomeReducerEvent.TypeSelected -> state.copy(selectedTab = event.type) + } } /** 카드 주 텍스트는 서비스명이, 미등록 서비스 아이콘과 필터 탭은 종류가 결정한다. */ diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailContract.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailContract.kt new file mode 100644 index 0000000000..8f8289a37f --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailContract.kt @@ -0,0 +1,23 @@ +package com.afternote.feature.afternote.presentation.receiver.detail + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +internal sealed interface ReceivedAfternoteDetailIntent : MviIntent { + data object Retry : ReceivedAfternoteDetailIntent + + data object RefreshOnReturn : ReceivedAfternoteDetailIntent +} + +internal sealed interface ReceivedAfternoteDetailReducerEvent : ReducerEvent { + data object Loading : ReceivedAfternoteDetailReducerEvent + + data class ContentLoaded( + val id: Long, + val content: ReceivedDetailContentUiModel, + ) : ReceivedAfternoteDetailReducerEvent + + data class LoadFailed( + val keepsContent: Boolean, + ) : ReceivedAfternoteDetailReducerEvent +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailRoute.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailRoute.kt index ef241af4c6..2f248b99dc 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailRoute.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailRoute.kt @@ -2,7 +2,6 @@ package com.afternote.feature.afternote.presentation.receiver.detail import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -27,7 +26,7 @@ import com.afternote.feature.afternote.presentation.shared.detail.DetailLoadErro * 실패를 이쪽으로 보내면 서버·네트워크 오류가 "디자인 예정" 으로 표시되고 재시도 수단도 사라진다 (#713). */ @Composable -fun ReceivedAfternoteDetailRoute( +internal fun ReceivedAfternoteDetailRoute( onNavigateBack: () -> Unit, onNavigateToFullList: () -> Unit, onNavigateToPlaylist: (afternoteId: Long) -> Unit, @@ -43,9 +42,20 @@ fun ReceivedAfternoteDetailRoute( // 방출하지 않는 refreshOnReturn() 을 쓴다. 첫 진입의 ON_RESUME 스킵(진입은 init 로드가 // 담당)과 실행 중 로드와의 중복 차단은 VM 이 판단한다 — 결선부는 무조건 부른다. LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - viewModel.refreshOnReturn() + viewModel.onIntent(ReceivedAfternoteDetailIntent.RefreshOnReturn) } + ReceivedAfternoteDetailContent(uiState, viewModel::onIntent, onNavigateBack, onNavigateToFullList, onNavigateToPlaylist) +} + +@Composable +private fun ReceivedAfternoteDetailContent( + uiState: ReceivedAfternoteDetailUiState, + onIntent: (ReceivedAfternoteDetailIntent) -> Unit, + onNavigateBack: () -> Unit, + onNavigateToFullList: () -> Unit, + onNavigateToPlaylist: (Long) -> Unit, +) { when (val state = uiState) { ReceivedAfternoteDetailUiState.Loading -> { LoadingBody() @@ -55,7 +65,7 @@ fun ReceivedAfternoteDetailRoute( DetailLoadErrorContent( messageRes = state.messageRes, onBackClick = onNavigateBack, - onRetryClick = viewModel::retry, + onRetryClick = { onIntent(ReceivedAfternoteDetailIntent.Retry) }, ) } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailUiState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailUiState.kt index a2a58e17ff..48821c0e69 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailUiState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailUiState.kt @@ -1,8 +1,9 @@ package com.afternote.feature.afternote.presentation.receiver.detail import androidx.annotation.StringRes +import com.afternote.core.ui.mvi.UiState -sealed interface ReceivedAfternoteDetailUiState { +internal sealed interface ReceivedAfternoteDetailUiState : UiState { data object Loading : ReceivedAfternoteDetailUiState data class Success( diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModel.kt index 9299629625..a8d456466c 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModel.kt @@ -1,8 +1,8 @@ package com.afternote.feature.afternote.presentation.receiver.detail -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteRoute import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage @@ -14,10 +14,6 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -32,19 +28,48 @@ import kotlinx.coroutines.launch * 수정·삭제·작성자 표시명·수신자 목록은 보유하지 않는다. */ @HiltViewModel(assistedFactory = ReceivedAfternoteDetailViewModel.Factory::class) -class ReceivedAfternoteDetailViewModel +internal class ReceivedAfternoteDetailViewModel @AssistedInject constructor( @Assisted private val route: ReceivedAfternoteRoute.DetailRoute, private val receiverRepository: ReceiverRepository, private val errorReporter: ErrorReporter, - ) : ViewModel() { + ) : MviViewModel( + ReceivedAfternoteDetailUiState.Loading, + ) { private val afternoteIdFromNav: Long = route.afternoteId - private val _uiState = - MutableStateFlow(ReceivedAfternoteDetailUiState.Loading) - val uiState: StateFlow = _uiState.asStateFlow() + override fun onIntent(intent: ReceivedAfternoteDetailIntent) { + when (intent) { + ReceivedAfternoteDetailIntent.Retry -> retry() + ReceivedAfternoteDetailIntent.RefreshOnReturn -> refreshOnReturn() + } + } + + override fun reduce( + state: ReceivedAfternoteDetailUiState, + event: ReceivedAfternoteDetailReducerEvent, + ): ReceivedAfternoteDetailUiState = + when (event) { + ReceivedAfternoteDetailReducerEvent.Loading -> { + ReceivedAfternoteDetailUiState.Loading + } + + is ReceivedAfternoteDetailReducerEvent.ContentLoaded -> { + ReceivedAfternoteDetailUiState.Success(detailId = event.id, contentUiModel = event.content) + } + + is ReceivedAfternoteDetailReducerEvent.LoadFailed -> { + if (event.keepsContent && + state is ReceivedAfternoteDetailUiState.Success + ) { + state + } else { + ReceivedAfternoteDetailUiState.Error(R.string.afternote_detail_load_error) + } + } + } /** 진행 중인 상세 조회 — 첫 진입 이후의 ON_RESUME 이 실행 중인 로드와 겹치면 건너뛰기 위한 가드. */ private var loadJob: Job? = null @@ -62,7 +87,7 @@ class ReceivedAfternoteDetailViewModel loadDetail(afternoteIdFromNav) } - fun retry() { + private fun retry() { loadDetail(afternoteIdFromNav) } @@ -73,7 +98,7 @@ class ReceivedAfternoteDetailViewModel * 첫 ON_RESUME(진입 자체)은 [isFirstResume] 로 스킵하고, 그 이후의 resume 이 실행 중인 * 로드와 겹치면 진행 중인 Job 으로 건너뛴다. */ - fun refreshOnReturn() { + private fun refreshOnReturn() { if (isFirstResume) { isFirstResume = false return @@ -91,7 +116,7 @@ class ReceivedAfternoteDetailViewModel loadJob = viewModelScope.launch { if (showsLoading) { - _uiState.value = ReceivedAfternoteDetailUiState.Loading + dispatch(ReceivedAfternoteDetailReducerEvent.Loading) } val result = receiverRepository.getReceivedAfternoteDetail(afternoteId = afternoteId) // 새 로드가 이 Job 을 취소했다면 화면은 그쪽이 결정한다. repository 는 @@ -101,23 +126,13 @@ class ReceivedAfternoteDetailViewModel ensureActive() result .onSuccess { detail -> - _uiState.value = - ReceivedAfternoteDetailUiState.Success( - detailId = afternoteId, - contentUiModel = detail.toReceivedDetailContentUiModel(), - ) + dispatch( + ReceivedAfternoteDetailReducerEvent.ContentLoaded(afternoteId, detail.toReceivedDetailContentUiModel()), + ) }.onFailure { e -> // 화면을 유지하는 자동 갱신 실패도 기록한다 — 콘솔이 유일한 관측 지점이다. errorReporter.recordAfternoteFailure(AfternoteFailureStage.RECEIVED_DETAIL_LOAD, e) - _uiState.update { current -> - if (keepsStateOnFailure && current is ReceivedAfternoteDetailUiState.Success) { - current - } else { - ReceivedAfternoteDetailUiState.Error( - messageRes = R.string.afternote_detail_load_error, - ) - } - } + dispatch(ReceivedAfternoteDetailReducerEvent.LoadFailed(keepsStateOnFailure)) } } } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/ReceivedAfternoteNavHost.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/ReceivedAfternoteNavHost.kt index bd4d986688..ac35d757ae 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/ReceivedAfternoteNavHost.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/navigation/ReceivedAfternoteNavHost.kt @@ -1,26 +1,19 @@ package com.afternote.feature.afternote.presentation.receiver.navigation import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LifecycleEventEffect -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack -import com.afternote.core.ui.loading.LoadingBody import com.afternote.core.ui.navigation.FeatureNavDisplay import com.afternote.core.ui.navigation.FeatureNavigationCallbacks import com.afternote.feature.afternote.presentation.navigation.AfternoteLightTheme import com.afternote.feature.afternote.presentation.receiver.afternotelist.ReceiverAfternoteHomeEntry import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailRoute import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel -import com.afternote.feature.afternote.presentation.receiver.playlist.MemorialPlaylistScreen -import com.afternote.feature.afternote.presentation.receiver.playlist.ReceiverMemorialPlaylistUiState +import com.afternote.feature.afternote.presentation.receiver.playlist.ReceiverMemorialPlaylistRoute import com.afternote.feature.afternote.presentation.receiver.playlist.ReceiverMemorialPlaylistViewModel -import com.afternote.feature.afternote.presentation.shared.detail.DetailLoadErrorContent /** * 수신 애프터노트(목록·상세·추억 플레이리스트)가 소유하는 로컬 Navigation 3 스택. @@ -72,36 +65,7 @@ public fun ReceivedAfternoteNavHost( hiltViewModel( creationCallback = { factory -> factory.create(key) }, ) - val playlistUiState by playlistViewModel.uiState.collectAsStateWithLifecycle() - - // 화면을 떠났다 돌아오면 다시 조회한다 — 백스택에 살아 있는 동안 옛 값이 남지 않게 - // 한다 (#701). 로딩을 방출하지 않는 refreshOnReturn() 을 쓴다. 첫 진입의 ON_RESUME - // 스킵(진입은 init 로드가 담당)과 실행 중 로드와의 중복 차단은 VM 이 판단한다. - LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - playlistViewModel.refreshOnReturn() - } - - when (val state = playlistUiState) { - ReceiverMemorialPlaylistUiState.Loading -> { - LoadingBody() - } - - is ReceiverMemorialPlaylistUiState.Error -> { - DetailLoadErrorContent( - messageRes = state.messageRes, - onBackClick = actions::popBack, - onRetryClick = playlistViewModel::retry, - ) - } - - is ReceiverMemorialPlaylistUiState.Success -> { - MemorialPlaylistScreen( - senderName = state.senderName, - songs = state.songs, - onBackClick = actions::popBack, - ) - } - } + ReceiverMemorialPlaylistRoute(viewModel = playlistViewModel, onBackClick = actions::popBack) } } }, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistContract.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistContract.kt new file mode 100644 index 0000000000..fa7eea5711 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistContract.kt @@ -0,0 +1,22 @@ +package com.afternote.feature.afternote.presentation.receiver.playlist + +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +internal sealed interface ReceiverMemorialPlaylistIntent : MviIntent { + data object Retry : ReceiverMemorialPlaylistIntent + + data object RefreshOnReturn : ReceiverMemorialPlaylistIntent +} + +internal sealed interface ReceiverMemorialPlaylistReducerEvent : ReducerEvent { + data object Loading : ReceiverMemorialPlaylistReducerEvent + + data class ContentLoaded( + val content: ReceiverMemorialPlaylistUiState.Success, + ) : ReceiverMemorialPlaylistReducerEvent + + data class LoadFailed( + val keepsContent: Boolean, + ) : ReceiverMemorialPlaylistReducerEvent +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistRoute.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistRoute.kt new file mode 100644 index 0000000000..331495a4c1 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistRoute.kt @@ -0,0 +1,46 @@ +package com.afternote.feature.afternote.presentation.receiver.playlist + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.loading.LoadingBody +import com.afternote.feature.afternote.presentation.shared.detail.DetailLoadErrorContent + +@Composable +internal fun ReceiverMemorialPlaylistRoute( + viewModel: ReceiverMemorialPlaylistViewModel, + onBackClick: () -> Unit, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.onIntent(ReceiverMemorialPlaylistIntent.RefreshOnReturn) + } + ReceiverMemorialPlaylistContent(state, viewModel::onIntent, onBackClick) +} + +@Composable +private fun ReceiverMemorialPlaylistContent( + state: ReceiverMemorialPlaylistUiState, + onIntent: (ReceiverMemorialPlaylistIntent) -> Unit, + onBackClick: () -> Unit, +) { + when (state) { + ReceiverMemorialPlaylistUiState.Loading -> { + LoadingBody() + } + + is ReceiverMemorialPlaylistUiState.Error -> { + DetailLoadErrorContent( + messageRes = state.messageRes, + onBackClick = onBackClick, + onRetryClick = { onIntent(ReceiverMemorialPlaylistIntent.Retry) }, + ) + } + + is ReceiverMemorialPlaylistUiState.Success -> { + MemorialPlaylistScreen(senderName = state.senderName, songs = state.songs, onBackClick = onBackClick) + } + } +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistUiState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistUiState.kt index 76770e43a7..b507e72a27 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistUiState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistUiState.kt @@ -1,9 +1,10 @@ package com.afternote.feature.afternote.presentation.receiver.playlist import androidx.annotation.StringRes +import com.afternote.core.ui.mvi.UiState import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDisplay -sealed interface ReceiverMemorialPlaylistUiState { +internal sealed interface ReceiverMemorialPlaylistUiState : UiState { data object Loading : ReceiverMemorialPlaylistUiState data class Success( diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModel.kt index 75de8acdaa..a66eaec293 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModel.kt @@ -1,8 +1,8 @@ package com.afternote.feature.afternote.presentation.receiver.playlist -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteRoute import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage @@ -14,10 +14,6 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -27,19 +23,48 @@ import kotlinx.coroutines.launch * `X-Auth-Code` 헤더는 ReceiverAuthInterceptor가 자동 부착합니다. */ @HiltViewModel(assistedFactory = ReceiverMemorialPlaylistViewModel.Factory::class) -class ReceiverMemorialPlaylistViewModel +internal class ReceiverMemorialPlaylistViewModel @AssistedInject constructor( @Assisted private val route: ReceivedAfternoteRoute.MemorialPlaylistRoute, private val receiverRepository: ReceiverRepository, private val errorReporter: ErrorReporter, - ) : ViewModel() { + ) : MviViewModel( + ReceiverMemorialPlaylistUiState.Loading, + ) { private val afternoteIdFromNav: Long = route.afternoteId - private val _uiState = - MutableStateFlow(ReceiverMemorialPlaylistUiState.Loading) - val uiState: StateFlow = _uiState.asStateFlow() + override fun onIntent(intent: ReceiverMemorialPlaylistIntent) { + when (intent) { + ReceiverMemorialPlaylistIntent.Retry -> retry() + ReceiverMemorialPlaylistIntent.RefreshOnReturn -> refreshOnReturn() + } + } + + override fun reduce( + state: ReceiverMemorialPlaylistUiState, + event: ReceiverMemorialPlaylistReducerEvent, + ): ReceiverMemorialPlaylistUiState = + when (event) { + ReceiverMemorialPlaylistReducerEvent.Loading -> { + ReceiverMemorialPlaylistUiState.Loading + } + + is ReceiverMemorialPlaylistReducerEvent.ContentLoaded -> { + event.content + } + + is ReceiverMemorialPlaylistReducerEvent.LoadFailed -> { + if (event.keepsContent && + state is ReceiverMemorialPlaylistUiState.Success + ) { + state + } else { + ReceiverMemorialPlaylistUiState.Error(R.string.afternote_receiver_memorial_playlist_load_error) + } + } + } /** 진행 중인 조회 — 첫 진입 이후의 ON_RESUME 이 실행 중인 로드와 겹치면 건너뛰기 위한 가드. */ private var loadJob: Job? = null @@ -57,7 +82,7 @@ class ReceiverMemorialPlaylistViewModel loadPlaylist(afternoteIdFromNav) } - fun retry() { + private fun retry() { loadPlaylist(afternoteIdFromNav) } @@ -68,7 +93,7 @@ class ReceiverMemorialPlaylistViewModel * 첫 ON_RESUME(진입 자체)은 [isFirstResume] 로 스킵하고, 그 이후의 resume 이 실행 중인 * 로드와 겹치면 진행 중인 Job 으로 건너뛴다. */ - fun refreshOnReturn() { + private fun refreshOnReturn() { if (isFirstResume) { isFirstResume = false return @@ -84,7 +109,7 @@ class ReceiverMemorialPlaylistViewModel ) { loadJob?.cancel() if (showsLoading) { - _uiState.value = ReceiverMemorialPlaylistUiState.Loading + dispatch(ReceiverMemorialPlaylistReducerEvent.Loading) } loadJob = viewModelScope.launch { @@ -103,25 +128,20 @@ class ReceiverMemorialPlaylistViewModel albumImageUrl = song.coverUrl, ) }.orEmpty() - _uiState.value = - ReceiverMemorialPlaylistUiState.Success( - senderName = detail.senderName, - songs = songs, - memorialVideoUrl = playlist?.memorialVideoUrl, - memorialThumbnailUrl = playlist?.memorialThumbnailUrl, - ) + dispatch( + ReceiverMemorialPlaylistReducerEvent.ContentLoaded( + ReceiverMemorialPlaylistUiState.Success( + senderName = detail.senderName, + songs = songs, + memorialVideoUrl = playlist?.memorialVideoUrl, + memorialThumbnailUrl = playlist?.memorialThumbnailUrl, + ), + ), + ) }.onFailure { e -> // 화면을 유지하는 자동 갱신 실패도 기록한다 — 콘솔이 유일한 관측 지점이다. errorReporter.recordAfternoteFailure(AfternoteFailureStage.RECEIVED_PLAYLIST_LOAD, e) - _uiState.update { current -> - if (keepsStateOnFailure && current is ReceiverMemorialPlaylistUiState.Success) { - current - } else { - ReceiverMemorialPlaylistUiState.Error( - messageRes = R.string.afternote_receiver_memorial_playlist_load_error, - ) - } - } + dispatch(ReceiverMemorialPlaylistReducerEvent.LoadFailed(keepsStateOnFailure)) } } } diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/detail/DeleteInProgressOverlayScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/detail/DeleteInProgressOverlayScreenshotTest.kt deleted file mode 100644 index 08188dd016..0000000000 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/detail/DeleteInProgressOverlayScreenshotTest.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.afternote.feature.afternote.presentation.detail - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import com.afternote.core.ui.theme.AfternoteTheme -import com.android.tools.screenshot.PreviewTest - -/** - * [DeleteInProgressOverlay] 는 상세 화면 위에 겹쳐 그리는 반투명 스크림이라 - * 하위 콘텐츠를 어둡게 덮는 alpha 효과가 핵심이다. 뒤에 더미 콘텐츠를 깔고 - * 그 위에 오버레이를 얹어 스크림 대비와 중앙 인디케이터를 함께 회귀 검증한다. - */ -@PreviewTest -@Preview(showBackground = true) -@Composable -internal fun deleteInProgressOverlayScreenshot() { - AfternoteTheme { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text(text = "상세 콘텐츠") - DeleteInProgressOverlay() - } - } -} diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreenScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreenScreenshotTest.kt index d613340591..dc892b50a0 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreenScreenshotTest.kt +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongScreenScreenshotTest.kt @@ -25,8 +25,7 @@ internal fun addSongScreenScreenshot() { AfternoteTheme { AddSongScreen( uiState = AddSongUiState(songs = SAMPLE_SONGS), - onSearchQueryChange = {}, - onErrorConsumed = {}, + onIntent = {}, onBackClick = {}, onSongsAdded = {}, ) @@ -45,8 +44,7 @@ internal fun addSongScreenCompactScreenshot() { AfternoteTheme { AddSongScreen( uiState = AddSongUiState(songs = SAMPLE_SONGS), - onSearchQueryChange = {}, - onErrorConsumed = {}, + onIntent = {}, onBackClick = {}, onSongsAdded = {}, ) diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/DeleteInProgressOverlayScreenshotTestKt/deleteInProgressOverlayScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/DeleteInProgressOverlayScreenshotTestKt/deleteInProgressOverlayScreenshot_748aa731_0.png deleted file mode 100644 index b48725f942..0000000000 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/detail/DeleteInProgressOverlayScreenshotTestKt/deleteInProgressOverlayScreenshot_748aa731_0.png and /dev/null differ diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt index 5365575e13..c41a4669fd 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteAuthorExtendedTest.kt @@ -47,6 +47,7 @@ import com.afternote.feature.afternote.domain.usecase.editor.ResolveMemorialMedi import com.afternote.feature.afternote.domain.usecase.editor.SaveAfternoteUseCase import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.detail.AfternoteDetailDeleteResult +import com.afternote.feature.afternote.presentation.detail.AfternoteDetailIntent import com.afternote.feature.afternote.presentation.detail.AfternoteDetailUiState import com.afternote.feature.afternote.presentation.detail.AfternoteDetailViewModel import com.afternote.feature.afternote.presentation.detail.DetailContentUiModel @@ -463,12 +464,12 @@ private fun AuthorDetailForDelete( result.messageRes ?: AfternoteFeatureR.string.afternote_detail_delete_failed, ) scope.launch { snackbarHostState.showSnackbar(message) } - viewModel.onDeleteResultConsumed() + viewModel.onIntent(AfternoteDetailIntent.ConsumeDeleteResult) } is AfternoteDetailDeleteResult.Succeeded -> { deletedIds += result.id - viewModel.onDeleteResultConsumed() + viewModel.onIntent(AfternoteDetailIntent.ConsumeDeleteResult) } null -> { @@ -493,7 +494,7 @@ private fun AuthorDetailForDelete( content = account.content, snackbarHostState = snackbarHostState, onEditClick = {}, - onDeleteConfirm = viewModel::deleteAfternote, + onDeleteConfirm = { viewModel.onIntent(AfternoteDetailIntent.Delete) }, ) } } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostViewModelTest.kt new file mode 100644 index 0000000000..2c1b78e828 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/AfternoteHostViewModelTest.kt @@ -0,0 +1,61 @@ +package com.afternote.feature.afternote.presentation + +import com.afternote.core.domain.testing.FakeUserProfileCacheRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AfternoteHostViewModelTest { + @After + fun resetDispatcher() = Dispatchers.resetMain() + + @Test + fun `프로필은 화면 구독 때 시작하고 빠른 복귀는 기존 구독을 유지한다`() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val repository = FakeUserProfileCacheRepository() + val viewModel = AfternoteHostViewModel(repository) + assertNull(viewModel.uiState.value.isPasskeyRegistered) + assertEquals(0, repository.isPasskeyRegisteredFlowCalls) + + viewModel.onIntent(AfternoteHostIntent.ObserveProfile) + runCurrent() + assertEquals(false, viewModel.uiState.value.isPasskeyRegistered) + assertEquals(1, repository.passkeyRegisteredState.subscriptionCount.value) + + viewModel.onIntent(AfternoteHostIntent.StopObservingProfile) + runCurrent() + advanceTimeBy(4_999) + viewModel.onIntent(AfternoteHostIntent.ObserveProfile) + repository.passkeyRegisteredState.value = true + runCurrent() + assertEquals(true, viewModel.uiState.value.isPasskeyRegistered) + assertEquals(1, repository.isPasskeyRegisteredFlowCalls) + + viewModel.onIntent(AfternoteHostIntent.StopObservingProfile) + runCurrent() + advanceTimeBy(5_000) + runCurrent() + assertEquals(0, repository.passkeyRegisteredState.subscriptionCount.value) + assertEquals(true, viewModel.uiState.value.isPasskeyRegistered) + + repository.passkeyRegisteredState.value = false + viewModel.onIntent(AfternoteHostIntent.ObserveProfile) + runCurrent() + assertEquals(false, viewModel.uiState.value.isPasskeyRegistered) + assertEquals(2, repository.isPasskeyRegisteredFlowCalls) + viewModel.onIntent(AfternoteHostIntent.StopObservingProfile) + advanceTimeBy(5_001) + runCurrent() + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContentTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContentTest.kt new file mode 100644 index 0000000000..b944299d21 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailContentTest.kt @@ -0,0 +1,67 @@ +package com.afternote.feature.afternote.presentation.detail + +import androidx.activity.ComponentActivity +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.click +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTouchInput +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.presentation.R +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AfternoteDetailContentTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `실제 상세 상태가 삭제 진행 표시를 올리고 완료하면 제거한다`() { + var state by mutableStateOf( + AfternoteDetailUiState.Success( + detailId = 73L, + contentUiModel = DetailContentUiModel.Memorial(MemorialDetailContent()), + authorDisplayName = "서영", + isDeleting = true, + ), + ) + val intents = mutableListOf() + var backClicks = 0 + composeRule.setContent { + AfternoteTheme { + AfternoteDetailContent( + uiState = state, + onIntent = intents::add, + snackbarHostState = SnackbarHostState(), + onNavigateBack = { backClicks++ }, + onNavigateToEditor = { _, _ -> }, + onVideoClick = {}, + ) + } + } + val deleting = composeRule.activity.getString(R.string.afternote_detail_deleting) + composeRule.onNodeWithText("추억 노트에 대한\n서영님의 기록").assertExists() + composeRule.onNodeWithContentDescription(deleting).assertIsDisplayed() + val back = composeRule.activity.getString(com.afternote.core.ui.R.string.core_ui_content_description_back) + composeRule.onNodeWithContentDescription(back).performTouchInput { click() } + composeRule.runOnIdle { + assertEquals(emptyList(), intents) + assertEquals(0, backClicks) + state = (state as AfternoteDetailUiState.Success).copy(isDeleting = false) + } + composeRule.onNodeWithContentDescription(deleting).assertDoesNotExist() + composeRule.onNodeWithContentDescription(back).performTouchInput { click() } + composeRule.runOnIdle { assertEquals(1, backClicks) } + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModelTest.kt index 75ff275a14..09f80d8b9c 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModelTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailViewModelTest.kt @@ -72,7 +72,7 @@ class AfternoteDetailViewModelTest { val states = recordStates(viewModel) // 첫 진입 화면의 ON_RESUME (init 로드는 이미 실패로 종료됨). - viewModel.refreshOnReturn() + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) assertEquals(listOf(73L), repository.requestedDetailIds) assertTrue(states.last() is AfternoteDetailUiState.Error) @@ -93,8 +93,8 @@ class AfternoteDetailViewModelTest { val states = recordStates(viewModel) // init 로드가 아직 도는 중 — 첫 resume(스킵) 뒤 또 한 번 resume 이 와도 중복이 없어야 한다. - viewModel.refreshOnReturn() - viewModel.refreshOnReturn() + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) gate.complete(Unit) assertEquals(listOf(73L), repository.requestedDetailIds) @@ -118,8 +118,8 @@ class AfternoteDetailViewModelTest { val viewModel = viewModel(repository) val states = recordStates(viewModel) - viewModel.refreshOnReturn() // 첫 진입의 ON_RESUME — 스킵 - viewModel.refreshOnReturn() // 백스택 복귀의 ON_RESUME + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) // 첫 진입의 ON_RESUME — 스킵 + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) // 백스택 복귀의 ON_RESUME assertEquals(listOf(73L, 73L), repository.requestedDetailIds) assertEquals("Facebook", states.last().serviceNameOrNull()) @@ -146,8 +146,8 @@ class AfternoteDetailViewModelTest { val viewModel = viewModel(repository, errorReporter = reporter) val states = recordStates(viewModel) - viewModel.refreshOnReturn() // 첫 진입의 ON_RESUME — 스킵 - viewModel.refreshOnReturn() // 백스택 복귀의 ON_RESUME + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) // 첫 진입의 ON_RESUME — 스킵 + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) // 백스택 복귀의 ON_RESUME // 잘 보고 있던 상세가 에러 화면으로 대체되지 않는다. assertEquals("Instagram", states.last().serviceNameOrNull()) @@ -202,8 +202,8 @@ class AfternoteDetailViewModelTest { val viewModel = viewModel(repository, myProfileRepository = myProfileRepository) val states = recordStates(viewModel) - viewModel.refreshOnReturn() // 첫 진입의 ON_RESUME — 스킵 - viewModel.refreshOnReturn() // 백스택 복귀의 ON_RESUME + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) // 첫 진입의 ON_RESUME — 스킵 + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) // 백스택 복귀의 ON_RESUME // 갱신이 만든 새 Success 도 이름을 들고 있어야 제목이 «…에 대한 기록» 으로 되돌아가지 않는다. assertEquals("Threads", states.last().serviceNameOrNull()) @@ -307,9 +307,9 @@ class AfternoteDetailViewModelTest { val viewModel = viewModel(repository) val states = recordStates(viewModel) - viewModel.deleteAfternote() - viewModel.refreshOnReturn() // 첫 진입의 ON_RESUME — 스킵 - viewModel.refreshOnReturn() // 백스택 복귀의 ON_RESUME + viewModel.onIntent(AfternoteDetailIntent.Delete) + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) // 첫 진입의 ON_RESUME — 스킵 + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) // 백스택 복귀의 ON_RESUME val last = states.last() as AfternoteDetailUiState.Success assertEquals("Threads", states.last().serviceNameOrNull()) @@ -338,7 +338,7 @@ class AfternoteDetailViewModelTest { assertTrue(states.last() is AfternoteDetailUiState.Error) - viewModel.retry() + viewModel.onIntent(AfternoteDetailIntent.Retry) // 응답이 오기 전 — 사용자가 누른 동작이므로 기다림을 표시한다(자동 갱신과 갈리는 지점). assertTrue(states.last() is AfternoteDetailUiState.Loading) @@ -365,7 +365,7 @@ class AfternoteDetailViewModelTest { } 2 -> { - viewModelRef.retry() + viewModelRef.onIntent(AfternoteDetailIntent.Retry) Result.success(detail(serviceName = "Stale")) } @@ -379,8 +379,8 @@ class AfternoteDetailViewModelTest { viewModelRef = viewModel val states = recordStates(viewModel) - viewModel.refreshOnReturn() // 첫 진입의 ON_RESUME — 스킵 - viewModel.refreshOnReturn() // 백스택 복귀의 ON_RESUME — 이 로드가 재시도에 잘린다 + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) // 첫 진입의 ON_RESUME — 스킵 + viewModel.onIntent(AfternoteDetailIntent.RefreshOnReturn) // 백스택 복귀의 ON_RESUME — 이 로드가 재시도에 잘린다 assertEquals("Retry", states.last().serviceNameOrNull()) } @@ -402,7 +402,7 @@ class AfternoteDetailViewModelTest { val viewModel = viewModel(repository) val states = recordStates(viewModel) - viewModel.deleteAfternote() + viewModel.onIntent(AfternoteDetailIntent.Delete) assertTrue(states.last() is AfternoteDetailUiState.Error) assertEquals(emptyList(), deletedIds) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongSearchProgressTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongSearchProgressTest.kt index 02474ebfd4..78b32fcb82 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongSearchProgressTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongSearchProgressTest.kt @@ -54,8 +54,7 @@ class AddSongSearchProgressTest { AfternoteTheme { AddSongScreen( uiState = uiState, - onSearchQueryChange = {}, - onErrorConsumed = {}, + onIntent = {}, onBackClick = {}, onSongsAdded = {}, ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongViewModelTest.kt new file mode 100644 index 0000000000..524938f605 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/memorial/AddSongViewModelTest.kt @@ -0,0 +1,64 @@ +package com.afternote.feature.afternote.presentation.editor.memorial + +import com.afternote.feature.afternote.domain.repository.author.MusicSearchRepository +import com.afternote.feature.afternote.presentation.NoopAuthorErrorReporter +import com.afternote.feature.afternote.presentation.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +class AddSongViewModelTest { + @After + fun resetDispatcher() = Dispatchers.resetMain() + + @Test + fun `검색 Intent는 debounce한 최신 검색어만 조회하고 실패를 소비한 뒤 다시 표시한다`() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val queries = mutableListOf() + val repository = + MusicSearchRepository { query -> + queries += query + Result.failure(IOException("offline")) + } + val viewModel = AddSongViewModel(repository, NoopAuthorErrorReporter) + viewModel.onIntent(AddSongIntent.Search("old")) + runCurrent() + advanceTimeBy(299) + viewModel.onIntent(AddSongIntent.Search(" latest ")) + runCurrent() + advanceTimeBy(300) + runCurrent() + assertEquals(listOf("latest"), queries) + assertEquals(" latest ", viewModel.uiState.value.searchQuery) + assertEquals(R.string.afternote_editor_search_failed_generic, viewModel.uiState.value.errorRes) + + viewModel.onIntent(AddSongIntent.ConsumeError) + assertNull(viewModel.uiState.value.errorRes) + viewModel.onIntent(AddSongIntent.Search("latest")) + runCurrent() + advanceTimeBy(300) + runCurrent() + assertEquals(2, queries.size) + assertEquals(R.string.afternote_editor_search_failed_generic, viewModel.uiState.value.errorRes) + + viewModel.onIntent(AddSongIntent.Search(" ")) + runCurrent() + advanceTimeBy(300) + runCurrent() + assertEquals(2, queries.size) + assertEquals(false, viewModel.uiState.value.isLoading) + assertNull(viewModel.uiState.value.errorRes) + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModelTest.kt index ce1e695c31..7f854c61aa 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModelTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/receiver/SelectReceiverViewModelTest.kt @@ -118,7 +118,7 @@ class SelectReceiverViewModelTest { assertTrue(viewModel.uiState.value.loadFailed) repository.onGetReceivers = null - viewModel.refresh() + viewModel.onIntent(SelectReceiverIntent.Refresh) runCurrent() val state = viewModel.uiState.value @@ -131,10 +131,10 @@ class SelectReceiverViewModelTest { runTest { val viewModel = viewModelWithReceivers() - viewModel.toggleReceiverSelection(1L) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) assertEquals(listOf(1L), viewModel.uiState.value.selectedReceiverIds) - viewModel.toggleReceiverSelection(1L) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) assertEquals(emptyList(), viewModel.uiState.value.selectedReceiverIds) } @@ -143,8 +143,8 @@ class SelectReceiverViewModelTest { runTest { val viewModel = viewModelWithReceivers() - viewModel.toggleReceiverSelection(2L) - viewModel.toggleReceiverSelection(1L) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(2L)) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) assertEquals(listOf(2L, 1L), viewModel.uiState.value.selectedReceiverIds) } @@ -154,9 +154,9 @@ class SelectReceiverViewModelTest { runTest { val viewModel = viewModelWithReceivers() - viewModel.toggleReceiverSelection(1L) - viewModel.toggleReceiverSelection(2L) - viewModel.toggleReceiverSelection(1L) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(2L)) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) assertEquals(listOf(2L), viewModel.uiState.value.selectedReceiverIds) } @@ -174,8 +174,8 @@ class SelectReceiverViewModelTest { runTest { val viewModel = viewModelWithReceivers(formReceiverIds = listOf(1L)) - viewModel.toggleReceiverSelection(1L) - viewModel.applyPreselection(listOf(1L)) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) + viewModel.onIntent(SelectReceiverIntent.ApplyPreselection(listOf(1L))) assertEquals(emptyList(), viewModel.uiState.value.selectedReceiverIds) } @@ -185,8 +185,8 @@ class SelectReceiverViewModelTest { runTest { val viewModel = viewModelWithReceivers(formReceiverIds = listOf(1L)) - viewModel.toggleReceiverSelection(1L) - viewModel.toggleReceiverSelection(1L) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) assertEquals(listOf(1L), viewModel.uiState.value.selectedReceiverIds) } @@ -200,7 +200,7 @@ class SelectReceiverViewModelTest { val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, SavedStateHandle()) runCurrent() - viewModel.applyPreselection(listOf(1L, 99L)) + viewModel.onIntent(SelectReceiverIntent.ApplyPreselection(listOf(1L, 99L))) gate.complete( listOf( @@ -227,10 +227,10 @@ class SelectReceiverViewModelTest { val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, SavedStateHandle()) runCurrent() - viewModel.toggleReceiverSelection(1L) - viewModel.toggleReceiverSelection(2L) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(2L)) repository.receiverState.value = listOf(Receiver(1L, "김혜성", "아들")) - viewModel.refresh() + viewModel.onIntent(SelectReceiverIntent.Refresh) runCurrent() assertEquals(listOf(1L), viewModel.uiState.value.selectedReceiverIds) @@ -244,8 +244,8 @@ class SelectReceiverViewModelTest { val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, SavedStateHandle()) runCurrent() - viewModel.toggleReceiverSelection(1L) - viewModel.refresh() + viewModel.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) + viewModel.onIntent(SelectReceiverIntent.Refresh) runCurrent() assertEquals(listOf(1L), viewModel.uiState.value.selectedReceiverIds) @@ -267,7 +267,7 @@ class SelectReceiverViewModelTest { ) val beforeDeath = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, savedStateHandle) runCurrent() - beforeDeath.toggleReceiverSelection(2L) + beforeDeath.onIntent(SelectReceiverIntent.ToggleReceiver(2L)) val restored = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, savedStateHandle) runCurrent() @@ -288,13 +288,13 @@ class SelectReceiverViewModelTest { ), ) val beforeDeath = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, savedStateHandle) - beforeDeath.applyPreselection(listOf(1L)) + beforeDeath.onIntent(SelectReceiverIntent.ApplyPreselection(listOf(1L))) runCurrent() - beforeDeath.toggleReceiverSelection(1L) + beforeDeath.onIntent(SelectReceiverIntent.ToggleReceiver(1L)) // 재생성 뒤 Route 의 LaunchedEffect 가 같은 폼 수신자로 다시 부른다. val restored = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, savedStateHandle) - restored.applyPreselection(listOf(1L)) + restored.onIntent(SelectReceiverIntent.ApplyPreselection(listOf(1L))) runCurrent() assertEquals(emptyList(), restored.uiState.value.selectedReceiverIds) @@ -314,7 +314,7 @@ class SelectReceiverViewModelTest { ), ) val viewModel = SelectReceiverViewModel(repository, NoopAuthorErrorReporter, SavedStateHandle()) - if (formReceiverIds.isNotEmpty()) viewModel.applyPreselection(formReceiverIds) + if (formReceiverIds.isNotEmpty()) viewModel.onIntent(SelectReceiverIntent.ApplyPreselection(formReceiverIds)) dispatcher.scheduler.runCurrent() return viewModel } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeListFailureReportingTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeListFailureReportingTest.kt index ebe8668066..55424216af 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeListFailureReportingTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeListFailureReportingTest.kt @@ -41,7 +41,7 @@ class AfternoteHomeListFailureReportingTest { val reporter = RecordingErrorReporter() val viewModel = viewModel(reporter) - viewModel.onListLoadFailed(IOException("목록 조회 실패")) + viewModel.onIntent(AfternoteHomeIntent.ListLoadFailed(IOException("목록 조회 실패"))) assertEquals(listOf("list_load"), reporter.recordedStages) assertEquals(listOf(IOException::class.java.name), reporter.recordedErrorTypes) @@ -52,7 +52,7 @@ class AfternoteHomeListFailureReportingTest { val reporter = RecordingErrorReporter() val viewModel = viewModel(reporter) - repeat(5) { viewModel.onListLoadFailed(IOException("목록 조회 실패")) } + repeat(5) { viewModel.onIntent(AfternoteHomeIntent.ListLoadFailed(IOException("목록 조회 실패"))) } assertEquals(1, reporter.recordedStages.size) } @@ -62,8 +62,8 @@ class AfternoteHomeListFailureReportingTest { val reporter = RecordingErrorReporter() val viewModel = viewModel(reporter) - viewModel.onListLoadFailed(IOException("연결 실패")) - viewModel.onListLoadFailed(SocketTimeoutException("응답 없음")) + viewModel.onIntent(AfternoteHomeIntent.ListLoadFailed(IOException("연결 실패"))) + viewModel.onIntent(AfternoteHomeIntent.ListLoadFailed(SocketTimeoutException("응답 없음"))) assertEquals( listOf(IOException::class.java.name, SocketTimeoutException::class.java.name), @@ -76,9 +76,9 @@ class AfternoteHomeListFailureReportingTest { val reporter = RecordingErrorReporter() val viewModel = viewModel(reporter) - viewModel.onListLoadFailed(IOException("목록 조회 실패")) - viewModel.onListLoadSucceeded() - viewModel.onListLoadFailed(IOException("목록 조회 실패")) + viewModel.onIntent(AfternoteHomeIntent.ListLoadFailed(IOException("목록 조회 실패"))) + viewModel.onIntent(AfternoteHomeIntent.ListLoadSucceeded) + viewModel.onIntent(AfternoteHomeIntent.ListLoadFailed(IOException("목록 조회 실패"))) assertEquals(listOf("list_load", "list_load"), reporter.recordedStages) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModelTest.kt index cd0766bfdb..ad98f7a14a 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModelTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedAfternoteDetailViewModelTest.kt @@ -63,12 +63,12 @@ class ReceivedAfternoteDetailViewModelTest { val states = recordStates(viewModel) // 첫 진입 화면의 ON_RESUME (init 로드는 이미 실패로 종료됨) — 재조회가 걸리면 안 된다. - viewModel.refreshOnReturn() + viewModel.onIntent(ReceivedAfternoteDetailIntent.RefreshOnReturn) assertEquals(listOf(42L), repository.requestedDetailIds) assertTrue(states.last() is ReceivedAfternoteDetailUiState.Error) // 복구는 사용자의 수동 재시도로만 일어난다. - viewModel.retry() + viewModel.onIntent(ReceivedAfternoteDetailIntent.Retry) assertEquals(listOf(42L, 42L), repository.requestedDetailIds) assertEquals("Instagram", states.last().serviceNameOrNull()) } @@ -88,8 +88,8 @@ class ReceivedAfternoteDetailViewModelTest { val states = recordStates(viewModel) // init 로드가 아직 도는 중 — 첫 resume(스킵) 뒤 또 한 번 resume 이 와도 중복이 없어야 한다. - viewModel.refreshOnReturn() - viewModel.refreshOnReturn() + viewModel.onIntent(ReceivedAfternoteDetailIntent.RefreshOnReturn) + viewModel.onIntent(ReceivedAfternoteDetailIntent.RefreshOnReturn) gate.complete(Unit) assertEquals(listOf(42L), repository.requestedDetailIds) @@ -113,8 +113,8 @@ class ReceivedAfternoteDetailViewModelTest { val viewModel = viewModel(repository) val states = recordStates(viewModel) - viewModel.refreshOnReturn() // 첫 진입의 ON_RESUME — 스킵 - viewModel.refreshOnReturn() // 백스택 복귀의 ON_RESUME + viewModel.onIntent(ReceivedAfternoteDetailIntent.RefreshOnReturn) // 첫 진입의 ON_RESUME — 스킵 + viewModel.onIntent(ReceivedAfternoteDetailIntent.RefreshOnReturn) // 백스택 복귀의 ON_RESUME assertEquals(listOf(42L, 42L), repository.requestedDetailIds) assertEquals("Facebook", states.last().serviceNameOrNull()) @@ -141,8 +141,8 @@ class ReceivedAfternoteDetailViewModelTest { val viewModel = viewModel(repository, errorReporter = reporter) val states = recordStates(viewModel) - viewModel.refreshOnReturn() // 첫 진입의 ON_RESUME — 스킵 - viewModel.refreshOnReturn() // 백스택 복귀의 ON_RESUME + viewModel.onIntent(ReceivedAfternoteDetailIntent.RefreshOnReturn) // 첫 진입의 ON_RESUME — 스킵 + viewModel.onIntent(ReceivedAfternoteDetailIntent.RefreshOnReturn) // 백스택 복귀의 ON_RESUME // 잘 보고 있던 상세가 에러 화면으로 대체되지 않는다. assertEquals("Instagram", states.last().serviceNameOrNull()) @@ -175,7 +175,7 @@ class ReceivedAfternoteDetailViewModelTest { val states = recordStates(viewModel) // 첫 로드가 매달린 사이 사용자가 «다시 시도하기» — 옛 Job 은 잘리고 새 로드가 화면을 채운다. - viewModel.retry() + viewModel.onIntent(ReceivedAfternoteDetailIntent.Retry) assertEquals("Fresh", states.last().serviceNameOrNull()) // 잘린 옛 로드가 이제야 값을 들고 돌아온다. diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModelTest.kt index e508ac4f14..514ad56f7f 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModelTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/playlist/ReceiverMemorialPlaylistViewModelTest.kt @@ -74,7 +74,7 @@ class ReceiverMemorialPlaylistViewModelTest { assertEquals(IOException::class.java.name, errorReporter.reportedErrors.single().message) assertTrue(errorReporter.reportedErrors.none { it.message?.contains("sensitive") == true }) - viewModel.retry() + viewModel.onIntent(ReceiverMemorialPlaylistIntent.Retry) assertEquals(listOf(42L, 42L), repository.requestedDetailIds) assertEquals(2, errorReporter.reportedErrors.size) @@ -98,7 +98,7 @@ class ReceiverMemorialPlaylistViewModelTest { val viewModel = viewModel(afternoteId = 42L, repository = repository) // 첫 진입 화면의 ON_RESUME (init 로드는 이미 실패로 종료됨). - viewModel.refreshOnReturn() + viewModel.onIntent(ReceiverMemorialPlaylistIntent.RefreshOnReturn) assertEquals(listOf(42L), repository.requestedDetailIds) assertTrue(viewModel.uiState.value is ReceiverMemorialPlaylistUiState.Error) @@ -118,8 +118,8 @@ class ReceiverMemorialPlaylistViewModelTest { val viewModel = viewModel(afternoteId = 42L, repository = repository) // init 로드가 아직 도는 중 — 첫 resume(스킵) 뒤 또 한 번 resume 이 와도 중복이 없어야 한다. - viewModel.refreshOnReturn() - viewModel.refreshOnReturn() + viewModel.onIntent(ReceiverMemorialPlaylistIntent.RefreshOnReturn) + viewModel.onIntent(ReceiverMemorialPlaylistIntent.RefreshOnReturn) gate.complete(Unit) assertEquals(listOf(42L), repository.requestedDetailIds) @@ -147,8 +147,8 @@ class ReceiverMemorialPlaylistViewModelTest { viewModel.uiState.collect { states += it } } - viewModel.refreshOnReturn() // 첫 진입의 ON_RESUME — 스킵 - viewModel.refreshOnReturn() // 백스택 복귀의 ON_RESUME + viewModel.onIntent(ReceiverMemorialPlaylistIntent.RefreshOnReturn) // 첫 진입의 ON_RESUME — 스킵 + viewModel.onIntent(ReceiverMemorialPlaylistIntent.RefreshOnReturn) // 백스택 복귀의 ON_RESUME assertEquals(listOf(42L, 42L), repository.requestedDetailIds) val refreshed = states.last() as ReceiverMemorialPlaylistUiState.Success @@ -178,8 +178,8 @@ class ReceiverMemorialPlaylistViewModelTest { errorReporter = errorReporter, ) - viewModel.refreshOnReturn() // 첫 진입의 ON_RESUME — 스킵 - viewModel.refreshOnReturn() // 백스택 복귀의 ON_RESUME + viewModel.onIntent(ReceiverMemorialPlaylistIntent.RefreshOnReturn) // 첫 진입의 ON_RESUME — 스킵 + viewModel.onIntent(ReceiverMemorialPlaylistIntent.RefreshOnReturn) // 백스택 복귀의 ON_RESUME // 잘 보고 있던 목록이 에러 화면으로 대체되지 않는다. val state = viewModel.uiState.value as ReceiverMemorialPlaylistUiState.Success diff --git a/feature/afternote/presentation/src/testFixtures/kotlin/com/afternote/feature/afternote/presentation/testing/AfternoteScreenFixtures.kt b/feature/afternote/presentation/src/testFixtures/kotlin/com/afternote/feature/afternote/presentation/testing/AfternoteScreenFixtures.kt new file mode 100644 index 0000000000..469e214f50 --- /dev/null +++ b/feature/afternote/presentation/src/testFixtures/kotlin/com/afternote/feature/afternote/presentation/testing/AfternoteScreenFixtures.kt @@ -0,0 +1,52 @@ +package com.afternote.feature.afternote.presentation.testing + +import androidx.compose.runtime.Composable +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository +import com.afternote.feature.afternote.presentation.home.AfternoteHomeEntry +import com.afternote.feature.afternote.presentation.home.AfternoteHomeViewModel +import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailRoute +import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel +import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteRoute +import com.afternote.feature.receiver.domain.repository.ReceiverRepository + +/** 실제 목록 VM과 화면을 연결한다. 테스트는 저장소 응답과 화면의 사용자 액션으로 동작을 검증한다. */ +public class AfternoteHomeScreenFixture( + repository: AfternoteRepository, + errorReporter: ErrorReporter, +) { + private val viewModel = AfternoteHomeViewModel(repository, errorReporter) + + @Composable + public fun Content( + navigateToDetail: (Long) -> Unit, + navigateToAdd: (AfternoteType) -> Unit, + onSettingClick: () -> Unit, + ) { + AfternoteHomeEntry(navigateToDetail, navigateToAdd, onSettingClick, viewModel) + } +} + +/** 수신 상세의 실제 조회·재시도·Lifecycle·렌더 경계를 앱 기기 테스트에서도 그대로 사용한다. */ +public class ReceivedAfternoteDetailScreenFixture( + afternoteId: Long, + receiverRepository: ReceiverRepository, + errorReporter: ErrorReporter, +) { + private val viewModel = + ReceivedAfternoteDetailViewModel( + route = ReceivedAfternoteRoute.DetailRoute(afternoteId), + receiverRepository = receiverRepository, + errorReporter = errorReporter, + ) + + @Composable + public fun Content( + onNavigateBack: () -> Unit, + onNavigateToFullList: () -> Unit, + onNavigateToPlaylist: (Long) -> Unit, + ) { + ReceivedAfternoteDetailRoute(onNavigateBack, onNavigateToFullList, onNavigateToPlaylist, viewModel) + } +} diff --git a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt index 226b7f263a..33528579ec 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt @@ -487,19 +487,6 @@ class MviContractKonsistTest { "com.afternote.feature.setting.presentation.receiver.ReceiverRegisterViewModel._events", ) - /** #1804 가 뺀다. */ - private val ISSUE_1804_AFTERNOTE = - setOf( - "com.afternote.feature.afternote.presentation.AfternoteHostViewModel", - "com.afternote.feature.afternote.presentation.detail.AfternoteDetailViewModel", - "com.afternote.feature.afternote.presentation.editor.memorial.AddSongViewModel", - "com.afternote.feature.afternote.presentation.editor.receiver.SelectReceiverViewModel", - "com.afternote.feature.afternote.presentation.home.AfternoteHomeViewModel", - "com.afternote.feature.afternote.presentation.receiver.afternotelist.ReceiverAfternoteHomeViewModel", - "com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel", - "com.afternote.feature.afternote.presentation.receiver.playlist.ReceiverMemorialPlaylistViewModel", - ) - /** #1808 이 뺀다. */ private val ISSUE_1808_HOME = setOf( @@ -579,7 +566,6 @@ class MviContractKonsistTest { val PENDING_MVI_MIGRATION = UNASSIGNED_ONBOARDING + UNASSIGNED_RECEIVER + - ISSUE_1804_AFTERNOTE + ISSUE_1805_SETTING + ISSUE_1806_TIMELETTER + ISSUE_1807_MINDRECORD + diff --git a/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt index faa5b88f15..c88c00190a 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt @@ -296,15 +296,6 @@ class ProductionVisibilityKonsistTest { "core/ui/src/main/kotlin/com/afternote/core/ui/topbar/HomeTopBar.kt::property PROFILE_ICON_TEST_TAG [implicit-public]", ) - /** - * 남은 항목은 MVI 전환(#1804)이 다시 열 화면 seam 이라 #1817 이 그 뒤에 판정한다. - * #1673 의 나머지(mapper·test tag·Saver·목록 매퍼)는 해소돼 목록에서 빠졌다. - */ - val ISSUE_1673_AFTERNOTE = - setOf( - "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/detail/AfternoteDetailRoute.kt::function DeleteInProgressOverlay [internal]", - ) - /** * 남은 항목은 MVI 전환(#1807)이 다시 열 Content seam 이라 #1818 이 그 뒤에 판정한다. * #1674 의 나머지(매퍼 2건·화면 helper 3건·요약 helper 2건·주간 날짜 복원)는 해소돼 @@ -356,7 +347,6 @@ class ProductionVisibilityKonsistTest { val LEGACY_TEST_DRIVEN_VISIBILITY = ISSUE_1671_PLATFORM + ISSUE_1672_CORE + - ISSUE_1673_AFTERNOTE + ISSUE_1674_MINDRECORD + ISSUE_1675_ONBOARDING + ISSUE_1676_RECEIVER + diff --git a/konsist/src/test/kotlin/com/afternote/konsist/RefreshOnReturnWiringKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/RefreshOnReturnWiringKonsistTest.kt index a7d1feec94..3fc7986eb7 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/RefreshOnReturnWiringKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/RefreshOnReturnWiringKonsistTest.kt @@ -1,6 +1,8 @@ package com.afternote.konsist import com.lemonappdev.konsist.api.declaration.KoFileDeclaration +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test /** @@ -19,7 +21,7 @@ import org.junit.Test * * ### 규칙 * main 소스셋의 어느 클래스가 `refreshOnReturn` 을 선언하면, **자기 파일이 아닌** main 소스셋 - * 파일 중 `Lifecycle.Event.ON_RESUME` 과 `refreshOnReturn(` 을 함께 갖고 그 클래스 이름을 + * 파일 중 `Lifecycle.Event.ON_RESUME`과 직접 갱신 호출 또는 해당 ViewModel의 RefreshOnReturn Intent를 갖고 클래스 이름을 * 참조하는 파일이 최소 하나 있어야 한다. * * 모듈을 가로지르는 결선(홈 탭은 `feature:home:presentation` 의 ViewModel 을 app 모듈 @@ -35,7 +37,7 @@ class RefreshOnReturnWiringKonsistTest { @Test fun `refreshOnReturn 을 선언한 ViewModel 은 ON_RESUME 결선을 갖는다`() { val mainFiles = mainSourceFiles() - val wiringSites = mainFiles.filter { ON_RESUME in it.text && REFRESH_CALL in it.text } + val wiringSites = mainFiles.filter { ON_RESUME in it.text } val unwired = mainFiles @@ -46,7 +48,7 @@ class RefreshOnReturnWiringKonsistTest { .map { declaration -> file to declaration.name } }.filter { (declaringFile, className) -> wiringSites.none { site -> - site.projectPath != declaringFile.projectPath && site.references(className) + site.projectPath != declaringFile.projectPath && site.references(className) && hasRefreshCall(site.text, className) } }.map { (declaringFile, className) -> "${declaringFile.normalizedProjectPath()} — $className" } @@ -64,6 +66,23 @@ class RefreshOnReturnWiringKonsistTest { } } + @Test + fun `MVI 전환 뒤에도 해당 ViewModel의 resume Intent 결선을 요구한다`() { + assertTrue(hasRefreshCall("viewModel.onIntent(ExampleIntent.RefreshOnReturn)", "ExampleViewModel")) + assertTrue(hasRefreshCall("viewModel.refreshOnReturn()", "ExampleViewModel")) + assertFalse(hasRefreshCall("viewModel.onIntent(OtherIntent.RefreshOnReturn)", "ExampleViewModel")) + assertFalse(hasRefreshCall("viewModel.onIntent(ExampleIntent.Retry)", "ExampleViewModel")) + } + + private fun hasRefreshCall( + text: String, + className: String, + ): Boolean { + val intentName = className.removeSuffix("ViewModel") + "Intent" + val intentCall = Regex("""\bonIntent\s*\(\s*${Regex.escape(intentName)}\.RefreshOnReturn\s*\)""") + return REFRESH_CALL in text || intentCall.containsMatchIn(text) + } + private fun mainSourceFiles(): List = AfternoteKonsistScope.files.filter { "/src/main/" in it.normalizedProjectPath() }