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..d6c5910dea 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt @@ -76,6 +76,7 @@ class AfternoteAuthorExtendedAndroidTest { navigateToDetail = detailRoutes::add, navigateToAdd = addRoutes::add, onSettingClick = {}, + onDraftListClick = {}, viewModel = viewModel, ) } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteDraftNavHostAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteDraftNavHostAndroidTest.kt new file mode 100644 index 0000000000..de27b49480 --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteDraftNavHostAndroidTest.kt @@ -0,0 +1,450 @@ +package com.afternote.afternote_fe + +import androidx.activity.compose.setContent +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.paging.LoadState +import androidx.paging.LoadStates +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.afternote.afternote_fe.test.FailureArtifactRule +import com.afternote.afternote_fe.test.HiltTestActivity +import com.afternote.core.ui.bottombar.BottomNavTab +import com.afternote.core.ui.navigation.FeatureStackBoundary +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.data.di.AfternoteAuthorRepositoryModule +import com.afternote.feature.afternote.data.repositoryimpl.author.MemorialMediaUploadRepositoryImpl +import com.afternote.feature.afternote.data.repositoryimpl.author.MemorialThumbnailUploadRepositoryImpl +import com.afternote.feature.afternote.data.repositoryimpl.author.MusicSearchRepositoryImpl +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.domain.model.author.Detail +import com.afternote.feature.afternote.domain.model.author.DetailContent +import com.afternote.feature.afternote.domain.model.author.DetailReceiver +import com.afternote.feature.afternote.domain.model.author.DetailTimestamps +import com.afternote.feature.afternote.domain.model.author.DraftDetail +import com.afternote.feature.afternote.domain.model.author.ListItem +import com.afternote.feature.afternote.domain.model.author.playlist.MemorialMedia +import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository +import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MemorialThumbnailUploadRepository +import com.afternote.feature.afternote.domain.repository.author.MusicSearchRepository +import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository +import com.afternote.feature.afternote.presentation.navigation.AfternoteExternalActions +import com.afternote.feature.afternote.presentation.navigation.AfternoteNavHost +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.flowOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** Uses the production host and Hilt factories; no test access to internal route or ViewModel types. */ +@HiltAndroidTest +@UninstallModules(AfternoteAuthorRepositoryModule::class) +@RunWith(AndroidJUnit4::class) +class AfternoteDraftNavHostAndroidTest { + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeRule = createAndroidComposeRule() + + @get:Rule(order = 2) + val failureArtifactRule = FailureArtifactRule { composeRule.onRoot().captureToImage().asAndroidBitmap() } + + private val fake = FakeAfternoteRepository() + + @BindValue + @JvmField + val repository: AfternoteRepository = fake + + private lateinit var restorationTester: StateRestorationTester + + @Before + fun setUp() { + hiltRule.inject() + // Static PagingData.from has no load-state completion by default. Model the completed + // repository response explicitly so the empty published list can leave its loading state. + fake.onGetPagedAfternotes = { type -> + completedPage(fake.items.filter { !it.isDraft && (type == null || it.type == type) }) + } + fake.onGetPagedDrafts = { type -> + completedPage(fake.items.filter { it.isDraft && (type == null || it.type == type) }) + } + fake.items += + ListItem(id = DRAFT_ID, serviceName = DRAFT_TITLE, date = "2026.09.08", type = AfternoteType.GALLERY_AND_FILES, isDraft = true) + fake.draftDetails[DRAFT_ID] = draft() + } + + @Test + fun draftList_resumeAndRestoreRecreatesAssistedDraftRoute_thenBackReturnsToListAndHome() { + launchHost() + openDraft() + assertEquals(listOf(DRAFT_ID), fake.requestedDraftDetailIds.toList()) + assertTrue(fake.requestedDetailIds.isEmpty()) + composeRule.onNodeWithText("임시저장").assertIsEnabled() + composeRule.onNodeWithText("등록").assertIsEnabled() + + // Clear retained ViewModels as well as restoring saved composition state so a fresh assisted + // factory must receive the restored draft key; an existing ViewModel cannot mask a lost flag. + composeRule.runOnIdle { composeRule.activity.viewModelStore.clear() } + restorationTester.emulateSavedInstanceStateRestore() + waitForText("등록") + composeRule.waitUntil(5_000) { fake.requestedDraftDetailIds.size == 2 } + composeRule.onNodeWithText(DRAFT_TITLE).assertIsDisplayed() + assertTrue(fake.requestedDetailIds.isEmpty()) + + back() + waitForText("임시 저장된 애프터노트") + composeRule.onNodeWithText(DRAFT_TITLE).assertIsDisplayed() + back() + waitForText("임시저장") + composeRule.onNodeWithText("임시 저장된 애프터노트").assertDoesNotExist() + } + + @Test + fun activityRecreationRetainsUnsubmittedDraftInputAndEditorViewModel() { + fake.items[0] = fake.items[0].copy(type = AfternoteType.SOCIAL_NETWORK) + fake.draftDetails[DRAFT_ID] = draft().copy(type = AfternoteType.SOCIAL_NETWORK) + composeRule.activityRule.scenario.onActivity(::setActivityHostContent) + waitForText("임시저장") + openDraft() + composeRule + .onNodeWithText("아이디를 입력해 주세요.") + .performScrollTo() + .performTextInput("still-writing") + + composeRule.activityRule.scenario.recreate() + composeRule.activityRule.scenario.onActivity(::setActivityHostContent) + waitForText("still-writing") + composeRule.onNodeWithText("still-writing").assertIsDisplayed() + // Configuration recreation retains the flow ViewModel and does not refetch stale prefill. + assertEquals(listOf(DRAFT_ID), fake.requestedDraftDetailIds.toList()) + assertTrue(fake.requestedDetailIds.isEmpty()) + back() + waitForText("나가기") + composeRule.onNodeWithText("나가기").performClick() + waitForText("임시 저장된 애프터노트") + } + + @Test + fun draftSaveKeepsDraft_thenPublishClearsFlagAndReturnsHome() { + launchHost() + openDraft() + composeRule.onNodeWithText("임시저장").performClick() + composeRule.waitUntil(5_000) { fake.updateCalls.size == 1 } + waitForText("임시저장") + assertEquals(DRAFT_ID, fake.updateCalls.single().first) + assertEquals( + true, + fake.updateCalls + .single() + .second.isDraft, + ) + + // A complete draft can be published through the same real editor flow. + fake.draftDetails[DRAFT_ID] = + draft().copy( + processingMethods = listOf("계정을 그대로 유지해 주세요"), + receivers = listOf(DetailReceiver(receiverId = 7L, name = "김수신", relation = "딸")), + ) + openDraft() + composeRule.onNodeWithText("등록").performClick() + composeRule.waitUntil(5_000) { fake.updateCalls.size == 2 } + assertEquals(DRAFT_ID, fake.updateCalls.last().first) + assertEquals( + false, + fake.updateCalls + .last() + .second.isDraft, + ) + waitForText("임시저장") + composeRule.onNodeWithText("등록").assertDoesNotExist() + } + + @Test + fun homeFilterLoadingAndRetryKeepDraftEntryActionable() { + val firstFilterLoad = CompletableDeferred() + val retryFilterLoad = CompletableDeferred() + var filterLoads = 0 + fake.onGetPagedAfternotes = { type -> + if (type == null) { + completedPage(emptyList()) + } else { + Pager(PagingConfig(pageSize = 20)) { + object : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + filterLoads += 1 + return if (filterLoads == 1) { + firstFilterLoad.await() + LoadResult.Error(IllegalStateException("offline")) + } else { + retryFilterLoad.await() + LoadResult.Page(emptyList(), prevKey = null, nextKey = null) + } + } + + override fun getRefreshKey(state: PagingState): Int? = null + } + }.flow + } + } + launchHost() + waitForText("임시저장") + try { + composeRule.mainClock.autoAdvance = false + composeRule.onNodeWithText("소셜네트워크").performClick() + composeRule.mainClock.advanceTimeBy(500) + composeRule + .onNodeWithText("임시저장") + .assertIsDisplayed() + .assertIsEnabled() + .performClick() + composeRule.mainClock.advanceTimeBy(500) + composeRule.onNodeWithText("임시 저장된 애프터노트").assertIsDisplayed() + + back() + composeRule.mainClock.advanceTimeBy(500) + firstFilterLoad.complete(Unit) + composeRule.mainClock.autoAdvance = true + waitForText("다시 시도") + + composeRule.mainClock.autoAdvance = false + composeRule.onNodeWithText("다시 시도").performClick() + composeRule.mainClock.advanceTimeBy(500) + composeRule.onNodeWithText("다시 시도").assertDoesNotExist() + composeRule + .onNodeWithText("임시저장") + .assertIsDisplayed() + .assertIsEnabled() + .performClick() + composeRule.mainClock.advanceTimeBy(500) + composeRule.onNodeWithText("임시 저장된 애프터노트").assertIsDisplayed() + composeRule.waitUntil(5_000) { filterLoads == 2 } + assertEquals(listOf(null, null), fake.requestedDraftTypes.toList()) + assertEquals(AfternoteType.SOCIAL_NETWORK, fake.requestedTypes.last()) + } finally { + firstFilterLoad.complete(Unit) + retryFilterLoad.complete(Unit) + composeRule.mainClock.autoAdvance = true + } + } + + @Test + fun draftPrefillFailureDisablesSaving_untilRetryUsesDraftContract() { + val releaseLoad = CompletableDeferred() + var attempts = 0 + fake.onGetDraftDetail = { + attempts += 1 + if (attempts == 1) { + releaseLoad.await() + Result.failure(IllegalStateException("offline")) + } else { + Result.success(draft()) + } + } + launchHost() + composeRule.onNodeWithText("임시저장").performClick() + waitForText(DRAFT_TITLE) + // Hold the animated prefill skeleton on a known frame; waiting for Compose idleness + // while the repository is deliberately suspended would wait for the shimmer forever. + composeRule.mainClock.autoAdvance = false + try { + composeRule.onNodeWithText(DRAFT_TITLE).performClick() + composeRule.mainClock.advanceTimeBy(500) + composeRule.onNodeWithText("등록").assertIsNotEnabled() + composeRule.onNodeWithText("임시저장").assertIsNotEnabled() + } finally { + releaseLoad.complete(Unit) + composeRule.mainClock.autoAdvance = true + } + waitForText("다시 불러오기") + composeRule.onNodeWithText("등록").assertIsNotEnabled() + composeRule.onNodeWithText("임시저장").assertIsNotEnabled() + composeRule.onNodeWithText("다시 불러오기").performClick() + waitForText(DRAFT_TITLE) + composeRule.onNodeWithText("등록").assertIsEnabled() + composeRule.onNodeWithText("임시저장").assertIsEnabled() + assertEquals(listOf(DRAFT_ID, DRAFT_ID), fake.requestedDraftDetailIds.toList()) + assertTrue(fake.requestedDetailIds.isEmpty()) + assertTrue(fake.updateCalls.isEmpty()) + } + + @Test + fun draftList_refreshErrorCanRetryToEmpty_thenBackReturnsHome() { + var attempts = 0 + fake.onGetPagedDrafts = { + Pager(PagingConfig(pageSize = 20)) { + object : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + attempts += 1 + return if (attempts == 1) { + LoadResult.Error(IllegalStateException("offline")) + } else { + LoadResult.Page(emptyList(), prevKey = null, nextKey = null) + } + } + + override fun getRefreshKey(state: PagingState): Int? = null + } + }.flow + } + launchHost() + composeRule.onNodeWithText("임시저장").performClick() + waitForText("다시 시도") + composeRule.onNodeWithText("다시 시도").performClick() + waitForText(composeRule.activity.getString(com.afternote.feature.afternote.presentation.R.string.afternote_draft_list_empty_body)) + assertEquals(2, attempts) + assertEquals(listOf(null), fake.requestedDraftTypes.toList()) + back() + waitForText("임시저장") + } + + @Test + fun publishedEditDoesNotOfferDraftSave() { + val publishedId = 81L + fake.items += ListItem(id = publishedId, serviceName = "발행한 기록", date = "2026.09.08", type = AfternoteType.GALLERY_AND_FILES) + fake.details[publishedId] = + Detail( + id = publishedId, + serviceName = "발행한 기록", + timestamps = DetailTimestamps(updatedAt = "2026-09-08"), + receivers = emptyList(), + leaveMessageBlocks = emptyList(), + content = DetailContent.Gallery(processingMethods = listOf("계정을 그대로 유지해 주세요")), + ) + launchHost() + composeRule.onNodeWithText("발행한 기록").performClick() + composeRule.onNodeWithContentDescription("수정").performClick() + waitForText("수정하기") + composeRule.onNodeWithText("수정하기").performClick() + waitForText("등록") + composeRule.onNodeWithText("임시저장").assertDoesNotExist() + assertTrue(fake.requestedDraftDetailIds.isEmpty()) + assertTrue(fake.requestedDetailIds.all { it == publishedId }) + } + + private fun setActivityHostContent(activity: HiltTestActivity) { + // The same composition call site is required for saveable keys on both Activity instances. + activity.setContent { HostContent() } + } + + private fun completedPage(items: List) = + flowOf( + PagingData.from( + items, + sourceLoadStates = + LoadStates( + refresh = LoadState.NotLoading(endOfPaginationReached = true), + prepend = LoadState.NotLoading(endOfPaginationReached = true), + append = LoadState.NotLoading(endOfPaginationReached = true), + ), + ), + ) + + private fun launchHost() { + restorationTester = StateRestorationTester(composeRule) + restorationTester.setContent { HostContent() } + waitForText("임시저장") + } + + @Composable + private fun HostContent() { + AfternoteTheme { + AfternoteNavHost( + boundary = FeatureStackBoundary { error("Unexpected host exit") }, + externalActions = + object : AfternoteExternalActions { + override fun navigateToBottomTab(tab: BottomNavTab) = error("Unexpected tab") + + override fun navigateToSetting() = error("Unexpected setting") + + override fun onFingerprintAuthFailed(message: String) = error(message) + }, + ) + } + } + + private fun openDraft() { + composeRule.onNodeWithText("임시저장").performClick() + waitForText("임시 저장된 애프터노트") + waitForText(DRAFT_TITLE) + val titleBounds = composeRule.onNodeWithText("임시 저장된 애프터노트").fetchSemanticsNode().boundsInRoot + val itemBounds = composeRule.onNodeWithText(DRAFT_TITLE).fetchSemanticsNode().boundsInRoot + assertTrue("Draft rows must be laid out below the navigation bar", itemBounds.top >= titleBounds.bottom) + composeRule.onNodeWithText(DRAFT_TITLE).performClick() + waitForText("등록") + waitForText(DRAFT_TITLE) + } + + private fun back() = composeRule.onNodeWithContentDescription("뒤로가기").performClick() + + private fun waitForText(text: String) { + composeRule.waitUntil(5_000) { + composeRule + .onAllNodes( + androidx.compose.ui.test + .hasText(text), + ).fetchSemanticsNodes() + .isNotEmpty() + } + } + + private fun draft() = + DraftDetail( + id = DRAFT_ID, + type = AfternoteType.GALLERY_AND_FILES, + serviceName = DRAFT_TITLE, + timestamps = DetailTimestamps(updatedAt = "2026-09-08"), + receivers = emptyList(), + leaveMessageBlocks = emptyList(), + credentials = null, + processingMethods = emptyList(), + songs = emptyList(), + media = MemorialMedia(photoUrl = null, videoUrl = null, thumbnailUrl = null), + ) + + @Module + @InstallIn(SingletonComponent::class) + object UnusedAuthorBindings { + @Provides + fun music(impl: MusicSearchRepositoryImpl): MusicSearchRepository = impl + + @Provides + fun thumbnail(impl: MemorialThumbnailUploadRepositoryImpl): MemorialThumbnailUploadRepository = impl + + @Provides + fun media(impl: MemorialMediaUploadRepositoryImpl): MemorialMediaUploadRepository = impl + } + + private companion object { + const val DRAFT_ID = 71L + const val DRAFT_TITLE = "이어서 작성할 사진 기록" + } +} 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 711c00e8cc..8bdfe5f243 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt @@ -85,6 +85,7 @@ class AfternoteHomeRefreshBannerAndroidTest { navigateToDetail = {}, navigateToAdd = {}, onSettingClick = {}, + onDraftListClick = null, viewModel = viewModel, ) } diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/AfternoteDto.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/AfternoteDto.kt index 15783f82ce..f753c6060f 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/AfternoteDto.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/AfternoteDto.kt @@ -116,8 +116,7 @@ data class AfternotePlaylistDto( * `[]` 가 곧 전부 삭제다. 기본값 `emptyList()` 가 있던 동안은 그 배열이 키째 빠져 「유지」로 * 흡수됐고, 곡을 전부 뺀 저장이 서버에 반영되지 않았다 (#1599). * - [atmosphere] 는 기본값을 남겨 늘 생략한다. FE 화면에 없는 값이라 삭제를 지시할 자격이 없다. - * - `memorialAudioUrl` 은 아직 필드를 두지 않는다 — 같은 이유이고, 첨부 수단이 생기는 #1118 에서 - * 위 규칙대로 편입한다. + * - `memorialAudioUrl` 은 지원하지 않는 기능이므로 필드를 두지 않고 항상 생략한다. * * 생성(POST)에서 `[]` 는 종전의 생략과 동작이 같다 — `PlaylistRelationStrategy.save` 는 0건을 * 순회하고 끝나고, `PlaylistValidationStrategy.requirePlaylistSongs` 는 `null` 과 `isEmpty()` 를 diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListContract.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListContract.kt new file mode 100644 index 0000000000..1acf22cc10 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListContract.kt @@ -0,0 +1,16 @@ +package com.afternote.feature.afternote.presentation.draft + +import androidx.paging.PagingData +import com.afternote.core.ui.mvi.ReducerEvent +import com.afternote.core.ui.mvi.UiState +import com.afternote.feature.afternote.presentation.shared.component.ListItemUiModel + +internal data class AfternoteDraftListUiState( + val items: PagingData = PagingData.empty(), +) : UiState + +internal sealed interface AfternoteDraftListReducerEvent : ReducerEvent { + data class PageGenerationChanged( + val items: PagingData, + ) : AfternoteDraftListReducerEvent +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListRoute.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListRoute.kt new file mode 100644 index 0000000000..0dc366d502 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListRoute.kt @@ -0,0 +1,42 @@ +package com.afternote.feature.afternote.presentation.draft + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.paging.LoadState +import androidx.paging.compose.collectAsLazyPagingItems +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.presentation.R +import kotlinx.coroutines.flow.map + +/** + * 임시저장 목록 Entry — append 실패는 홈과 같이 Snackbar 로만 알린다. + */ +@Composable +internal fun AfternoteDraftListEntry( + onBackClick: () -> Unit, + onResumeDraft: (id: Long, type: AfternoteType) -> Unit, + viewModel: AfternoteDraftListViewModel = hiltViewModel(), +) { + val pages = remember(viewModel) { viewModel.uiState.map { it.items } } + val items = pages.collectAsLazyPagingItems() + val snackbarHostState = remember { SnackbarHostState() } + + val appendState = items.loadState.append + val appendErrorMessage = stringResource(R.string.afternote_home_append_error) + LaunchedEffect(appendState) { + if (appendState is LoadState.Error) { + snackbarHostState.showSnackbar(message = appendErrorMessage) + } + } + + AfternoteDraftListScreen( + items = items, + onBackClick = onBackClick, + onDraftClick = onResumeDraft, + snackbarHostState = snackbarHostState, + ) +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListScreen.kt new file mode 100644 index 0000000000..5763aa6084 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListScreen.kt @@ -0,0 +1,84 @@ +package com.afternote.feature.afternote.presentation.draft + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import com.afternote.core.ui.loading.LoadingBody +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.shared.component.AfternoteList +import com.afternote.feature.afternote.presentation.shared.component.EmptyListBody +import com.afternote.feature.afternote.presentation.shared.component.ErrorListBody +import com.afternote.feature.afternote.presentation.shared.component.ListItemUiModel + +/** + * 임시저장 목록 화면 — 항목을 고르면 상세가 아니라 **에디터로 이어쓴다**. + * + * 시안이 없어(애프터노트 구역에 임시저장 화면 0건, 2026-09-03 전량 대조) 같은 제품의 기존 임시저장 + * 화면(타임레터 「임시 저장된 레터」)을 따라 세웠다 — 뒤로가기 + 제목, 카드 목록. + * + * 「총 N 개」는 넣지 않았다. 목록 응답이 `content/page/size/hasNext` 뿐이라 총계가 없고 + * (BE `AfternotePageResponse`), 지금까지 불러온 개수를 총계처럼 적으면 스크롤에 따라 숫자가 자란다. + */ +@Composable +internal fun AfternoteDraftListScreen( + items: LazyPagingItems, + onBackClick: () -> Unit, + onDraftClick: (id: Long, type: AfternoteType) -> Unit, + modifier: Modifier = Modifier, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, +) { + val refreshState = items.loadState.refresh + val isInitialLoading = refreshState is LoadState.Loading && items.itemCount == 0 + + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = Color.Transparent, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + DetailTopBar( + title = stringResource(R.string.afternote_draft_list_title), + onBackClick = onBackClick, + ) + }, + ) { paddingValues -> + val bodyModifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + when { + isInitialLoading -> { + LoadingBody(modifier = bodyModifier) + } + + refreshState is LoadState.Error -> { + ErrorListBody(onRetry = items::retry, modifier = bodyModifier) + } + + items.itemCount == 0 -> { + EmptyListBody( + description = stringResource(R.string.afternote_draft_list_empty_body), + modifier = bodyModifier, + ) + } + + else -> { + AfternoteList( + items = items, + onItemClick = onDraftClick, + modifier = bodyModifier, + ) + } + } + } +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListViewModel.kt new file mode 100644 index 0000000000..bff260a8d7 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListViewModel.kt @@ -0,0 +1,48 @@ +package com.afternote.feature.afternote.presentation.draft + +import androidx.lifecycle.viewModelScope +import androidx.paging.cachedIn +import androidx.paging.map +import com.afternote.core.ui.mvi.MviViewModel +import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository +import com.afternote.feature.afternote.presentation.shared.component.toUiModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * 임시저장 목록. + * + * 서버는 발행분과 임시저장을 한 요청에 섞어 주지 않아(`draftOnly` 미전송 = 발행분만) 목록이 따로 선다. + * 종류 필터를 두지 않는 이유는 이 화면의 용도가 «이어쓸 것 고르기» 하나뿐이라서다 — 홈처럼 훑는 화면이 아니다. + */ +@HiltViewModel +internal class AfternoteDraftListViewModel + @Inject + constructor( + afternoteRepository: AfternoteRepository, + ) : MviViewModel(AfternoteDraftListUiState()) { + init { + val drafts = + afternoteRepository + .getPagedDrafts(type = null) + .map { pagingData -> pagingData.map { it.toUiModel() } } + .cachedIn(viewModelScope) + viewModelScope.launch { + drafts.collect { dispatch(AfternoteDraftListReducerEvent.PageGenerationChanged(it)) } + } + } + + // Retry/refresh belong to Paging; navigation is handled by the entry callbacks. + // This read-only ViewModel has no user intents. + override fun onIntent(intent: Nothing): Unit = intent + + override fun reduce( + state: AfternoteDraftListUiState, + event: AfternoteDraftListReducerEvent, + ): AfternoteDraftListUiState = + when (event) { + is AfternoteDraftListReducerEvent.PageGenerationChanged -> state.copy(items = event.items) + } + } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRoute.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRoute.kt index 9036ac28e4..a6eedc43e8 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRoute.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRoute.kt @@ -16,10 +16,11 @@ import com.afternote.feature.afternote.presentation.editor.state.rememberAfterno /** * 작성자 에디터 화면: type-safe editor flow + 단방향 이벤트. * - * 홈의 `visibleItems` 스냅샷은 에디터에 전달하지 않는다. 식별은 라우트의 `itemId`·`initialType` 정도로 최소화한다. + * 홈의 `visibleItems` 스냅샷은 에디터에 전달하지 않는다. 라우트의 `itemId`·`initialType`·`isDraft`를 assisted factory에 전달한다. * - * **수정 진입 데이터 로드:** 상세 화면과 같이 [AfternoteEditorViewModel]의 `init`에서 - * [androidx.lifecycle.SavedStateHandle]의 `itemId`만 보고 Repository `getDetail`을 호출한다 (Compose `LaunchedEffect` 위임 없음). + * **수정·이어쓰기 데이터 로드:** [AfternoteEditorViewModel]이 전달받은 식별자와 draft 여부로 + * Repository의 `getDetail` 또는 `getDraftDetail`을 선택한다. 저장된 폼과 복원 표식은 + * [androidx.lifecycle.SavedStateHandle]이 보존하므로 재생성 시 서버 응답이 작성 중 입력을 덮지 않는다. */ @Composable internal fun AfternoteEditorNavigation( @@ -137,6 +138,16 @@ internal fun AfternoteEditorNavigation( state = state, ) } + val saveDraft = + remember(editViewModel, state) { + buildOnRegisterClick( + editViewModel = editViewModel, + state = state, + asDraft = true, + ) + } + // 발행분 수정에는 임시저장을 제공하지 않는다. 신규 작성과 목록에서 이어쓰는 초안에만 노출한다. + val onSaveDraftClick = saveDraft.takeIf { !editViewModel.isPublishedEdit } // 썸네일 실패는 알리는 것으로 끝내지 않는다 — 영상 재선택 없이 되돌릴 액션을 같은 스낵바에 건다. // 어느 오류에 거는지는 오류 자체가 말한다 ([offersMemorialThumbnailRetry]). val thumbnailRetryAction = @@ -152,6 +163,7 @@ internal fun AfternoteEditorNavigation( form = uiState.form, onBackClick = onPopBackStack, onRegisterClick = onRegisterClick, + onSaveDraftClick = onSaveDraftClick, snackbarMessage = snackbarMessage, snackbarAction = thumbnailRetryAction, onSnackbarMessageConsumed = { diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRouteHelpers.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRouteHelpers.kt index 91b441a69b..9e3ac25602 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRouteHelpers.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorRouteHelpers.kt @@ -62,6 +62,7 @@ internal fun AfternoteEditorError.offersMemorialThumbnailRetry(): Boolean = internal fun buildOnRegisterClick( editViewModel: AfternoteEditorViewModel, state: AfternoteEditorState, + asDraft: Boolean = false, ): () -> Unit = { // 폼 스냅샷은 한 번만 읽는다 — 필드마다 다시 읽으면 조립 도중 갱신이 끼어 서로 다른 시점의 값이 섞인다. @@ -80,6 +81,7 @@ internal fun buildOnRegisterClick( editViewModel.onIntent( AfternoteEditorIntent.Save( payload = payload, + asDraft = asDraft, selectedReceiverIds = form.afternoteEditReceivers.map { it.id }, memorialMedia = SaveAfternoteMemorialMedia( diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorScreen.kt index 00195994f5..9aa4dd460a 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorScreen.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp import com.afternote.core.ui.modifierextention.addFocusCleaner import com.afternote.core.ui.popup.Popup import com.afternote.core.ui.popup.PopupType @@ -64,6 +65,7 @@ fun AfternoteEditorScreen( form: EditorFormState, onBackClick: () -> Unit, onRegisterClick: () -> Unit, + onSaveDraftClick: (() -> Unit)? = null, snackbarMessage: String?, onSnackbarMessageConsumed: () -> Unit, // 스낵바 쌍과 같은 이유로 기본값을 주지 않는다: 새 진입 경로가 검증 팝업 배선을 빠뜨리면 @@ -138,6 +140,36 @@ fun AfternoteEditorScreen( title = stringResource(R.string.afternote_editor_screen_title), onBackClick = onBackAttempt, actions = { + // 임시저장은 정식 등록과 나란히 둔다 — 마음의 기록·타임레터 작성 화면이 「등록」 옆·아래에 + // 「임시저장」을 두는 것과 같은 배치다(애프터노트 구역엔 시안이 없어 그 관례를 따랐다, #808). + // + // 발행분 수정처럼 임시저장이 없는 경로에서는 콜백이 null 이므로 버튼을 그리지 않는다. + // + // 「등록」과 같은 게이트를 받는다 — 저장이 나가 있는 동안·prefill 을 못 읽은 동안 눌리면 + // VM 의 `isSaving` 가드에 걸려 오류도 스낵바도 없이 삼켜진다. 등록만 흐리고 이 버튼을 + // 멀쩡히 두면 사용자는 여기로 연타한다 (#705 규칙, #808 리뷰). + if (onSaveDraftClick != null) { + Text( + text = stringResource(R.string.afternote_editor_save_draft), + style = AfternoteDesign.typography.bodySmallR, + color = + if (isSubmitEnabled) { + AfternoteDesign.colors.gray7 + } else { + AfternoteDesign.colors.gray5 + }, + modifier = + Modifier + .clickable( + enabled = isSubmitEnabled, + role = Role.Button, + onClick = { + focusManager.clearFocus() + onSaveDraftClick() + }, + ).padding(end = 12.dp), + ) + } // 저장이 나가 있는 동안·prefill 을 못 읽은 동안은 «등록» 을 흐리고 눌리지 않게 한다 (#705). // 유휴 상태와 같은 모습이면 사용자는 응답 없는 화면으로 읽고 연타로 중복 저장을 시도한다. Text( diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt index cdb187ae8d..c0de9938af 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/editor/AfternoteEditorViewModel.kt @@ -225,6 +225,8 @@ internal class AfternoteEditorViewModel val isEditing: Boolean get() = route.itemId != null + val isPublishedEdit: Boolean get() = isEditing && !route.isDraft + override fun onIntent(intent: AfternoteEditorIntent) { when (intent) { is AfternoteEditorIntent.SetType -> { 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..ecd4047167 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 @@ -30,6 +30,7 @@ fun AfternoteHomeEntry( navigateToDetail: (Long) -> Unit, navigateToAdd: (AfternoteType) -> Unit, onSettingClick: () -> Unit, + onDraftListClick: (() -> Unit)?, viewModel: AfternoteHomeViewModel = hiltViewModel(), ) { val selectedType by viewModel.selectedType.collectAsStateWithLifecycle() @@ -59,6 +60,7 @@ fun AfternoteHomeEntry( AfternoteHomeScreen( items = items, + onDraftListClick = onDraftListClick, selectedType = selectedType, snackbarHostState = snackbarHostState, onTypeSelected = viewModel::selectTab, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeRoute.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeRoute.kt index 081f722101..24e70ca383 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeRoute.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeRoute.kt @@ -8,10 +8,12 @@ internal fun AfternoteHomeNavigation( onNavigateToDetail: (itemId: Long) -> Unit, onNavigateToNewEditor: (initialType: AfternoteType) -> Unit, onNavigateToSetting: () -> Unit, + onNavigateToDraftList: (() -> Unit)?, ) { AfternoteHomeEntry( navigateToDetail = onNavigateToDetail, navigateToAdd = onNavigateToNewEditor, onSettingClick = onNavigateToSetting, + onDraftListClick = onNavigateToDraftList, ) } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeScreen.kt index fee65825c8..5036aed3af 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeScreen.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeScreen.kt @@ -68,6 +68,7 @@ internal fun AfternoteHomeScreen( snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, onFabClick: (() -> Unit)? = null, onSettingClick: (() -> Unit)? = null, + onDraftListClick: (() -> Unit)? = null, ) { val refreshState = items.loadState.refresh val isRefreshing = refreshState is LoadState.Loading && items.itemCount > 0 @@ -127,6 +128,7 @@ internal fun AfternoteHomeScreen( is AfternoteHomeBodyState.Reloading -> { ReloadingBody( + onDraftListClick = onDraftListClick, headerDescription = headerDescription, nextStep = nextStep, selectedType = bodyState.selectedType, @@ -145,6 +147,7 @@ internal fun AfternoteHomeScreen( is AfternoteHomeBodyState.FilteredError -> { FilteredErrorBody( + onDraftListClick = onDraftListClick, headerDescription = headerDescription, nextStep = nextStep, selectedType = bodyState.selectedType, @@ -171,6 +174,7 @@ internal fun AfternoteHomeScreen( onTypeSelected = onTypeSelected, onListItemClick = onListItemClick, headerDescription = headerDescription, + onDraftListClick = onDraftListClick, filterRowScrollState = filterRowScrollState, ) } @@ -179,6 +183,7 @@ internal fun AfternoteHomeScreen( AfternoteHomeBodyState.Empty -> { if (showsHeaderOnEmptyList) { EmptyHomeBody( + onDraftListClick = onDraftListClick, headerDescription = headerDescription, nextStep = nextStep, emptyListDescription = emptyListDescription, 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..65796227bf 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 @@ -7,12 +7,11 @@ 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.feature.afternote.domain.model.author.ListItem import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage import com.afternote.feature.afternote.presentation.reporting.recordAfternoteFailure import com.afternote.feature.afternote.presentation.shared.component.ListItemUiModel -import com.afternote.feature.afternote.presentation.shared.util.getIconResForService +import com.afternote.feature.afternote.presentation.shared.component.toUiModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -79,12 +78,3 @@ class AfternoteHomeViewModel lastReportedFailureType = null } } - -private fun ListItem.toUiModel(): ListItemUiModel = - ListItemUiModel( - id = id, - serviceName = serviceName, - date = date, - iconResId = getIconResForService(serviceName, type), - type = type, - ) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/EmptyHomeBody.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/EmptyHomeBody.kt index b5050faf79..66a99ed90d 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/EmptyHomeBody.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/EmptyHomeBody.kt @@ -37,6 +37,7 @@ internal fun EmptyHomeBody( onTypeSelected: (AfternoteType?) -> Unit, filterRowScrollState: ScrollState, modifier: Modifier = Modifier, + onDraftListClick: (() -> Unit)? = null, ) { Column( modifier = modifier, @@ -46,6 +47,7 @@ internal fun EmptyHomeBody( HomeHeaderSection( description = headerDescription, nextStep = nextStep, + onDraftListClick = onDraftListClick, ) // 필터 행과 빈 본문 사이에는 간격을 두지 않는다 — 목록 상태의 AfternoteListContent 와 같은 배치다. Column { diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/FilteredErrorBody.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/FilteredErrorBody.kt index 60b1a69e71..0c48ca4fe2 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/FilteredErrorBody.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/FilteredErrorBody.kt @@ -39,6 +39,7 @@ internal fun FilteredErrorBody( onRetry: () -> Unit, filterRowScrollState: ScrollState, modifier: Modifier = Modifier, + onDraftListClick: (() -> Unit)? = null, ) { Column( modifier = modifier, @@ -48,6 +49,7 @@ internal fun FilteredErrorBody( HomeHeaderSection( description = headerDescription, nextStep = nextStep, + onDraftListClick = onDraftListClick, ) // 필터 행과 실패 본문 사이에는 간격을 두지 않는다 — 목록 상태의 AfternoteListContent 와 같은 배치다. // 남은 높이를 weight 로 받아 실패 문구가 «필터 행 아래 영역» 의 가운데에 온다. diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/HomeHeaderSection.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/HomeHeaderSection.kt index 812ccc947c..56a92fe82a 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/HomeHeaderSection.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/HomeHeaderSection.kt @@ -1,10 +1,13 @@ package com.afternote.feature.afternote.presentation.home +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -15,6 +18,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.afternote.core.ui.AfternoteOutlinedCard import com.afternote.core.ui.AfternoteSectionHeader @@ -55,6 +59,7 @@ internal fun HomeHeaderSection( description: String, nextStep: NextStep?, modifier: Modifier = Modifier, + onDraftListClick: (() -> Unit)? = null, ) { Column( modifier = @@ -62,11 +67,39 @@ internal fun HomeHeaderSection( .fillMaxWidth() .padding(horizontal = 25.dp), ) { - Text( - text = stringResource(R.string.afternote_home_title), - style = AfternoteDesign.typography.h1, - color = AfternoteDesign.colors.gray9, - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.afternote_home_title), + style = AfternoteDesign.typography.h1, + color = AfternoteDesign.colors.gray9, + modifier = Modifier.weight(1f), + ) + // 임시저장은 작성자에게만 있다 — 같은 화면을 쓰는 수신자 목록은 null 로 안 그린다(#620 과 같은 이유). + if (onDraftListClick != null) { + // 글자 한 줄은 48dp 에 못 미친다 — 클릭 타깃을 Box 로 넓혀 최소 치수를 맞추고 + // 그 Box 가 클릭·Role 을 갖는다(`assertAccessibleClickTargets` 계약). + Box( + modifier = + Modifier + .defaultMinSize(minWidth = 48.dp, minHeight = 48.dp) + .clickable( + onClickLabel = stringResource(R.string.afternote_home_draft_entry_description), + role = Role.Button, + onClick = onDraftListClick, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.afternote_home_draft_entry), + style = AfternoteDesign.typography.bodySmallR, + color = AfternoteDesign.colors.gray7, + ) + } + } + } Spacer(modifier = Modifier.height(8.dp)) Text( text = description, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/ReloadingBody.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/ReloadingBody.kt index b7a316df83..0bd70dfb4f 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/ReloadingBody.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/ReloadingBody.kt @@ -34,6 +34,7 @@ internal fun ReloadingBody( onTypeSelected: (AfternoteType?) -> Unit, filterRowScrollState: ScrollState, modifier: Modifier = Modifier, + onDraftListClick: (() -> Unit)? = null, ) { Column( modifier = modifier, @@ -43,6 +44,7 @@ internal fun ReloadingBody( HomeHeaderSection( description = headerDescription, nextStep = nextStep, + onDraftListClick = onDraftListClick, ) // 필터 행과 로딩 사이에는 간격을 두지 않는다 — 목록 상태의 AfternoteListContent 와 같은 배치다. Column(modifier = Modifier.weight(1f)) { diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteLocalNavActions.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteLocalNavActions.kt index fd7d03de8c..8164f2c13b 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteLocalNavActions.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteLocalNavActions.kt @@ -5,6 +5,7 @@ import androidx.navigation3.runtime.NavKey import com.afternote.core.ui.bottombar.BottomNavTab import com.afternote.core.ui.navigation.FeatureStackBoundary import com.afternote.core.ui.navigation.popOrExit +import com.afternote.core.ui.navigation.pushSingleTop import com.afternote.core.ui.navigation.replaceAllWith import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.presentation.editor.AfternoteEditorViewModel @@ -47,6 +48,17 @@ internal class AfternoteLocalNavActions( override fun navigateToSetting(): Unit = externalActions.navigateToSetting() + override fun navigateToDraftList(): Unit = backStack.pushSingleTop(AfternoteRoute.DraftListRoute) + + override fun navigateToEditorForResume( + itemId: Long, + initialType: AfternoteType, + ) { + backStack.add( + AfternoteRoute.EditorFlowRoute(itemId = itemId, initialType = initialType, isDraft = true), + ) + } + override fun onFingerprintAuthFailed(message: String): Unit = externalActions.onFingerprintAuthFailed(message) } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteNavActions.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteNavActions.kt index c63b1dc3a1..cf9129db48 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteNavActions.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteNavActions.kt @@ -12,7 +12,7 @@ import com.afternote.feature.afternote.domain.AfternoteType * Screen 콜백 인자(예: `onSongClick`)는 *도메인 이벤트* 자리로 본 인터페이스와 분리. * NavGraph 가 둘을 매핑한다. */ -interface AfternoteNavActions { +internal interface AfternoteNavActions { fun navigateToBottomTab(tab: BottomNavTab) fun popBack() @@ -41,4 +41,19 @@ interface AfternoteNavActions { /** Afternote 홈 TopBar 설정 기어 → 설정 화면(Route.Setting) 진입. */ fun navigateToSetting() + + /** 홈 헤더의 "임시저장" → 임시저장 목록 진입 (#1762 · #1792). */ + fun navigateToDraftList() + + /** + * 임시저장 목록에서 항목 선택 → 이어쓰기로 에디터 진입 (#808 · #1791). + * + * 수정 진입([navigateToEditorForEdit])과 목적지는 같지만 프리필을 읽는 계약이 다르다 — + * 임시저장은 발행 보장이 없는 draft 상세로 관용해서 읽어야 한다. 그래서 같은 라우트에 + * `isDraft` 를 실어 «여는 쪽» 이 어느 계약으로 읽을지 정한다. + */ + fun navigateToEditorForResume( + itemId: Long, + initialType: AfternoteType, + ) } 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 85618dec5f..17170d0335 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 @@ -13,6 +13,7 @@ import com.afternote.core.ui.navigation.FeatureStackBoundary 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.draft.AfternoteDraftListEntry 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 @@ -70,6 +71,7 @@ public fun AfternoteNavHost( onNavigateToDetail = actions::navigateToAfternoteDetail, onNavigateToNewEditor = actions::navigateToNewEditor, onNavigateToSetting = actions::navigateToSetting, + onNavigateToDraftList = actions::navigateToDraftList, ) } } @@ -87,6 +89,15 @@ public fun AfternoteNavHost( } } + entry { + AfternoteLightTheme { + AfternoteDraftListEntry( + onBackClick = actions::popBack, + onResumeDraft = actions::navigateToEditorForResume, + ) + } + } + entry { key -> AfternoteLightTheme { AfternoteEditorFlowHost( diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/model/AfternoteRoute.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/model/AfternoteRoute.kt index b3b372751a..0d9dfa8734 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/model/AfternoteRoute.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/navigation/model/AfternoteRoute.kt @@ -41,4 +41,8 @@ sealed interface AfternoteRoute : NavKey { @Serializable data object FingerprintLoginRoute : AfternoteRoute + + /** 임시저장 목록 (#1762). 홈에서 진입해 항목을 고르면 에디터로 이어진다. */ + @Serializable + data object DraftListRoute : AfternoteRoute } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/component/InfiniteListBody.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/component/InfiniteListBody.kt index aad4aea532..24e8e1e6f2 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/component/InfiniteListBody.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/component/InfiniteListBody.kt @@ -30,6 +30,7 @@ internal fun InfiniteListBody( nextStep: NextStep?, filterRowScrollState: ScrollState, modifier: Modifier = Modifier, + onDraftListClick: (() -> Unit)? = null, ) { Column( modifier = modifier, @@ -39,6 +40,7 @@ internal fun InfiniteListBody( HomeHeaderSection( description = headerDescription, nextStep = nextStep, + onDraftListClick = onDraftListClick, ) AfternoteListContent( items = items, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/component/ListItemUiModelMapper.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/component/ListItemUiModelMapper.kt new file mode 100644 index 0000000000..39dce4ac77 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/component/ListItemUiModelMapper.kt @@ -0,0 +1,14 @@ +package com.afternote.feature.afternote.presentation.shared.component + +import com.afternote.feature.afternote.domain.model.author.ListItem +import com.afternote.feature.afternote.presentation.shared.util.getIconResForService + +/** 목록 카드는 홈·임시저장 목록이 같은 것을 쓴다 — 변환도 한 곳에 둔다. */ +fun ListItem.toUiModel(): ListItemUiModel = + ListItemUiModel( + id = id, + serviceName = serviceName, + date = date, + iconResId = getIconResForService(serviceName, type), + type = type, + ) diff --git a/feature/afternote/presentation/src/main/res/values/strings.xml b/feature/afternote/presentation/src/main/res/values/strings.xml index 682f225139..eecd4952c9 100644 --- a/feature/afternote/presentation/src/main/res/values/strings.xml +++ b/feature/afternote/presentation/src/main/res/values/strings.xml @@ -197,4 +197,9 @@ %1$s님의 플레이리스트 고인이 남긴 곡이 없어요. 총 %1$d곡 + 임시 저장된 애프터노트 + 임시저장 + 이어서 작성할 임시저장 목록 + 아직 임시저장한 애프터노트가 없어요.\n작성 중에 임시저장하면 여기에 보여요. + 임시저장 diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListScreenshotTest.kt new file mode 100644 index 0000000000..539076812d --- /dev/null +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListScreenshotTest.kt @@ -0,0 +1,56 @@ +package com.afternote.feature.afternote.presentation.draft + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.tooling.preview.Preview +import androidx.paging.LoadState +import androidx.paging.LoadStates +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +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.presentation.shared.component.ListItemUiModel +import com.afternote.feature.afternote.presentation.shared.component.toUiModel +import com.android.tools.screenshot.PreviewTest +import kotlinx.coroutines.flow.MutableStateFlow + +@PreviewTest +@Preview(showBackground = true, widthDp = 360, heightDp = 800) +@Composable +internal fun draftListEmptyScreenshot() { + DraftListPreview(emptyList()) +} + +@PreviewTest +@Preview(showBackground = true, widthDp = 360, heightDp = 800) +@Composable +internal fun draftListItemsScreenshot() { + DraftListPreview( + listOf( + ListItem(1, "가족에게 남기는 사진 기록", "2026.09.08", AfternoteType.GALLERY_AND_FILES, isDraft = true).toUiModel(), + ListItem(2, "아직 작성 중인 계정 기록", "2026.09.07", AfternoteType.SOCIAL_NETWORK, isDraft = true).toUiModel(), + ), + ) +} + +@Composable +private fun DraftListPreview(items: List) { + val paging = + remember(items) { + MutableStateFlow( + PagingData.from( + items, + sourceLoadStates = + LoadStates( + refresh = LoadState.NotLoading(endOfPaginationReached = true), + prepend = LoadState.NotLoading(endOfPaginationReached = true), + append = LoadState.NotLoading(endOfPaginationReached = true), + ), + ), + ) + }.collectAsLazyPagingItems() + AfternoteTheme { + AfternoteDraftListScreen(items = paging, onBackClick = {}, onDraftClick = { _, _ -> }) + } +} diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/EditorDraftActionsScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/EditorDraftActionsScreenshotTest.kt new file mode 100644 index 0000000000..ea5222db3c --- /dev/null +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/editor/EditorDraftActionsScreenshotTest.kt @@ -0,0 +1,39 @@ +package com.afternote.feature.afternote.presentation.editor + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.presentation.editor.state.EditorFormState +import com.android.tools.screenshot.PreviewTest + +@PreviewTest +@Preview(showBackground = true, widthDp = 360, heightDp = 100) +@Composable +internal fun editorDraftActionsEnabledScreenshot() { + DraftActionsPreview(isSubmitEnabled = true) +} + +@PreviewTest +@Preview(showBackground = true, widthDp = 360, heightDp = 100) +@Composable +internal fun editorDraftActionsDisabledScreenshot() { + DraftActionsPreview(isSubmitEnabled = false) +} + +@Composable +private fun DraftActionsPreview(isSubmitEnabled: Boolean) { + AfternoteTheme { + AfternoteEditorScreen( + form = EditorFormState(), + onBackClick = {}, + onRegisterClick = {}, + onSaveDraftClick = {}, + snackbarMessage = null, + onSnackbarMessageConsumed = {}, + validationMessage = null, + onValidationMessageConsumed = {}, + content = {}, + isSubmitEnabled = isSubmitEnabled, + ) + } +} diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/home/EmptyHomeBodyScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/home/EmptyHomeBodyScreenshotTest.kt index 2a15f76fe5..9e105775e5 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/home/EmptyHomeBodyScreenshotTest.kt +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/home/EmptyHomeBodyScreenshotTest.kt @@ -38,3 +38,19 @@ internal fun emptyHomeBodyScreenshot() { ) } } + +@PreviewTest +@Preview(showBackground = true, widthDp = 360, heightDp = 800) +@Composable +internal fun emptyHomeWithDraftEntryScreenshot() { + AfternoteTheme { + EmptyHomeBody( + headerDescription = stringResource(R.string.afternote_home_header_description), + nextStep = null, + emptyListDescription = stringResource(R.string.afternote_empty_list_body), + onTypeSelected = {}, + onDraftListClick = {}, + filterRowScrollState = rememberScrollState(), + ) + } +} diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListScreenshotTestKt/draftListEmptyScreenshot_f2e6aec3_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListScreenshotTestKt/draftListEmptyScreenshot_f2e6aec3_0.png new file mode 100644 index 0000000000..e55e782c7a Binary files /dev/null and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListScreenshotTestKt/draftListEmptyScreenshot_f2e6aec3_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListScreenshotTestKt/draftListItemsScreenshot_f2e6aec3_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListScreenshotTestKt/draftListItemsScreenshot_f2e6aec3_0.png new file mode 100644 index 0000000000..74b7437b69 Binary files /dev/null and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListScreenshotTestKt/draftListItemsScreenshot_f2e6aec3_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/EditorDraftActionsScreenshotTestKt/editorDraftActionsDisabledScreenshot_eb3bc9db_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/EditorDraftActionsScreenshotTestKt/editorDraftActionsDisabledScreenshot_eb3bc9db_0.png new file mode 100644 index 0000000000..bab2d3cb72 Binary files /dev/null and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/EditorDraftActionsScreenshotTestKt/editorDraftActionsDisabledScreenshot_eb3bc9db_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/EditorDraftActionsScreenshotTestKt/editorDraftActionsEnabledScreenshot_eb3bc9db_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/EditorDraftActionsScreenshotTestKt/editorDraftActionsEnabledScreenshot_eb3bc9db_0.png new file mode 100644 index 0000000000..703db69bd1 Binary files /dev/null and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/editor/EditorDraftActionsScreenshotTestKt/editorDraftActionsEnabledScreenshot_eb3bc9db_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/home/EmptyHomeBodyScreenshotTestKt/emptyHomeWithDraftEntryScreenshot_f2e6aec3_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/home/EmptyHomeBodyScreenshotTestKt/emptyHomeWithDraftEntryScreenshot_f2e6aec3_0.png new file mode 100644 index 0000000000..faad42e49d Binary files /dev/null and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/home/EmptyHomeBodyScreenshotTestKt/emptyHomeWithDraftEntryScreenshot_f2e6aec3_0.png differ diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListViewModelTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListViewModelTest.kt new file mode 100644 index 0000000000..39a75c6a9d --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/draft/AfternoteDraftListViewModelTest.kt @@ -0,0 +1,42 @@ +package com.afternote.feature.afternote.presentation.draft + +import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * 이 화면은 임시저장만 보여야 한다. + * + * 서버가 발행분과 임시저장을 한 요청에 섞어 주지 않으므로(`draftOnly` 미전송 = 발행분만) 발행 목록을 + * 재활용하면 «임시저장 목록에 발행분이 뜨는» 결함이 된다 — 조회 자체가 갈렸는지로 닫는다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AfternoteDraftListViewModelTest { + @Before + fun setUp() { + Dispatchers.setMain(StandardTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `임시저장 목록만 조회하고 발행 목록은 건드리지 않는다`() { + val repository = FakeAfternoteRepository() + + AfternoteDraftListViewModel(repository) + + assertEquals(listOf(null), repository.requestedDraftTypes.toList()) + assertTrue(repository.requestedTypes.isEmpty()) + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorTouchTargetTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorTouchTargetTest.kt index a73eff2ca8..bdc6d82058 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorTouchTargetTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/editor/EditorTouchTargetTest.kt @@ -153,11 +153,13 @@ class EditorTouchTargetTest { fun `editor top back and submit expose named button callbacks`() { var backClicks = 0 var registerClicks = 0 + var saveDraftClicks = 0 composeRule.setContent { AfternoteTheme { AfternoteEditorScreen( form = EditorFormState(), onBackClick = { backClicks++ }, + onSaveDraftClick = { saveDraftClicks++ }, onRegisterClick = { registerClicks++ }, snackbarMessage = null, onSnackbarMessageConsumed = {}, @@ -172,10 +174,77 @@ class EditorTouchTargetTest { val targets = composeRule.scanEnabledClickTargets() assertEquals(Role.Button, targets.single { it.name == "뒤로가기" }.role) assertEquals(Role.Button, targets.single { it.name == "등록" }.role) + assertEquals(Role.Button, targets.single { it.name == "임시저장" }.role) composeRule.onNodeWithContentDescription("뒤로가기").performClick() composeRule.onNodeWithText("등록").performClick() + composeRule.onNodeWithText("임시저장").performClick() assertEquals(1, backClicks) assertEquals(1, registerClicks) + assertEquals(1, saveDraftClicks) + } + + /** + * 「임시저장」은 「등록」과 같은 게이트를 받는다 (#808 리뷰). + * + * 저장이 나가 있는 동안·prefill 을 못 읽은 동안 이 버튼만 살아 있으면, 누른 탭이 VM 의 `isSaving` + * 가드에 걸려 오류도 스낵바도 없이 삼켜진다. 게이트가 내려간 동안은 클릭 타깃에서 빠지고 콜백도 + * 불리지 않아야 한다 — `enabled` 를 지우면 아래 두 단언이 함께 빨개진다. + */ + @Test + fun `editor top bar disables the draft action together with submit`() { + var saveDraftClicks = 0 + composeRule.setContent { + AfternoteTheme { + AfternoteEditorScreen( + form = EditorFormState(), + onBackClick = {}, + onSaveDraftClick = { saveDraftClicks++ }, + onRegisterClick = {}, + isSubmitEnabled = false, + snackbarMessage = null, + onSnackbarMessageConsumed = {}, + validationMessage = null, + onValidationMessageConsumed = {}, + content = {}, + ) + } + } + + composeRule.assertAccessibleClickTargets() + val enabledNames = composeRule.scanEnabledClickTargets().map { it.name } + assertEquals(emptyList(), enabledNames.filter { it == "임시저장" || it == "등록" }) + + composeRule.onNodeWithText("임시저장").performClick() + assertEquals(0, saveDraftClicks) + } + + /** + * 콜백이 없으면 버튼 자체가 없어야 한다 (#808). + * + * 발행분 수정에서는 `AfternoteEditorRoute` 가 `null` 을 넘겨 등록 액션만 제공한다. + */ + @Test + fun `editor top bar hides the draft action when no callback is wired`() { + composeRule.setContent { + AfternoteTheme { + AfternoteEditorScreen( + form = EditorFormState(), + onBackClick = {}, + onSaveDraftClick = null, + onRegisterClick = {}, + snackbarMessage = null, + onSnackbarMessageConsumed = {}, + validationMessage = null, + onValidationMessageConsumed = {}, + content = {}, + ) + } + } + + composeRule.assertAccessibleClickTargets() + val names = composeRule.scanEnabledClickTargets().map { it.name } + assertEquals(emptyList(), names.filter { it == "임시저장" }) + assertEquals(1, names.count { it == "등록" }) } } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/HomeDraftEntryVisibilityTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/HomeDraftEntryVisibilityTest.kt new file mode 100644 index 0000000000..b5471e7780 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/HomeDraftEntryVisibilityTest.kt @@ -0,0 +1,57 @@ +package com.afternote.feature.afternote.presentation.home + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.afternote.core.ui.testing.assertAccessibleClickTargets +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.presentation.R +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 임시저장 진입점은 **작성자에게만** 있다 (#808). + * + * 목록 헤더는 작성자와 수신자가 같은 것을 쓴다 — 발신자 문구가 수신자에게 샜던 #620 과 같은 자리라, + * 진입점도 콜백이 없으면 아예 안 그리는지 가드한다. 수신자에게는 임시저장이라는 개념 자체가 없다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class HomeDraftEntryVisibilityTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `작성자 헤더는 임시저장 진입점을 그린다`() { + composeRule.setContent { + AfternoteTheme { + HomeHeaderSection(description = "설명", nextStep = null, onDraftListClick = {}) + } + } + + composeRule + .onNodeWithText(composeRule.activity.getString(R.string.afternote_home_draft_entry)) + .assertHasClickAction() + + // 클릭 가능한 것만으로는 부족하다 — 48dp·이름·Role 계약을 공용 스캐너로 함께 잠근다. + // 이 진입점은 글자 한 줄이라 타깃을 넓혀 두지 않으면 여기서 깨진다. + composeRule.assertAccessibleClickTargets() + } + + @Test + fun `수신자 헤더에는 임시저장 진입점이 없다`() { + composeRule.setContent { + AfternoteTheme { + HomeHeaderSection(description = "설명", nextStep = null) + } + } + + composeRule + .onNodeWithText(composeRule.activity.getString(R.string.afternote_home_draft_entry)) + .assertDoesNotExist() + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteLocalNavActionsTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteLocalNavActionsTest.kt index f0179ab538..308bb47c32 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteLocalNavActionsTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/navigation/AfternoteLocalNavActionsTest.kt @@ -73,6 +73,52 @@ class AfternoteLocalNavActionsTest { assertEquals(AfternoteType.SOCIAL_NETWORK, create.initialType) } + @Test + fun `홈에서 임시저장 목록으로 이동하고 뒤로가기로 홈에 돌아온다`() { + actions.replaceFingerprintLoginWithAfternoteHome() + + actions.navigateToDraftList() + assertEquals(listOf("AfternoteHomeRoute", "DraftListRoute"), stack()) + + actions.popBack() + assertEquals(listOf("AfternoteHomeRoute"), stack()) + } + + @Test + fun `임시저장 목록에서 고른 항목은 이어쓰기로 에디터에 이어진다`() { + actions.replaceFingerprintLoginWithAfternoteHome() + actions.navigateToDraftList() + + actions.navigateToEditorForResume(itemId = 31L, initialType = AfternoteType.SOCIAL_NETWORK) + + assertEquals( + listOf("AfternoteHomeRoute", "DraftListRoute", "EditorFlowRoute"), + stack(), + ) + // 이어쓰기를 그만두면 목록으로 돌아온다. + actions.popBack() + assertEquals(listOf("AfternoteHomeRoute", "DraftListRoute"), stack()) + } + + @Test + fun `이어쓰기 진입은 isDraft 를 목적지까지 나른다`() { + actions.navigateToEditorForResume(itemId = 31L, initialType = AfternoteType.MEMORIAL) + + val key = backStack.last() as AfternoteRoute.EditorFlowRoute + assertEquals(31L, key.itemId) + assertEquals(AfternoteType.MEMORIAL, key.initialType) + // 발행 보장이 없는 draft 상세로 읽어야 한다 — 이 표식이 빠지면 이어쓰기가 발행 계약으로 + // 파싱돼 프리필이 실패한다 (#808). + assertEquals(true, key.isDraft) + } + + @Test + fun `상세에서 온 수정 진입은 발행 계약을 그대로 받는다`() { + actions.navigateToEditorForEdit(itemId = 31L, initialType = AfternoteType.MEMORIAL) + + assertEquals(false, (backStack.last() as AfternoteRoute.EditorFlowRoute).isDraft) + } + @Test fun `저장 성공은 홈 위 화면들을 걷어내고 홈만 남긴다`() { actions.replaceFingerprintLoginWithAfternoteHome()