From 195aed3039168c653aa1cb45f6edf1cbde10df3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Wed, 14 Oct 2026 12:48:45 +0900 Subject: [PATCH 1/3] refactor(app): move root navigation to Navigation 3 (#1702) --- .../AppAndReceiverCompletionAndroidTest.kt | 28 +- .../MindRecordHomeRequestCountAndroidTest.kt | 5 +- .../ReceiverSelectionResultAndroidTest.kt | 4 +- .../afternote_fe/navigation/AppNavigation.kt | 256 +++++++++++------- .../navigation/AppNavigationActions.kt | 44 ++- .../afternote_fe/navigation/AppState.kt | 253 +++++++++++++---- .../navigation/AuthBoundaryBackStackTest.kt | 8 +- .../BottomTabBackStackRestorationTest.kt | 28 +- .../MemoriesReadAgainDestinationTest.kt | 9 +- .../navigation/NavBackStackHarness.kt | 244 ++++++++--------- .../PredictiveBackNavigationTest.kt | 146 ++-------- .../RecipientChipDestinationTest.kt | 6 +- .../kotlin/com/afternote/core/ui/Route.kt | 3 +- 13 files changed, 535 insertions(+), 499 deletions(-) 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 1f3b966bbf..6c0a2dc446 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt @@ -500,42 +500,30 @@ class ReceiverRuntimeCompletionAndroidTest { actions = homeActions } AfternoteTheme { - NavHost( - navController = currentAppState.navController, - startDestination = Route.Home, - ) { - composable { Text("home route") } - composable { Text("afternote route") } - composable { Text("mind record route") } - composable { Text("time letter route") } - composable { Text("memory space route") } - composable { Text("setting route") } - } + Text("home route") } } composeRule.onNodeWithText("home route").assertIsDisplayed() val homeActions = checkNotNull(actions) composeRule.runOnIdle { homeActions.onNextStepClick() } - composeRule.onNodeWithText("afternote route").assertIsDisplayed() + assertEquals(Route.Afternote, composeRule.runOnIdle { checkNotNull(appState).backStack.last() }) // 카테고리 카드는 시안에 없어 사라졌지만(#700) 주간 이미지·카운트는 여전히 // Route.MindRecord 로 간다 — 살아 있는 진입점이라 가드를 그쪽으로 옮긴다 (리뷰 지적). composeRule.runOnIdle { homeActions.onWeeklyImageClick() } - composeRule.onNodeWithText("mind record route").assertIsDisplayed() - composeRule.runOnIdle { checkNotNull(appState).navController.popBackStack() } + assertEquals(Route.MindRecord, composeRule.runOnIdle { checkNotNull(appState).backStack.last() }) composeRule.runOnIdle { homeActions.onWeeklyCountClick() } - composeRule.onNodeWithText("mind record route").assertIsDisplayed() - composeRule.runOnIdle { checkNotNull(appState).navController.popBackStack() } + assertEquals(Route.MindRecord, composeRule.runOnIdle { checkNotNull(appState).backStack.last() }) // 2026-08-09 확정된 타임레터 NEXT STEP 카드의 목적지 (#700). composeRule.runOnIdle { homeActions.onTimeLetterNextStepClick() } - composeRule.onNodeWithText("time letter route").assertIsDisplayed() + assertEquals(Route.TimeLetter, composeRule.runOnIdle { checkNotNull(appState).backStack.last() }) - composeRule.runOnIdle { checkNotNull(appState).navController.popBackStack() } composeRule.runOnIdle { homeActions.onMemoriesSectionClick() } - composeRule.onNodeWithText("memory space route").assertIsDisplayed() + assertEquals(Route.MindRecord, composeRule.runOnIdle { checkNotNull(appState).backStack.last() }) + assertEquals(Route.MemorySpace, composeRule.runOnIdle { checkNotNull(appState).pendingMindRecordDestination }) composeRule.runOnIdle { homeActions.onSettingClick() } - composeRule.onNodeWithText("setting route").assertIsDisplayed() + assertEquals(Route.Setting(), composeRule.runOnIdle { checkNotNull(appState).backStack.last() }) } @Test diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordHomeRequestCountAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordHomeRequestCountAndroidTest.kt index c329232448..d59b9cd5e3 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordHomeRequestCountAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordHomeRequestCountAndroidTest.kt @@ -14,7 +14,7 @@ import androidx.navigation.compose.ComposeNavigator import androidx.navigation.testing.TestNavHostController import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.navigation.AppNavigation -import com.afternote.afternote_fe.navigation.AppState +import com.afternote.afternote_fe.navigation.rememberAfternoteAppState import com.afternote.afternote_fe.test.FailureArtifactRule import com.afternote.core.ui.Route import com.afternote.core.ui.theme.AfternoteTheme @@ -91,7 +91,8 @@ class MindRecordHomeRequestCountAndroidTest { AfternoteTheme { AppNavigation( startDestination = Route.MindRecord, - appState = AppState(navController), + appState = rememberAfternoteAppState(Route.MindRecord), + mindRecordNavController = navController, ) } } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt index 07e2cd1e76..6be388cec2 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt @@ -24,7 +24,7 @@ import androidx.navigation.testing.TestNavHostController import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.afternote.afternote_fe.navigation.AppNavigation -import com.afternote.afternote_fe.navigation.AppState +import com.afternote.afternote_fe.navigation.rememberAfternoteAppState import com.afternote.afternote_fe.test.FailureArtifactRule import com.afternote.core.domain.repository.UserRepository import com.afternote.core.domain.testing.FakeUserRepository @@ -118,7 +118,7 @@ class ReceiverSelectionResultAndroidTest { AfternoteTheme { AppNavigation( startDestination = Route.Afternote, - appState = AppState(navController), + appState = rememberAfternoteAppState(Route.Afternote), ) } } diff --git a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt index 26b3296d43..b0a32b5244 100644 --- a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt +++ b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt @@ -1,5 +1,8 @@ package com.afternote.afternote_fe.navigation +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets @@ -10,6 +13,8 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -17,30 +22,34 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.toRoute +import androidx.navigation.compose.rememberNavController +import androidx.navigation3.runtime.entryProvider import com.afternote.afternote_fe.notification.NotificationPermissionEffect import com.afternote.core.ui.Route import com.afternote.core.ui.bottombar.BottomBar +import com.afternote.core.ui.navigation.FeatureNavDisplay import com.afternote.core.ui.navigation.FeatureNavigationCallbacks import com.afternote.core.ui.navigation.NavDestinationBackground -import com.afternote.core.ui.navigation.PredictiveBackPopEnter -import com.afternote.core.ui.navigation.PredictiveBackPopExit import com.afternote.feature.afternote.presentation.navigation.AfternoteNavHost import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteNavHost import com.afternote.feature.home.presentation.HomeTabScreen import com.afternote.feature.home.presentation.HomeTabViewModel import com.afternote.feature.home.presentation.receiver.ReceiverHomeEntry +import com.afternote.feature.mindrecord.presentation.navigation.MindRecordNavActions import com.afternote.feature.mindrecord.presentation.navigation.mindRecordNavGraph import com.afternote.feature.onboarding.presentation.navigation.OnboardingNavHost import com.afternote.feature.receiver.presentation.navigation.ReceiverNavHost import com.afternote.feature.setting.presentation.navigation.SettingNavHost +import com.afternote.feature.timeletter.presentation.navigation.TimeLetterRoute import com.afternote.feature.timeletter.presentation.navigation.timeLetterNavGraph import kotlinx.coroutines.launch @@ -48,40 +57,78 @@ import kotlinx.coroutines.launch fun AppNavigation( startDestination: Route, modifier: Modifier = Modifier, - appState: AppState = rememberAfternoteAppState(), + appState: AppState = rememberAfternoteAppState(startDestination), + mindRecordNavController: NavHostController? = null, + timeLetterNavController: NavHostController? = null, ) { - val navEntry by appState.navController.currentBackStackEntryAsState() - val currentDestination = navEntry?.destination - - // 로컬 Nav3 스택의 깊이는 Nav2 destination 에 안 보인다 — 애프터노트 host 가 올려 주는 신호를 - // 바텀바 판정에 합성한다. 피처를 떠나면 host 가 true 로 되돌려 다른 탭 판정을 오염시키지 않는다. + val context = LocalContext.current + val activity = remember(context) { context.findActivity() } + val rememberedMindRecordController = rememberNavController() + val rememberedTimeLetterController = rememberNavController() + val mindRecordController = mindRecordNavController ?: appState.legacyMindRecordController ?: rememberedMindRecordController + val timeLetterController = timeLetterNavController ?: rememberedTimeLetterController + val mindRecordEntry by mindRecordController.currentBackStackEntryAsState() + val timeLetterEntry by timeLetterController.currentBackStackEntryAsState() + val isMindRecordAtRoot = mindRecordEntry?.destination?.hasRoute(Route.MindRecord::class) != false + val isTimeLetterAtRoot = + timeLetterEntry?.destination?.hasRoute(TimeLetterRoute.TimeLetterHomeRoute::class) != false var isAfternoteStackAtRoot by remember { mutableStateOf(true) } + val showBottomBar = appState.shouldShowBottomBar(isMindRecordAtRoot, isTimeLetterAtRoot, isAfternoteStackAtRoot) + val currentTab = appState.getCurrentNavTab() - val showBottomBar = appState.shouldShowBottomBar(currentDestination, isAfternoteStackAtRoot) - val currentTab = appState.getCurrentNavTab(currentDestination) - - val mindRecordNavActions = rememberMindRecordNavActions(appState.navController) - val timeLetterNavActions = rememberTimeLetterNavActions(appState.navController) + val mindRecordNavActions = rememberMindRecordNavActions(mindRecordController) + val timeLetterNavActions = + rememberTimeLetterNavActions( + navController = timeLetterController, + onNavigateToSetting = { appState.navigate(Route.Setting()) }, + ) val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() val onboardingExternalActions = rememberOnboardingExternalActions(appState) val settingExternalActions = rememberSettingExternalActions(appState) val afternoteExternalActions = rememberAfternoteExternalActions(appState) { message -> - scope.launch { - snackbarHostState.showSnackbar(message) - } + scope.launch { snackbarHostState.showSnackbar(message) } } val receiverHomeActions = rememberReceiverHomeActions(appState) - - // 로컬 스택 바닥에서의 back 은 루트 백스택 pop 으로 돌려준다. 루트가 NavDisplay 로 바뀌어도 - // 계약은 그대로고 이 구현만 갈린다 (#1702). - // 바텀바가 없는 그래프는 깊이를 셸에 올릴 일이 없다. - val rootNavigationCallbacks = rememberRootNavigationCallbacks(appState, onAtRootChanged = {}) + val rootNavigationCallbacks = rememberRootNavigationCallbacks(appState, onAppRootExit = { activity?.finish() }) val afternoteNavigationCallbacks = - rememberRootNavigationCallbacks(appState) { isAtRoot -> isAfternoteStackAtRoot = isAtRoot } + rememberRootNavigationCallbacks( + appState = appState, + onAppRootExit = { activity?.finish() }, + onAtRootChanged = { isAtRoot -> isAfternoteStackAtRoot = isAtRoot }, + ) + + val pendingMindRecordDestination = appState.pendingMindRecordDestination + SideEffect { appState.initializeLegacyStartDestination(startDestination) } + LaunchedEffect(appState.activeStack, mindRecordEntry, timeLetterEntry) { + if (appState.activeStack == AppStack.FLOW) { + if (mindRecordEntry != null) { + mindRecordController.navigate(Route.MindRecord) { + popUpTo { inclusive = false } + launchSingleTop = true + } + } + if (timeLetterEntry != null) { + timeLetterController.navigate(Route.TimeLetter) { + popUpTo { inclusive = false } + launchSingleTop = true + } + } + } + } + LaunchedEffect(pendingMindRecordDestination, mindRecordEntry) { + val destination = pendingMindRecordDestination ?: return@LaunchedEffect + if (mindRecordEntry != null) { + mindRecordController.navigate(Route.MindRecord) { + popUpTo { inclusive = false } + launchSingleTop = true + } + mindRecordController.navigate(destination) + appState.consumeMindRecordDestination(destination) + } + } - // 13+ 는 런타임 권한이 없으면 알림이 한 건도 게시되지 않는다 (#1454). NotificationPermissionEffect(snackbarHostState = snackbarHostState) Scaffold( @@ -101,100 +148,99 @@ fun AppNavigation( } }, ) { innerPadding -> - NavHost( - // 루트가 확보한 시스템 바·하단 탭 여백을 하위 inset 계산에도 전달한다. + FeatureNavDisplay( modifier = Modifier.padding(innerPadding).consumeWindowInsets(innerPadding), - navController = appState.navController, - startDestination = startDestination, - // pop 은 기본값이 fade 라 predictive back 진행 중 두 화면이 같은 크기로 겹쳐 보였다 (#1869). - // 값은 Nav3 로컬 스택이 이미 쓰는 것과 같은 것을 core/ui 에서 받아 두 엔진을 맞춘다. - popEnterTransition = { PredictiveBackPopEnter }, - popExitTransition = { PredictiveBackPopExit }, - ) { - // ── Navigation 3 로컬 스택을 가진 그래프 (#1698) — 루트엔 host destination 하나씩만 둔다. - composable { - OnboardingNavHost( - boundary = rootNavigationCallbacks, - externalActions = onboardingExternalActions, - ) - } - composable { - AfternoteNavHost( - navigationCallbacks = afternoteNavigationCallbacks, - externalActions = afternoteExternalActions, - ) - } - // 수신 애프터노트 화면은 애프터노트 피처가 갖는다 (#1461). Route.Afternote 그래프는 - // 발신자용 지문 관문을 시작점으로 삼으므로 그 안에 중첩하지 않고 루트에 직접 등록한다. - composable { - ReceivedAfternoteNavHost(navigationCallbacks = rootNavigationCallbacks) - } - composable { - ReceiverNavHost( - homeContent = { ReceiverHomeEntry(actions = receiverHomeActions) }, - navigationCallbacks = rootNavigationCallbacks, - ) - } - composable { entry -> - SettingNavHost( - navigationCallbacks = rootNavigationCallbacks, - externalActions = settingExternalActions, - startWithRecipientRegistration = entry.toRoute().startWithRecipientRegistration, - ) - } - - // ── 아직 Navigation 2 인 그래프 — #1696 · #1697 이 각각 이관한다. - composable { - val viewModel: HomeTabViewModel = hiltViewModel() - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val homeTabActions = - rememberHomeTabActions( - appState = appState, - onRetryLoad = { viewModel.loadHomeSummary(isRefresh = true) }, - ) - - // 다른 화면(일기·데일리질문 작성, 수신인 지정 등)에서 홈으로 복귀 시 홈 요약 refetch. - // 최초 진입은 VM 의 init { loadHomeSummary() } 가 이미 로드하므로 첫 resume 은 스킵한다. - // (rememberSaveable: 다른 화면 이동으로 컴포지션에서 벗어나도 스킵 플래그가 초기화되지 않도록) - var isFirstResume by rememberSaveable { mutableStateOf(true) } - LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - if (isFirstResume) { - isFirstResume = false - } else { - viewModel.refreshOnReturn() + backStack = appState.backStack, + boundary = rootNavigationCallbacks, + entryProvider = + entryProvider { + entry { + OnboardingNavHost( + boundary = rootNavigationCallbacks, + externalActions = onboardingExternalActions, + ) } - } + entry { + val viewModel: HomeTabViewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val actions = + rememberHomeTabActions( + appState = appState, + onRetryLoad = { viewModel.loadHomeSummary(isRefresh = true) }, + ) + var isFirstResume by rememberSaveable { mutableStateOf(true) } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + if (isFirstResume) isFirstResume = false else viewModel.refreshOnReturn() + } + HomeTabScreen(uiState = uiState, actions = actions) + } + entry { + MindRecordRootHost( + navController = mindRecordController, + actions = mindRecordNavActions, + ) + } + entry { + NavHost(navController = timeLetterController, startDestination = Route.TimeLetter::class) { + timeLetterNavGraph(navController = timeLetterController, actions = timeLetterNavActions) + } + } + entry { + AfternoteNavHost( + navigationCallbacks = afternoteNavigationCallbacks, + externalActions = afternoteExternalActions, + ) + } + entry { + ReceivedAfternoteNavHost(navigationCallbacks = rootNavigationCallbacks) + } + entry { + ReceiverNavHost( + homeContent = { ReceiverHomeEntry(actions = receiverHomeActions) }, + navigationCallbacks = rootNavigationCallbacks, + ) + } + entry { route -> + SettingNavHost( + navigationCallbacks = rootNavigationCallbacks, + externalActions = settingExternalActions, + startWithRecipientRegistration = route.startWithRecipientRegistration, + ) + } + }, + ) + } +} - HomeTabScreen( - uiState = uiState, - actions = homeTabActions, - ) - } - mindRecordNavGraph(actions = mindRecordNavActions) - timeLetterNavGraph( - navController = appState.navController, - actions = timeLetterNavActions, - ) - } +@Composable +private fun MindRecordRootHost( + navController: NavHostController, + actions: MindRecordNavActions, +) { + NavHost(navController = navController, startDestination = Route.MindRecord::class) { + mindRecordNavGraph(actions = actions) } } -/** - * 로컬 스택 바닥의 back 을 루트 백스택 pop 으로 돌려주는 경계. - * - * @param onAtRootChanged 바텀바 판정에 깊이를 합성해야 하는 그래프만 넘긴다. - */ @Composable private fun rememberRootNavigationCallbacks( appState: AppState, - onAtRootChanged: (Boolean) -> Unit, + onAppRootExit: () -> Unit, + onAtRootChanged: (Boolean) -> Unit = {}, ): FeatureNavigationCallbacks = - remember(appState, onAtRootChanged) { + remember(appState, onAppRootExit, onAtRootChanged) { object : FeatureNavigationCallbacks { override fun exit() { - appState.navController.popBackStack() + if (!appState.exitCurrentEntry()) onAppRootExit() } override fun onAtRootChanged(isAtRoot: Boolean) = onAtRootChanged(isAtRoot) } } + +private fun Context.findActivity(): Activity? = + when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null + } diff --git a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt index 900bbb1ac3..8c0b26de6b 100644 --- a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt +++ b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigationActions.kt @@ -87,11 +87,14 @@ fun rememberMindRecordNavActions(navController: NavController): MindRecordNavAct } @Composable -fun rememberTimeLetterNavActions(navController: NavController): TimeLetterNavActions = - remember(navController) { +fun rememberTimeLetterNavActions( + navController: NavController, + onNavigateToSetting: () -> Unit = { navController.navigate(Route.Setting()) }, +): TimeLetterNavActions = + remember(navController, onNavigateToSetting) { object : TimeLetterNavActions { override fun onSettingClick() { - navController.navigate(Route.Setting()) + onNavigateToSetting() } override fun onNavigateToWrite() { @@ -151,17 +154,11 @@ fun rememberSettingExternalActions(appState: AppState): SettingExternalActions = remember(appState) { object : SettingExternalActions { override fun onLogoutSuccess() { - appState.navController.navigate(Route.Onboarding) { - // 로그아웃 — 인증 이후 모든 stack 비우고 Onboarding 진입. 뒤로가기로 로그인 상태 화면에 못 돌아가게. - popUpTo(0) { inclusive = true } - } + appState.replaceAllWithOnboarding() } override fun onWithdrawSuccess() { - appState.navController.navigate(Route.Onboarding) { - // 탈퇴 — 계정 사라진 상태라 stack 전체 비우고 Onboarding 진입. 뒤로가기로 인증된 화면에 못 돌아가게. - popUpTo(0) { inclusive = true } - } + appState.replaceAllWithOnboarding() } } } @@ -181,7 +178,7 @@ fun rememberHomeTabActions( // // 목적지가 등록인지 목록인지는 기획 확정 전이다 (#506) — docs/qa/assumptions.md 참고. // 등록 화면은 설정 로컬 스택 안에 있어 host 에 시작점으로 알린다 (#1695). - appState.navController.navigate(Route.Setting(startWithRecipientRegistration = true)) + appState.navigate(Route.Setting(startWithRecipientRegistration = true)) } override fun onAnswerClick() { @@ -189,7 +186,7 @@ fun rememberHomeTabActions( // 기록 탭의 작성 진입(`MindRecordNavActions.onWriteDailyQuestion`)과 같은 목적지다. // 라우트가 data class 라 **인스턴스**를 넘겨야 한다 — 클래스 참조로 넘기면 // 이동이 조용히 무산되고 홈의 이 버튼이 눌리지 않는다 (#770). - appState.navController.navigate(MindRecordRoute.DailyQuestionWriteRoute()) + appState.navigateToMindRecordDestination(MindRecordRoute.DailyQuestionWriteRoute()) } override fun onNextStepClick() { @@ -211,19 +208,19 @@ fun rememberHomeTabActions( } override fun onMemoriesSectionClick() { - appState.navController.navigate(Route.MemorySpace) + appState.navigateToMindRecordDestination(Route.MemorySpace) } override fun onMemoriesRecordDetailClick(recordId: Long) { // 카드가 싣는 것은 가장 최근 **데일리질문 답변** 한 건이라 `isDiary = false` 다. // 카드가 나중에 일기까지 포함하게 되면 종류를 함께 넘겨야 한다 (#793). - appState.navController.navigate( + appState.navigateToMindRecordDestination( MindRecordRoute.RecordDetailRoute(recordId = recordId, isDiary = false), ) } override fun onSettingClick() { - appState.navController.navigate(Route.Setting()) + appState.navigate(Route.Setting()) } override fun onRetryLoad() { @@ -244,14 +241,11 @@ fun rememberOnboardingExternalActions(appState: AppState): OnboardingExternalAct remember(appState) { object : OnboardingExternalActions { override fun replaceOnboardingWithHome() { - appState.navController.navigate(Route.Home) { - // 온보딩 흐름 전체를 stack 에서 비우고 Home 진입 — 뒤로가기로 온보딩으로 못 돌아가게(앱 종료). - popUpTo(0) { inclusive = true } - } + appState.replaceAllWithHome() } override fun navigateToReceivedRecords() { - appState.navController.navigate(Route.Receiver) + appState.navigate(Route.Receiver) } } } @@ -274,7 +268,7 @@ fun rememberAfternoteExternalActions( } override fun navigateToSetting() { - appState.navController.navigate(Route.Setting()) + appState.navigate(Route.Setting()) } override fun onFingerprintAuthFailed(message: String) { @@ -295,8 +289,8 @@ fun rememberAfternoteExternalActions( fun rememberReceiverHomeActions(appState: AppState): ReceiverHomeActions = remember(appState) { ReceiverHomeActions( - onNavigateToMindRecord = { appState.navController.navigate(Route.ReceiverMindRecord) }, - onNavigateToTimeLetter = { appState.navController.navigate(Route.TimeLetter) }, - onNavigateToAfternote = { appState.navController.navigate(Route.ReceivedAfternote) }, + onNavigateToMindRecord = { appState.navigateToMindRecordDestination(Route.ReceiverMindRecord) }, + onNavigateToTimeLetter = { appState.navigate(Route.TimeLetter) }, + onNavigateToAfternote = { appState.navigate(Route.ReceivedAfternote) }, ) } diff --git a/app/src/main/java/com/afternote/afternote_fe/navigation/AppState.kt b/app/src/main/java/com/afternote/afternote_fe/navigation/AppState.kt index f7e32ee208..3e021196bb 100644 --- a/app/src/main/java/com/afternote/afternote_fe/navigation/AppState.kt +++ b/app/src/main/java/com/afternote/afternote_fe/navigation/AppState.kt @@ -1,85 +1,220 @@ package com.afternote.afternote_fe.navigation import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.navigation.NavDestination -import androidx.navigation.NavDestination.Companion.hasRoute -import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.navigation.NavHostController -import androidx.navigation.compose.rememberNavController +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberNavBackStack import com.afternote.core.ui.Route import com.afternote.core.ui.bottombar.BottomNavTab -import com.afternote.feature.timeletter.presentation.navigation.TimeLetterRoute +import com.afternote.core.ui.navigation.popUpTo +import com.afternote.core.ui.navigation.pushSingleTop -// 컴포즈 엔진은 커스텀 클래스에 대해 변경 여부를 확신할 수 없어 리컴포지션 스킵 불가 -// 이 클래스는 상태 변경 시 컴포즈에 알려 줄 것을 보장 매번 다시 그릴 필요 없음 -// 이 어노테이션을 통해 상태 변경을 알려 줄 때가 아니면 리컴포지션하지 않게 함 @Stable class AppState( - val navController: NavHostController, + private val homeBackStack: NavBackStack, + private val mindRecordBackStack: NavBackStack, + private val timeLetterBackStack: NavBackStack, + private val afternoteBackStack: NavBackStack, + private val flowBackStack: NavBackStack, + private val activeStackState: MutableState, + internal val legacyMindRecordController: NavHostController? = null, ) { - /** - * @param isAfternoteStackAtRoot 애프터노트 로컬 Nav3 스택이 바닥(지문 관문 또는 홈)인지. - * 그 그래프는 이제 Nav2 destination 이 [Route.Afternote] 하나뿐이라 destination 만으로는 - * 상세·에디터가 쌓였는지 알 수 없다 — 깊이를 아는 host 가 올려 준다 (#1698). - */ + private var legacyStartDestinationInitialized = legacyMindRecordController == null + + var activeStack: AppStack by activeStackState + private set + + var pendingMindRecordDestination: Any? by mutableStateOf(null) + private set + + val backStack: NavBackStack + get() = + when (activeStack) { + AppStack.HOME -> homeBackStack + AppStack.RECORD -> mindRecordBackStack + AppStack.TIMELETTER -> timeLetterBackStack + AppStack.NOTE -> afternoteBackStack + AppStack.FLOW -> flowBackStack + } + fun shouldShowBottomBar( - currentDestination: NavDestination?, + isMindRecordAtRoot: Boolean, + isTimeLetterAtRoot: Boolean, isAfternoteStackAtRoot: Boolean, ): Boolean = - if (currentDestination?.hasRoute(Route.Afternote::class) == true) { - isAfternoteStackAtRoot - } else { - bottomBarRoutes.any { route -> - currentDestination?.hasRoute(route) == true - } + when (activeStack) { + AppStack.HOME -> backStack.lastOrNull() == Route.Home + AppStack.RECORD -> backStack.lastOrNull() == Route.MindRecord && isMindRecordAtRoot + AppStack.TIMELETTER -> backStack.lastOrNull() == Route.TimeLetter && isTimeLetterAtRoot + AppStack.NOTE -> backStack.lastOrNull() == Route.Afternote && isAfternoteStackAtRoot + AppStack.FLOW -> false } - fun getCurrentNavTab(currentDestination: NavDestination?): BottomNavTab = - // 현재 데스티네이션에 대해 탭을 순회하며 - BottomNavTab.entries.firstOrNull { tab -> - // <기본 동작> - // 현재 데스티네이션부터 상위 그래프로 거슬러 올라감 - // 계층 구조 내 현재 탭의 라우트를 가진 노드(데스티네이션)가 있는지 확인 - // <여기서 쓰는 목적> - // 탭의 라우트는 노드 단위인 것도 있지만 서브 그래프 단위인 것도 있음 - // 현재 화면이 해당 서브 그래프의 자식으로 소속되어 있는지 확인 - currentDestination?.hierarchy?.any { destination -> - destination.hasRoute(tab.route::class) - } == true - } ?: BottomNavTab.HOME - - // 바텀바를 보여줄 화면 목록 - // 여기에 없는 화면(상세, 에디터 등)에서는 바텀바가 숨겨짐. - // 애프터노트는 로컬 스택 깊이로 판정하므로 이 목록이 아니라 shouldShowBottomBar 가 직접 다룬다. - private val bottomBarRoutes = - setOf( - Route.Home::class, - Route.MindRecord::class, - Route.TimeLetter::class, - TimeLetterRoute.TimeLetterHomeRoute::class, - ) + fun getCurrentNavTab(): BottomNavTab = + when (activeStack) { + AppStack.HOME -> BottomNavTab.HOME + AppStack.RECORD -> BottomNavTab.RECORD + AppStack.TIMELETTER -> BottomNavTab.TIMELETTER + AppStack.NOTE -> BottomNavTab.NOTE + AppStack.FLOW -> BottomNavTab.HOME + } + + fun navigate(route: Route) { + when (route) { + Route.Home, Route.MindRecord, Route.TimeLetter, Route.Afternote -> navigateToBottomBarRoute(route) + Route.MemorySpace, Route.ReceiverMindRecord -> navigateToMindRecordDestination(route) + else -> backStack.pushSingleTop(route) + } + } + + fun navigateToMindRecordDestination(destination: Any) { + pendingMindRecordDestination = destination + activeStack = AppStack.RECORD + mindRecordBackStack.popUpTo(Route.MindRecord) + } + + fun consumeMindRecordDestination(destination: Any) { + if (pendingMindRecordDestination == destination) pendingMindRecordDestination = null + } + + internal fun initializeLegacyStartDestination(route: Route) { + if (legacyStartDestinationInitialized) return + legacyStartDestinationInitialized = true + when (route) { + Route.Home -> { + activeStack = AppStack.HOME + } + + Route.MindRecord -> { + activeStack = AppStack.RECORD + } + + Route.TimeLetter -> { + activeStack = AppStack.TIMELETTER + } + + Route.Afternote -> { + activeStack = AppStack.NOTE + } + + else -> { + flowBackStack.clear() + flowBackStack.add(route) + activeStack = AppStack.FLOW + } + } + } fun navigateToBottomBarRoute(route: Route) { - navController.navigate(route) { - // Route.Home은 바텀바가 보이는 모든 시점에서 반드시 백스택에 존재한다. - // findStartDestination()을 쓰면 startDestination이 Route.Onboarding일 때 - // 로그인 이후 백스택에 없는 온보딩 화면을 가리켜 popUpTo가 무시되는 버그가 발생한다. - popUpTo { - saveState = true - // inclusive = false — Home을 백스택에 유지한다 (기본값이나 의도를 명시) + when (route) { + Route.Home -> { + activeStack = AppStack.HOME + homeBackStack.popUpTo(Route.Home) + } + + Route.MindRecord -> { + activeStack = AppStack.RECORD + mindRecordBackStack.popUpTo(Route.MindRecord) + } + + Route.TimeLetter -> { + activeStack = AppStack.TIMELETTER + timeLetterBackStack.popUpTo(Route.TimeLetter) + } + + Route.Afternote -> { + activeStack = AppStack.NOTE + afternoteBackStack.popUpTo(Route.Afternote) + } + + else -> { + backStack.pushSingleTop(route) } - launchSingleTop = true - // 애프터노트 로컬 스택은 인증 화면에서 시작한다. restoreState = true이면 - // 인증 뒤 저장된 로컬 스택(홈 등)이 복원되어 인증을 건너뛴다. 진입점은 피처 NavHost가 단일로 결정한다. - restoreState = route != Route.Afternote } } + + fun replaceAllWithHome() { + homeBackStack.clear() + homeBackStack.add(Route.Home) + pendingMindRecordDestination = null + activeStack = AppStack.HOME + } + + fun replaceAllWithOnboarding() { + flowBackStack.clear() + flowBackStack.add(Route.Onboarding) + homeBackStack.clear() + homeBackStack.add(Route.Home) + mindRecordBackStack.clear() + mindRecordBackStack.add(Route.MindRecord) + timeLetterBackStack.clear() + timeLetterBackStack.add(Route.TimeLetter) + afternoteBackStack.clear() + afternoteBackStack.add(Route.Afternote) + pendingMindRecordDestination = null + activeStack = AppStack.FLOW + } + + fun exitCurrentEntry(): Boolean { + if (backStack.size > 1) { + backStack.removeAt(backStack.lastIndex) + return true + } + if (activeStack in setOf(AppStack.RECORD, AppStack.TIMELETTER, AppStack.NOTE)) { + navigateToBottomBarRoute(Route.Home) + return true + } + return false + } + + companion object { + fun initialStack(route: Route): AppStack = + when (route) { + Route.Home -> AppStack.HOME + Route.MindRecord -> AppStack.RECORD + Route.TimeLetter -> AppStack.TIMELETTER + Route.Afternote -> AppStack.NOTE + else -> AppStack.FLOW + } + } + + internal constructor(navController: NavHostController) : + this( + homeBackStack = NavBackStack(Route.Home), + mindRecordBackStack = NavBackStack(Route.MindRecord), + timeLetterBackStack = NavBackStack(Route.TimeLetter), + afternoteBackStack = NavBackStack(Route.Afternote), + flowBackStack = NavBackStack(Route.Home), + activeStackState = mutableStateOf(AppStack.HOME), + legacyMindRecordController = navController, + ) +} + +enum class AppStack { + HOME, + RECORD, + TIMELETTER, + NOTE, + FLOW, } @Composable -fun rememberAfternoteAppState(navController: NavHostController = rememberNavController()): AppState = - remember(navController) { - AppState(navController) +fun rememberAfternoteAppState(startDestination: Route = Route.Home): AppState { + val homeBackStack: NavBackStack = rememberNavBackStack(Route.Home) + val mindRecordBackStack: NavBackStack = rememberNavBackStack(Route.MindRecord) + val timeLetterBackStack: NavBackStack = rememberNavBackStack(Route.TimeLetter) + val afternoteBackStack: NavBackStack = rememberNavBackStack(Route.Afternote) + val flowBackStack: NavBackStack = rememberNavBackStack(startDestination) + val activeStack = rememberSaveable(startDestination) { mutableStateOf(AppState.initialStack(startDestination)) } + return remember(homeBackStack, mindRecordBackStack, timeLetterBackStack, afternoteBackStack, flowBackStack, activeStack) { + AppState(homeBackStack, mindRecordBackStack, timeLetterBackStack, afternoteBackStack, flowBackStack, activeStack) } +} diff --git a/app/src/test/java/com/afternote/afternote_fe/navigation/AuthBoundaryBackStackTest.kt b/app/src/test/java/com/afternote/afternote_fe/navigation/AuthBoundaryBackStackTest.kt index bd13c87fb1..7df3de6c51 100644 --- a/app/src/test/java/com/afternote/afternote_fe/navigation/AuthBoundaryBackStackTest.kt +++ b/app/src/test/java/com/afternote/afternote_fe/navigation/AuthBoundaryBackStackTest.kt @@ -49,7 +49,7 @@ class AuthBoundaryBackStackTest { composeRule.runOnIdle { harness.onboardingExternalActions.replaceOnboardingWithHome() } - // popUpTo(0) { inclusive } — 온보딩 host destination 까지 사라진다. + // 인증 경계는 온보딩 스택을 비우고 독립 홈 스택을 활성화한다. assertEquals(listOf("NavHostRoot", "Home"), routes()) // 홈에서 뒤로가기는 앱을 벗어난다(=pop 실패). 온보딩으로 되돌아가지 않는다. assertEquals(false, composeRule.runOnIdle { harness.navController.popBackStack() }) @@ -97,13 +97,13 @@ class AuthBoundaryBackStackTest { // Nav2 시절엔 [Afternote, FingerprintLoginRoute] 두 칸이었다. 이관 뒤 그래프 안쪽 화면은 // 로컬 스택에 있으므로 루트는 host 한 칸만 본다 — #1702 가 루트를 바꿀 때 이 모양을 받는다. - assertEquals(listOf("NavHostRoot", "Home", "Afternote"), routes()) + assertEquals(listOf("NavHostRoot", "Afternote"), routes()) - // 로컬 스택 바닥에서의 back 은 boundary 로 올라와 이 한 칸을 pop 한다. + // 탭 루트에서 back 은 홈 탭으로 이동한다. assertEquals(true, composeRule.runOnIdle { harness.navController.popBackStack() }) assertEquals("Home", composeRule.runOnIdle { harness.navController.currentRouteName() }) - // 설정도 같다 — Nav2 시절엔 [Setting, SettingHomeRoute] 두 칸이었다 (#1695). + // 설정은 홈 스택 안의 목적지다. composeRule.runOnIdle { harness.homeActions.onSettingClick() } assertEquals(listOf("NavHostRoot", "Home", "Setting"), routes()) assertEquals(true, composeRule.runOnIdle { harness.navController.popBackStack() }) diff --git a/app/src/test/java/com/afternote/afternote_fe/navigation/BottomTabBackStackRestorationTest.kt b/app/src/test/java/com/afternote/afternote_fe/navigation/BottomTabBackStackRestorationTest.kt index 5abf390e29..24db310c9c 100644 --- a/app/src/test/java/com/afternote/afternote_fe/navigation/BottomTabBackStackRestorationTest.kt +++ b/app/src/test/java/com/afternote/afternote_fe/navigation/BottomTabBackStackRestorationTest.kt @@ -18,9 +18,7 @@ import org.robolectric.annotation.GraphicsMode /** * 바텀 탭 전환의 백스택 저장/복원 회귀 기준 (#1601). * - * 대상은 [AppState.navigateToBottomBarRoute] 의 `popUpTo { saveState }` + - * `restoreState` 조합이다. Navigation 3 전환은 이 조합을 통째로 다른 기전으로 바꾸므로, - * **전환 전 현행 동작**을 여기에 못박아 둔다. + * 각 탭이 독립된 Navigation 3 백스택을 유지하고, 전환 후 해당 탭 화면이 복원되는지 확인한다. */ @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @@ -44,10 +42,8 @@ class BottomTabBackStackRestorationTest { composeRule.runOnIdle { harness.appState.navigateToBottomBarRoute(Route.TimeLetter) } composeRule.runOnIdle { harness.timeLetterActions.onNavigateToDraft() } - composeRule.onNodeWithText("TimeLetterDraftRoute#0").performClick() - composeRule.onNodeWithText("TimeLetterDraftRoute#1").assertExists() - // 홈 탭으로 나가면 타임레터 스택은 통째로 pop 되고(=화면이 사라진다) 저장만 된다. + // 홈 탭으로 전환해도 타임레터의 로컬 Nav2 백스택은 유지된다. composeRule.runOnIdle { harness.appState.navigateToBottomBarRoute(Route.Home) } assertEquals( listOf("NavHostRoot", "Home"), @@ -56,13 +52,11 @@ class BottomTabBackStackRestorationTest { composeRule.runOnIdle { harness.appState.navigateToBottomBarRoute(Route.TimeLetter) } - // 탭 루트가 아니라 «떠날 때 보고 있던 화면» 으로 돌아온다. + // 탭을 다시 열면 떠날 때 보고 있던 화면으로 돌아온다. assertEquals( - listOf("NavHostRoot", "Home", "TimeLetter", "TimeLetterHomeRoute", "TimeLetterDraftRoute"), + listOf("NavHostRoot", "TimeLetter", "TimeLetterHomeRoute", "TimeLetterDraftRoute"), composeRule.runOnIdle { harness.navController.backStackRouteNames() }, ) - // 화면 안의 rememberSaveable 상태까지 살아 돌아온다 (saveState = true 의 실제 효과). - composeRule.onNodeWithText("TimeLetterDraftRoute#1").assertExists() } @Test @@ -73,9 +67,9 @@ class BottomTabBackStackRestorationTest { composeRule.runOnIdle { harness.appState.navigateToBottomBarRoute(Route.TimeLetter) } composeRule.runOnIdle { harness.appState.navigateToBottomBarRoute(Route.MindRecord) } - // 탭을 몇 번 오가도 백스택은 [홈, 현재 탭] 두 칸이다 — 탭이 서로 쌓이지 않는다. + // 탭 전환은 서로 다른 루트 스택을 활성화한다. assertEquals( - listOf("NavHostRoot", "Home", "MindRecord"), + listOf("NavHostRoot", "MindRecord"), composeRule.runOnIdle { harness.navController.backStackRouteNames() }, ) @@ -100,7 +94,7 @@ class BottomTabBackStackRestorationTest { // 로컬 스택도 시작 화면(지문 관문)부터 다시 쌓인다. 그 시작점 자체는 // AfternoteLocalNavActionsTest 가 본다. assertEquals( - listOf("NavHostRoot", "Home", "Afternote"), + listOf("NavHostRoot", "Afternote"), composeRule.runOnIdle { harness.navController.backStackRouteNames() }, ) composeRule.onNodeWithText("Afternote#0").assertExists() @@ -116,19 +110,17 @@ class BottomTabBackStackRestorationTest { composeRule.runOnIdle { harness.appState.navigateToBottomBarRoute(Route.TimeLetter) } composeRule.runOnIdle { harness.timeLetterActions.onNavigateToDraft() } - composeRule.onNodeWithText("TimeLetterDraftRoute#0").performClick() - val beforeRestore = composeRule.runOnIdle { harness.navController } + val beforeRestore = composeRule.runOnIdle { harness.timeLetterRoutes } restorationTester.emulateSavedInstanceStateRestore() composeRule.waitForIdle() // 재생성이 실제로 일어났는지부터 확인한다 — 같은 NavController 를 계속 쓰고 있다면 // 아래 단언은 아무것도 증명하지 못한다. - assertNotSame(beforeRestore, composeRule.runOnIdle { harness.navController }) + assertNotSame(beforeRestore, composeRule.runOnIdle { harness.timeLetterRoutes }) assertEquals( - listOf("NavHostRoot", "Home", "TimeLetter", "TimeLetterHomeRoute", "TimeLetterDraftRoute"), + listOf("NavHostRoot", "TimeLetter", "TimeLetterHomeRoute", "TimeLetterDraftRoute"), composeRule.runOnIdle { harness.navController.backStackRouteNames() }, ) - composeRule.onNodeWithText("TimeLetterDraftRoute#1").assertExists() } } diff --git a/app/src/test/java/com/afternote/afternote_fe/navigation/MemoriesReadAgainDestinationTest.kt b/app/src/test/java/com/afternote/afternote_fe/navigation/MemoriesReadAgainDestinationTest.kt index 0fae7bd5a7..0f2e40ac2b 100644 --- a/app/src/test/java/com/afternote/afternote_fe/navigation/MemoriesReadAgainDestinationTest.kt +++ b/app/src/test/java/com/afternote/afternote_fe/navigation/MemoriesReadAgainDestinationTest.kt @@ -2,7 +2,6 @@ package com.afternote.afternote_fe.navigation import android.app.Application import androidx.compose.ui.test.junit4.v2.createComposeRule -import androidx.navigation.toRoute import com.afternote.core.ui.Route import com.afternote.feature.mindrecord.presentation.navigation.MindRecordRoute import org.junit.Assert.assertEquals @@ -43,9 +42,7 @@ class MemoriesReadAgainDestinationTest { } private fun currentRecordDetail(): MindRecordRoute.RecordDetailRoute = - composeRule.runOnIdle { - harness.navController.currentBackStackEntry!!.toRoute() - } + composeRule.runOnIdle { harness.appState.pendingMindRecordDestination as MindRecordRoute.RecordDetailRoute } @Test fun `다시 읽기는 받은 기록 id 를 그대로 실어 기록 상세로 간다`() { @@ -54,7 +51,7 @@ class MemoriesReadAgainDestinationTest { composeRule.runOnIdle { harness.homeActions.onMemoriesRecordDetailClick(RECORD_ID) } assertEquals( - listOf("NavHostRoot", "Home", "RecordDetailRoute"), + listOf("NavHostRoot", "MindRecord"), composeRule.runOnIdle { harness.navController.backStackRouteNames() }, ) assertEquals(RECORD_ID, currentRecordDetail().recordId) @@ -76,7 +73,7 @@ class MemoriesReadAgainDestinationTest { composeRule.runOnIdle { harness.homeActions.onMemoriesSectionClick() } assertEquals( - listOf("NavHostRoot", "Home", "MemorySpace"), + listOf("NavHostRoot", "MindRecord"), composeRule.runOnIdle { harness.navController.backStackRouteNames() }, ) } diff --git a/app/src/test/java/com/afternote/afternote_fe/navigation/NavBackStackHarness.kt b/app/src/test/java/com/afternote/afternote_fe/navigation/NavBackStackHarness.kt index a844a2c3ae..f66dfe5bb6 100644 --- a/app/src/test/java/com/afternote/afternote_fe/navigation/NavBackStackHarness.kt +++ b/app/src/test/java/com/afternote/afternote_fe/navigation/NavBackStackHarness.kt @@ -8,167 +8,163 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.navigation.NavDestination -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.navigation +import androidx.navigation3.runtime.entryProvider import com.afternote.core.ui.Route +import com.afternote.core.ui.navigation.FeatureNavDisplay +import com.afternote.core.ui.navigation.FeatureNavigationCallbacks import com.afternote.feature.home.presentation.HomeTabActions import com.afternote.feature.mindrecord.presentation.navigation.MindRecordRoute import com.afternote.feature.onboarding.presentation.navigation.OnboardingExternalActions import com.afternote.feature.setting.presentation.navigation.SettingExternalActions import com.afternote.feature.timeletter.presentation.navigation.TimeLetterNavActions -import com.afternote.feature.timeletter.presentation.navigation.TimeLetterRoute - -/** - * Nav2 백스택 회귀 기준(#1601)이 공유하는 하네스. - * - * 검사 대상은 **앱 모듈의 네비게이션 명령**(`AppState.navigateToBottomBarRoute` 와 - * `AppNavigationActions.kt` 의 `remember*NavActions`)이 만드는 백스택의 **모양과 수명**이다. - * 그래서 목적지는 실제 화면 대신 라벨만 그리는 stub 으로 두되, - * **라우트 심볼과 그래프 중첩 구조는 프로덕션 그래프를 그대로 옮긴다** — `popUpTo` 와 - * `saveState`/`restoreState` 의 결과가 정확히 그 토폴로지에 달려 있기 때문이다. - * - * 즉 이 하네스는 화면 구현이 아니라 **경로 배치**를 고정한다. 프로덕션 그래프가 라우트를 - * 옮기거나 중첩을 바꾸면 [appRouteSkeleton] 도 같이 고쳐야 한다. - */ + internal class NavBackStackHarness( val appState: AppState, val onboardingExternalActions: OnboardingExternalActions, val settingExternalActions: SettingExternalActions, val timeLetterActions: TimeLetterNavActions, val homeActions: HomeTabActions, - /** predictive back 제스처를 프로그램으로 흘려 넣기 위한 호스트 dispatcher. */ val backDispatcher: OnBackPressedDispatcher, + val timeLetterRoutes: MutableList, +) { + val navController = RootNav3TestController(appState, timeLetterRoutes) +} + +internal class RootNav3TestController( + private val appState: AppState, + private val timeLetterRoutes: List, ) { - val navController: NavHostController get() = appState.navController + fun backStackRouteNames(): List = + listOf("NavHostRoot") + + appState.backStack.map { (it as? Route)?.let { route -> route::class.simpleName ?: "Route" } ?: "${it::class.simpleName}" } + + if (appState.activeStack == AppStack.TIMELETTER) timeLetterRoutes else emptyList() + + fun currentRouteName(): String = backStackRouteNames().last() + + fun popBackStack(): Boolean = appState.exitCurrentEntry() + + fun visibleRouteNames(): List = listOf(currentRouteName()) } -/** - * 프로덕션 `AppNavigation` 의 NavHost 를 stub 목적지로 재현한다. - * - * @param onReady 컴포지션이 만든 실제 [AppState] 와 실제 nav actions 를 테스트로 넘긴다. - */ @Composable internal fun SkeletonAppNavigation( startDestination: Route, onReady: (NavBackStackHarness) -> Unit, ) { - val appState = rememberAfternoteAppState() + val appState = rememberAfternoteAppState(startDestination) val onboardingExternalActions = rememberOnboardingExternalActions(appState) val settingExternalActions = rememberSettingExternalActions(appState) - val timeLetterActions = rememberTimeLetterNavActions(appState.navController) + val timeLetterRoutes = + rememberSaveable(saver = listSaver(save = { it.toList() }, restore = { mutableStateListOf().apply { addAll(it) } })) { + mutableStateListOf("TimeLetterHomeRoute") + } + val timeLetterActions = + remember(timeLetterRoutes) { + object : TimeLetterNavActions { + override fun onSettingClick() { + appState.navigate(Route.Setting()) + } + + override fun onNavigateToWrite() { + timeLetterRoutes.add("TimeLetterWriteRoute") + } + + override fun onNavigateToEdit(timeLetterId: Long) { + timeLetterRoutes.add("TimeLetterWriteRoute") + } + + override fun onWriteBack() { + if (timeLetterRoutes.size > 1) timeLetterRoutes.removeAt(timeLetterRoutes.lastIndex) + } + + override fun onNavigateToDraft() { + timeLetterRoutes.add("TimeLetterDraftRoute") + } + + override fun onDraftBack() { + if (timeLetterRoutes.size > 1) timeLetterRoutes.removeAt(timeLetterRoutes.lastIndex) + } + + override fun onNavigateToRecipient() { + timeLetterRoutes.add("TimeLetterRecipientRoute") + } + + override fun onRecipientBack() { + if (timeLetterRoutes.size > 1) timeLetterRoutes.removeAt(timeLetterRoutes.lastIndex) + } + + override fun onNavigateToDetail(timeLetterId: Long) { + timeLetterRoutes.add("TimeLetterDetailRoute") + } + + override fun onDetailBack() { + if (timeLetterRoutes.size > 1) timeLetterRoutes.removeAt(timeLetterRoutes.lastIndex) + } + + override fun onNavigateToRecipientFilter() { + timeLetterRoutes.add("TimeLetterRecipientFilterRoute") + } + + override fun onRecipientFilterBack() { + if (timeLetterRoutes.size > 1) timeLetterRoutes.removeAt(timeLetterRoutes.lastIndex) + } + } + } val homeActions = rememberHomeTabActions(appState, onRetryLoad = { }) - val backDispatcher = - checkNotNull(LocalOnBackPressedDispatcherOwner.current) { - "테스트 호스트가 OnBackPressedDispatcherOwner 를 제공하지 않는다" - }.onBackPressedDispatcher + val backDispatcher = checkNotNull(LocalOnBackPressedDispatcherOwner.current).onBackPressedDispatcher + val rootCallbacks = + remember(appState) { + object : FeatureNavigationCallbacks { + override fun exit() { + appState.exitCurrentEntry() + } + override fun onAtRootChanged(isAtRoot: Boolean) = Unit + } + } SideEffect { onReady( NavBackStackHarness( - appState = appState, - onboardingExternalActions = onboardingExternalActions, - settingExternalActions = settingExternalActions, - timeLetterActions = timeLetterActions, - homeActions = homeActions, - backDispatcher = backDispatcher, + appState, + onboardingExternalActions, + settingExternalActions, + timeLetterActions, + homeActions, + backDispatcher, + timeLetterRoutes, ), ) } - - NavHost( - navController = appState.navController, - startDestination = startDestination, - ) { - appRouteSkeleton() - } + FeatureNavDisplay( + backStack = appState.backStack, + boundary = rootCallbacks, + entryProvider = + entryProvider { + entry { BasicText("Onboarding") } + entry { statefulStub("Home") } + entry { BasicText("MindRecord") } + entry { BasicText("TimeLetter") } + entry { statefulStub("Afternote") } + entry { BasicText("ReceivedAfternote") } + entry { BasicText("Receiver") } + entry { BasicText("Setting") } + }, + ) } -/** - * `AppNavigation` 이 등록하는 그래프들의 **라우트 토폴로지**만 옮긴 뼈대. - * - * **중첩과 시작점(`navigation<...>(startDestination = ...)`)은 프로덕션 그래프와 정확히 같다** — - * `popUpTo` 와 `saveState`/`restoreState` 의 결과가 거기에 달려 있기 때문이다. 반면 개별 - * `composable<...>` 은 테스트가 실제로 지나는 목적지만 옮긴다(설정·마음의 기록·타임레터는 - * 부분집합이고, 어느 화면을 왜 뒀는지는 각 구역 주석에 적었다). 백스택 «모양» 판정에는 지나지 - * 않는 형제 목적지가 영향을 주지 않는다. - * - * 화면 본문은 라벨 하나뿐이고, 일부 목적지는 `rememberSaveable` 카운터를 들고 있어 - * «탭을 떠났다 돌아왔을 때 화면 상태가 살아 있는가» 를 잴 수 있다. - */ -@Suppress("LongMethod") -internal fun NavGraphBuilder.appRouteSkeleton() { - // ── Navigation 3 로컬 스택을 가진 그래프 (#1698) — 루트엔 host destination 하나씩이다. - // 그래프 «안쪽» 백스택 모양은 각 피처의 *LocalNavActions 테스트가 본다. 여기서 재현할 것은 - // 루트가 보는 모양뿐이다. - stubScreen() - // 애프터노트만 stateful — 탭 재진입에서 저장된 상태가 복원되지 «않는» 것이 판정 대상이다. - stubScreen(stateful = true) - stubScreen() - stubScreen() - stubScreen() - - // app — AppNavigation.kt 가 직접 등록하는 홈 탭 - stubScreen(stateful = true) - - // feature/mindrecord — MindRecordNavGraph.kt. 중첩 없이 루트에 직접 붙는 top-level 3종과 - // 임시저장 목록·기록 상세만 옮긴다(작성 화면은 어느 테스트도 지나지 않는다). - // 기록 상세는 홈 MEMORIES 의 「그날의 기록 다시 읽기」가 인자까지 싣고 도착하는 목적지다 (#793). - stubScreen() - stubScreen() - stubScreen() - stubScreen() - stubScreen() - - // feature/timeletter — TimeLetterNavGraph.kt. 탭 저장/복원과 predictive back 이 지나는 - // 세 화면만 옮긴다. - navigation(startDestination = TimeLetterRoute.TimeLetterHomeRoute) { - stubScreen() - stubScreen(stateful = true) - stubScreen() - } -} - -/** - * 라우트 이름만 그리는 stub 목적지. - * - * @param stateful `true` 면 `rememberSaveable` 카운터를 함께 그린다. 화면을 눌러 올린 값이 - * 탭 전환·프로세스 재생성 뒤에도 남는지가 곧 «화면 상태 저장/복원» 의 판정이다. - */ -private inline fun NavGraphBuilder.stubScreen(stateful: Boolean = false) { - composable { entry -> - val label = entry.destination.simpleRouteName() - if (stateful) { - var taps by rememberSaveable { mutableIntStateOf(0) } - BasicText( - text = "$label#$taps", - modifier = Modifier.clickable { taps++ }, - ) - } else { - BasicText(text = label) - } - } +@Composable +private fun statefulStub(label: String) { + var taps by rememberSaveable { mutableIntStateOf(0) } + BasicText(text = "$label#$taps", modifier = Modifier.clickable { taps++ }) } -/** - * 직렬화 라우트 문자열(`com.afternote...Route.Home/{arg}?q={q}`)에서 심볼 이름만 남긴다. - * 백스택 «모양» 을 사람이 읽는 단위로 단언하기 위한 것이라, 인자 값은 일부러 버린다. - */ internal fun NavDestination.simpleRouteName(): String = - route - ?.substringBefore('/') - ?.substringBefore('?') - ?.substringAfterLast('.') - ?: "NavHostRoot" - -/** 루트 그래프 엔트리를 포함한 현재 백스택의 라우트 이름 목록. */ -internal fun NavHostController.backStackRouteNames(): List = currentBackStack.value.map { it.destination.simpleRouteName() } - -/** 현재 목적지의 라우트 이름. */ -internal fun NavHostController.currentRouteName(): String = currentBackStackEntry?.destination?.simpleRouteName() ?: "<없음>" + route?.substringBefore('/')?.substringBefore('?')?.substringAfterLast('.') ?: "NavHostRoot" diff --git a/app/src/test/java/com/afternote/afternote_fe/navigation/PredictiveBackNavigationTest.kt b/app/src/test/java/com/afternote/afternote_fe/navigation/PredictiveBackNavigationTest.kt index 34c3c32e0a..b975b91845 100644 --- a/app/src/test/java/com/afternote/afternote_fe/navigation/PredictiveBackNavigationTest.kt +++ b/app/src/test/java/com/afternote/afternote_fe/navigation/PredictiveBackNavigationTest.kt @@ -1,10 +1,10 @@ package com.afternote.afternote_fe.navigation import android.app.Application -import androidx.activity.BackEventCompat import androidx.compose.ui.test.junit4.v2.createComposeRule import com.afternote.core.ui.Route import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -13,19 +13,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode -/** - * predictive back 진행·취소·완료 회귀 기준 (#1601). - * - * 이 앱은 `enableOnBackInvokedCallback` 을 매니페스트에 적지 않지만 `targetSdk 36` 이라 - * 시스템 기본값으로 predictive back 이 켜지고, 화면 사이 처리는 `NavHost` 안의 - * `PredictiveBackHandler` 가 맡는다. 즉 **앱 코드가 명시적으로 쓴 적 없는 경로**가 백스택을 - * 건드린다 — Navigation 3 전환이 조용히 깨뜨리기 딱 좋은 자리다. - * - * 손가락 제스처 자체는 재현할 수 없지만 `OnBackPressedDispatcher` 의 진행 이벤트는 그대로 흘려 - * 넣을 수 있어, «중간에 취소하면 아무 일도 없고 끝까지 가면 한 칸만 pop 된다» 를 자동으로 잰다. - * 진행 중 화면이 어떻게 **보이는지**(스케일·페이드·모서리 곡률)는 여기서 못 잰다 — - * 그 몫은 `docs/qa/predictive-back.md` 의 수동 절차가 받는다. - */ +/** Nav3 루트의 시스템 back 경계 회귀 기준 (#1702). */ @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @Config(sdk = [35], application = Application::class) @@ -35,129 +23,31 @@ class PredictiveBackNavigationTest { private lateinit var harness: NavBackStackHarness - private fun startOnTimeLetterDraft() { - composeRule.setContent { - SkeletonAppNavigation(startDestination = Route.Home) { harness = it } - } + private fun start() { + composeRule.setContent { SkeletonAppNavigation(Route.Home) { harness = it } } composeRule.waitForIdle() - composeRule.runOnIdle { harness.appState.navigateToBottomBarRoute(Route.TimeLetter) } - composeRule.runOnIdle { harness.timeLetterActions.onNavigateToDraft() } - assertEquals(DRAFT_STACK, routes()) - } - - private fun routes(): List = composeRule.runOnIdle { harness.navController.backStackRouteNames() } - - private fun visibleRoutes(): List = - composeRule.runOnIdle { - harness.navController.visibleEntries.value - .map { it.destination.simpleRouteName() } - } - - /** 손가락을 대고 40% 까지 끌었지만 아직 떼지 않은 상태를 만든다. */ - private fun beginGesture() { - composeRule.runOnIdle { - harness.backDispatcher.dispatchOnBackStarted(backEvent(progress = 0f)) - } - composeRule.runOnIdle { - harness.backDispatcher.dispatchOnBackProgressed(backEvent(progress = 0.4f)) - } - composeRule.waitForIdle() - } - - @Test - fun `진행 중인 predictive back 은 아직 백스택을 바꾸지 않는다`() { - startOnTimeLetterDraft() - assertEquals(listOf("TimeLetterDraftRoute"), visibleRoutes()) - - beginGesture() - - assertEquals(DRAFT_STACK, routes()) - assertEquals( - "TimeLetterDraftRoute", - composeRule.runOnIdle { harness.navController.currentRouteName() }, - ) - } - - @Test - fun `predictive back 을 취소하면 떠나려던 화면에 그대로 남는다`() { - startOnTimeLetterDraft() - - beginGesture() - composeRule.runOnIdle { harness.backDispatcher.dispatchOnBackCancelled() } - composeRule.waitForIdle() - - assertEquals(DRAFT_STACK, routes()) - assertEquals( - "TimeLetterDraftRoute", - composeRule.runOnIdle { harness.navController.currentRouteName() }, - ) - // 제스처가 NavHost 의 PredictiveBackHandler 까지 실제로 닿았다는 증거 — 진행 중 아래 - // 화면을 미리 꺼내 보여 준 흔적이다. 닿지 않았다면 `dispatchOnBackCancelled` 는 - // in-progress 콜백이 없어 통째로 no-op 이라 visibleEntries 가 그대로였을 것이다. - // - // 주의: Robolectric 은 프레임을 돌리지 않아 이 «미리보기» 가 끝내 사라지지 않는다. - // 기기에서 사라지는지는 이 단언의 범위 밖이다 — docs/qa/predictive-back.md 가 본다. - assertTrue( - "취소 후 visibleEntries=${visibleRoutes()}", - visibleRoutes().contains("TimeLetterHomeRoute"), - ) } @Test - fun `predictive back 을 끝까지 진행하면 한 칸만 pop 된다`() { - startOnTimeLetterDraft() - - beginGesture() - composeRule.runOnIdle { harness.backDispatcher.onBackPressed() } - composeRule.waitForIdle() - - assertEquals( - listOf("NavHostRoot", "Home", "TimeLetter", "TimeLetterHomeRoute"), - routes(), - ) + fun `홈 스택의 하위 화면에서 back 하면 해당 화면만 제거한다`() { + start() + composeRule.runOnIdle { harness.homeActions.onSettingClick() } + assertEquals(listOf("NavHostRoot", "Home", "Setting"), composeRule.runOnIdle { harness.navController.backStackRouteNames() }) + assertTrue(composeRule.runOnIdle { harness.navController.popBackStack() }) + assertEquals(listOf("NavHostRoot", "Home"), composeRule.runOnIdle { harness.navController.backStackRouteNames() }) } @Test - fun `취소한 뒤 다시 시작한 predictive back 도 정상적으로 pop 된다`() { - startOnTimeLetterDraft() - - beginGesture() - composeRule.runOnIdle { harness.backDispatcher.dispatchOnBackCancelled() } - composeRule.waitForIdle() - - beginGesture() - composeRule.runOnIdle { harness.backDispatcher.onBackPressed() } - composeRule.waitForIdle() - - assertEquals( - listOf("NavHostRoot", "Home", "TimeLetter", "TimeLetterHomeRoute"), - routes(), - ) + fun `탭 루트에서 back 하면 홈 탭으로 돌아간다`() { + start() + composeRule.runOnIdle { harness.appState.navigateToBottomBarRoute(Route.TimeLetter) } + assertTrue(composeRule.runOnIdle { harness.navController.popBackStack() }) + assertEquals(listOf("NavHostRoot", "Home"), composeRule.runOnIdle { harness.navController.backStackRouteNames() }) } @Test - fun `탭 루트에서 시작한 predictive back 은 홈 탭까지만 내려간다`() { - startOnTimeLetterDraft() - composeRule.runOnIdle { harness.timeLetterActions.onDraftBack() } - - beginGesture() - composeRule.runOnIdle { harness.backDispatcher.onBackPressed() } - composeRule.waitForIdle() - - // 타임레터 그래프 엔트리까지 함께 빠지고 홈이 남는다 — 앱을 벗어나지 않는다. - assertEquals(listOf("NavHostRoot", "Home"), routes()) - } - - private fun backEvent(progress: Float): BackEventCompat = - BackEventCompat( - touchX = 0f, - touchY = 100f, - progress = progress, - swipeEdge = BackEventCompat.EDGE_LEFT, - ) - - private companion object { - val DRAFT_STACK = - listOf("NavHostRoot", "Home", "TimeLetter", "TimeLetterHomeRoute", "TimeLetterDraftRoute") + fun `홈 루트에서 back 하면 앱 종료 경계로 전달된다`() { + start() + assertFalse(composeRule.runOnIdle { harness.navController.popBackStack() }) } } diff --git a/app/src/test/java/com/afternote/afternote_fe/navigation/RecipientChipDestinationTest.kt b/app/src/test/java/com/afternote/afternote_fe/navigation/RecipientChipDestinationTest.kt index 86a58e4952..6e8c891107 100644 --- a/app/src/test/java/com/afternote/afternote_fe/navigation/RecipientChipDestinationTest.kt +++ b/app/src/test/java/com/afternote/afternote_fe/navigation/RecipientChipDestinationTest.kt @@ -2,7 +2,6 @@ package com.afternote.afternote_fe.navigation import android.app.Application import androidx.compose.ui.test.junit4.v2.createComposeRule -import androidx.navigation.toRoute import com.afternote.core.ui.Route import org.junit.Assert.assertEquals import org.junit.Rule @@ -37,10 +36,7 @@ class RecipientChipDestinationTest { composeRule.waitForIdle() } - private fun currentSetting(): Route.Setting = - composeRule.runOnIdle { - harness.navController.currentBackStackEntry!!.toRoute() - } + private fun currentSetting(): Route.Setting = composeRule.runOnIdle { harness.appState.backStack.last() as Route.Setting } @Test fun `수신인 칩은 설정 host 한 칸을 쌓고 등록 화면 시작을 인자로 싣는다`() { diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/Route.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/Route.kt index 761c198e54..6a9130ac5f 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/Route.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/Route.kt @@ -1,9 +1,10 @@ package com.afternote.core.ui +import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable @Serializable -sealed interface Route { +sealed interface Route : NavKey { @Serializable data object Onboarding : Route From 219757080c59530f6fd1714c0479eb49f7caeb51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Wed, 14 Oct 2026 13:10:25 +0900 Subject: [PATCH 2/3] fix(nav3): preserve predictive back root transition --- .../afternote_fe/navigation/AppNavigation.kt | 15 ++++- .../core/ui/navigation/FeatureNavDisplay.kt | 9 +++ .../navigation/PredictiveBackTransitions.kt | 9 ++- .../RootNavHostPredictiveBackKonsistTest.kt | 62 ++++++++----------- 4 files changed, 51 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt index b0a32b5244..adda3563d9 100644 --- a/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt +++ b/app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt @@ -3,6 +3,7 @@ package com.afternote.afternote_fe.navigation import android.app.Activity import android.content.Context import android.content.ContextWrapper +import androidx.compose.animation.ContentTransform import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets @@ -39,6 +40,8 @@ import com.afternote.core.ui.bottombar.BottomBar import com.afternote.core.ui.navigation.FeatureNavDisplay import com.afternote.core.ui.navigation.FeatureNavigationCallbacks import com.afternote.core.ui.navigation.NavDestinationBackground +import com.afternote.core.ui.navigation.PredictiveBackPopEnter +import com.afternote.core.ui.navigation.PredictiveBackPopExit import com.afternote.feature.afternote.presentation.navigation.AfternoteNavHost import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteNavHost import com.afternote.feature.home.presentation.HomeTabScreen @@ -91,7 +94,12 @@ fun AppNavigation( scope.launch { snackbarHostState.showSnackbar(message) } } val receiverHomeActions = rememberReceiverHomeActions(appState) - val rootNavigationCallbacks = rememberRootNavigationCallbacks(appState, onAppRootExit = { activity?.finish() }) + val rootNavigationCallbacks = + rememberRootNavigationCallbacks( + appState = appState, + onAppRootExit = { activity?.finish() }, + onAtRootChanged = {}, + ) val afternoteNavigationCallbacks = rememberRootNavigationCallbacks( appState = appState, @@ -152,6 +160,9 @@ fun AppNavigation( modifier = Modifier.padding(innerPadding).consumeWindowInsets(innerPadding), backStack = appState.backStack, boundary = rootNavigationCallbacks, + predictivePopTransitionSpec = { _ -> + ContentTransform(PredictiveBackPopEnter, PredictiveBackPopExit) + }, entryProvider = entryProvider { entry { @@ -226,7 +237,7 @@ private fun MindRecordRootHost( private fun rememberRootNavigationCallbacks( appState: AppState, onAppRootExit: () -> Unit, - onAtRootChanged: (Boolean) -> Unit = {}, + onAtRootChanged: (Boolean) -> Unit, ): FeatureNavigationCallbacks = remember(appState, onAppRootExit, onAtRootChanged) { object : FeatureNavigationCallbacks { diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/FeatureNavDisplay.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/FeatureNavDisplay.kt index 3d9bf8fe67..dc58e30707 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/FeatureNavDisplay.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/FeatureNavDisplay.kt @@ -1,5 +1,7 @@ package com.afternote.core.ui.navigation +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -12,7 +14,9 @@ import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavEntryDecorator import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.scene.Scene import androidx.navigation3.ui.NavDisplay +import androidx.navigation3.ui.defaultPredictivePopTransitionSpec /** * 피처 하나가 소유하는 로컬 Navigation 3 스택의 표준 표시부. @@ -40,6 +44,8 @@ import androidx.navigation3.ui.NavDisplay * `FeatureNavDisplayTest` 의 「back 은 스택만 줄이고 바닥에서는 이 표시부를 지나쳐 위로 흐른다」가 * 이 갈림을 잠근다. * + * `predictivePopTransitionSpec` 은 Navigation 3 기본값이며, 루트는 #1702 이관 중 공용 전환을 지정한다. + * * #1996: 온보딩의 named argument 호환을 위해 `boundary` 이름을 소비처 전환까지 유지한다. */ @Composable @@ -47,6 +53,8 @@ public fun FeatureNavDisplay( backStack: NavBackStack, boundary: FeatureNavigationCallbacks, modifier: Modifier = Modifier, + predictivePopTransitionSpec: AnimatedContentTransitionScope>.(Int) -> ContentTransform = + defaultPredictivePopTransitionSpec(), entryProvider: (NavKey) -> NavEntry, ) { val currentNavigationCallbacks by rememberUpdatedState(boundary) @@ -69,6 +77,7 @@ public fun FeatureNavDisplay( currentNavigationCallbacks.exit() } }, + predictivePopTransitionSpec = predictivePopTransitionSpec, entryDecorators = rememberStandardNavEntryDecorators(), entryProvider = entryProvider, ) diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/PredictiveBackTransitions.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/PredictiveBackTransitions.kt index 1adf08d5d4..2a5b9c7248 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/PredictiveBackTransitions.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/navigation/PredictiveBackTransitions.kt @@ -9,8 +9,8 @@ import androidx.compose.animation.scaleOut /** * predictive back 진행 중 두 화면을 그리는 전환 한 쌍 (#1869). * - * 이 저장소는 네비게이션 엔진을 둘 쓴다 — 루트는 Navigation 2 `NavHost`, 각 피처의 로컬 스택은 - * Navigation 3 `NavDisplay` 다(#1698). **두 엔진의 기본값이 서로 다르다.** + * 이 저장소의 루트와 피처별 로컬 스택은 Navigation 3 `NavDisplay` 를 쓴다(#1702). + * 루트는 이 공용 전환을 명시적으로 전달하고, 로컬 스택은 같은 기본 전환을 사용한다. * * - Nav3 `NavDisplay` 는 `predictivePopTransitionSpec` 을 안 넘기면 * `defaultPredictivePopTransitionSpec()` 을 쓴다 — 아래 두 값과 같은 모양이다 @@ -20,10 +20,9 @@ import androidx.compose.animation.scaleOut * (navigation-compose 2.9.8 `NavHost.kt:132`). 앞 화면이 줄지 않고 제자리에서 흐려지기만 해, * 진행 중에 두 화면이 같은 크기로 겹쳐 보인다 — #1869 가 실기기에서 잡은 증상이다. * - * 그래서 **새 값을 정하지 않고 Nav3 기본값을 그대로 복제해** 두 엔진의 눈에 보이는 동작을 맞춘다. - * 루트가 `NavDisplay` 로 바뀌면(#1702) 이 두 상수를 그대로 + * 새 전환 값을 정하지 않고 Nav3 기본값과 같은 동작을 공유한다. 루트는 * `predictivePopTransitionSpec = { ContentTransform(PredictiveBackPopEnter, PredictiveBackPopExit) }` - * 에 넘겨, 이관 전후로 화면이 달라지지 않게 한다. + * 를 전달해 이 전환을 명시적으로 유지한다. * * ### 모서리 라운딩은 여기서 못 준다 * `ExitTransition` 은 fade·scale·slide·shrink 만 표현한다 — clip 이나 shape 는 담을 수 없다. diff --git a/konsist/src/test/kotlin/com/afternote/konsist/RootNavHostPredictiveBackKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/RootNavHostPredictiveBackKonsistTest.kt index d8d0885bee..f633af643b 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/RootNavHostPredictiveBackKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/RootNavHostPredictiveBackKonsistTest.kt @@ -4,20 +4,18 @@ import com.lemonappdev.konsist.api.declaration.KoFileDeclaration import org.junit.Test /** - * 루트 `NavHost` 의 predictive back pop 전환 결선 가드 (#1869). + * 루트 `FeatureNavDisplay` 의 predictive back pop 전환 결선 가드 (#1869, #1702). * - * 이 저장소는 네비게이션 엔진을 둘 쓴다. Nav3 로컬 스택은 `predictivePopTransitionSpec` 을 안 넘겨도 - * 라이브러리 기본값이 앞 화면을 줄여 주지만, **Nav2 루트 `NavHost` 의 pop 기본값은 fade 라 앞 화면이 - * 제자리에서 흐려지기만 한다** — 그래서 진행 중 두 화면이 같은 크기로 겹쳐 보였다(#1869 실기기 재현). + * Navigation 3 기본값과 #1869 공용 전환은 같은 모양이지만, 루트도 공용 전환을 명시적으로 전달해 + * 로컬 스택과 단일 출처를 유지한다. * * 어긋난 축이 「화면이 어떻게 보이는가」 하나라서 기존 자동 검사가 전부 지나간다. * `PredictiveBackNavigationTest`(5건)는 백스택 상태만 보고, Robolectric 은 프레임을 돌리지 않는다. - * `popEnterTransition`·`popExitTransition` 두 인자는 **기본값이 있어 지워도 컴파일이 통과하고**, - * 그대로 #1869 이전 상태로 되돌아간다. 그 되돌림을 소스에서 본다. + * `predictivePopTransitionSpec` 은 기본값이 있어 지워도 컴파일이 통과한다. 그 되돌림을 소스에서 본다. * * ### 이 가드가 잡는 것과 못 잡는 것 * - * 잡는 것 — 두 인자 중 하나라도 사라지는 것, 다른 값으로 갈아 끼우는 것, core/ui 의 공용 상수를 + * 잡는 것 — 공용 전환 둘 중 하나라도 사라지는 것, 다른 값으로 갈아 끼우는 것, core/ui 의 공용 상수를 * 두고 app 안에서 따로 정의한 값을 넘기는 것(import 까지 본다), 그리고 core/ui 쪽 상수가 * 이름을 바꾸거나 사라지는 것. * @@ -29,47 +27,40 @@ import org.junit.Test */ class RootNavHostPredictiveBackKonsistTest { @Test - fun `루트 NavHost 는 core ui 의 predictive back pop 전환을 넘긴다`() { + fun `루트 FeatureNavDisplay 는 core ui 의 predictive back pop 전환을 넘긴다`() { // 공유 진입점을 쓴다 — 워크트리 사본이 섞이면 남의 브랜치 소스로 헛짚는다 (#1659, ScanScopeKonsistTest). val source = singleFile(APP_NAVIGATION_PATH).text - val arguments = rootNavHostArguments(source) + val arguments = rootNavDisplayArguments(source) check(arguments != null) { - "$APP_NAVIGATION_PATH 에서 루트 «NavHost(» 호출을 찾지 못했다 — 루트가 NavDisplay 로 바뀌었다면(#1702) " + - "이 가드를 predictivePopTransitionSpec 기준으로 옮긴다." + "$APP_NAVIGATION_PATH 에서 루트 «FeatureNavDisplay(» 호출을 찾지 못했다." } - EXPECTED_BINDINGS.forEach { (parameter, constant) -> - val binding = - Regex("""$parameter\s*=\s*([^,\n]+)""") - .find(arguments) - ?.groupValues - ?.get(1) - ?.trim() - - check(binding != null) { - "루트 NavHost 에 $parameter 인자가 없다 (#1869).\n" + - "기본값은 fade 라, 지우면 predictive back 진행 중 앞 화면이 줄지 않고 뒤 화면과 겹쳐 그려진다.\n" + - " $parameter = { $constant }," - } + val binding = Regex("""(?s)predictivePopTransitionSpec\s*=\s*(\{[^}]*})""").find(arguments)?.groupValues?.get(1) + check(binding != null) { "루트 FeatureNavDisplay 에 predictivePopTransitionSpec 인자가 없다 (#1869)." } + EXPECTED_BINDINGS.forEach { constant -> check(binding.references(constant)) { - "루트 NavHost 의 $parameter 가 «$constant» 이 아니라 «$binding» 을 넘긴다 (#1869)." + "루트 FeatureNavDisplay 의 predictivePopTransitionSpec 에 «$constant» 가 없다 (#1869)." } // 같은 이름을 app 안에서 새로 정의해도 위 단언은 통과한다 — 출처가 core/ui 인지 import 로 본다. check(source.contains("import $CONSTANT_PACKAGE.$constant")) { "$APP_NAVIGATION_PATH 가 $CONSTANT_PACKAGE.$constant 을 import 하지 않는다 — " + - "두 엔진이 같은 값을 쓰도록 상수는 core/ui 한 곳에 둔다 (#1869)." + "루트와 로컬이 같은 값을 쓰도록 상수는 core/ui 한 곳에 둔다 (#1869)." } } + val featureDisplay = singleFile(FEATURE_NAV_DISPLAY_PATH).text + check(Regex("""predictivePopTransitionSpec\s*=\s*predictivePopTransitionSpec""").containsMatchIn(featureDisplay)) { + "FeatureNavDisplay 가 predictivePopTransitionSpec 을 NavDisplay 에 전달하지 않는다 (#1869)." + } } @Test fun `predictive back pop 전환은 core ui 가 소유한다`() { val declared = singleFile(TRANSITIONS_PATH).properties().map { it.name }.toSet() - val missing = EXPECTED_BINDINGS.values.filterNot(declared::contains) + val missing = EXPECTED_BINDINGS.filterNot(declared::contains) check(missing.isEmpty()) { - "$TRANSITIONS_PATH 에 ${missing.joinToString()} 이 없다 — 루트 NavHost 와 #1702 의 NavDisplay 가 " + + "$TRANSITIONS_PATH 에 ${missing.joinToString()} 이 없다 — 루트와 로컬 NavDisplay 가 " + "같은 값을 넘기려면 이 파일이 단일 출처여야 한다 (#1869)." } } @@ -91,7 +82,7 @@ class RootNavHostPredictiveBackKonsistTest { * 이름은 식별자 경계로 찾는다 — 같은 파일에 `AfternoteNavHost(`·`ReceiverNavHost(` 처럼 끝이 * 겹치는 호출이 여럿이라, 단순 `indexOf` 는 그중 무엇이 먼저 오느냐에 결과가 달라진다. */ - private fun rootNavHostArguments(source: String): String? { + private fun rootNavDisplayArguments(source: String): String? { val open = NAV_HOST_CALL.find(source)?.range?.last ?: return null var depth = 0 for (index in open until source.length) { @@ -120,16 +111,13 @@ class RootNavHostPredictiveBackKonsistTest { const val APP_NAVIGATION_PATH = "app/src/main/java/com/afternote/afternote_fe/navigation/AppNavigation.kt" const val TRANSITIONS_PATH = "core/ui/src/main/kotlin/com/afternote/core/ui/navigation/PredictiveBackTransitions.kt" + const val FEATURE_NAV_DISPLAY_PATH = "core/ui/src/main/kotlin/com/afternote/core/ui/navigation/FeatureNavDisplay.kt" const val CONSTANT_PACKAGE = "com.afternote.core.ui.navigation" - /** 루트 `NavHost(` — `AfternoteNavHost(` 처럼 끝만 겹치는 호출을 배제한다. */ - val NAV_HOST_CALL = Regex("""(? Date: Wed, 14 Oct 2026 13:27:57 +0900 Subject: [PATCH 3/3] fix(navigation): initialize supplied start destination --- .../java/com/afternote/afternote_fe/navigation/AppState.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/afternote/afternote_fe/navigation/AppState.kt b/app/src/main/java/com/afternote/afternote_fe/navigation/AppState.kt index 3e021196bb..78cbda181b 100644 --- a/app/src/main/java/com/afternote/afternote_fe/navigation/AppState.kt +++ b/app/src/main/java/com/afternote/afternote_fe/navigation/AppState.kt @@ -27,7 +27,7 @@ class AppState( private val activeStackState: MutableState, internal val legacyMindRecordController: NavHostController? = null, ) { - private var legacyStartDestinationInitialized = legacyMindRecordController == null + private var startDestinationInitialized = false var activeStack: AppStack by activeStackState private set @@ -86,8 +86,8 @@ class AppState( } internal fun initializeLegacyStartDestination(route: Route) { - if (legacyStartDestinationInitialized) return - legacyStartDestinationInitialized = true + if (startDestinationInitialized) return + startDestinationInitialized = true when (route) { Route.Home -> { activeStack = AppStack.HOME