Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9e6d740
feat(navigation): add parent/flow-scope hierarchy fields to CodeNavig…
bmc08gt Jul 9, 2026
c0d0e77
feat(navigation): dispatch navigate() by route type through the paren…
bmc08gt Jul 9, 2026
f9020ac
feat(navigation): add navigateBackWithResult() and dismiss() intents
bmc08gt Jul 9, 2026
e391120
feat(navigation): wire FlowHost inner navigator with parent + FlowScope
bmc08gt Jul 9, 2026
3839ff1
fix(navigation): guard sheet dismiss on isSheetRoot; polish from fina…
bmc08gt Jul 12, 2026
c7ce2f9
refactor(navigation): make FlowNavigator a thin adapter over CodeNavi…
bmc08gt Jul 12, 2026
0efb446
refactor(navigation): route chat flow through single dispatching navi…
bmc08gt Jul 13, 2026
e68aaa5
refactor(navigation): drop inert flow re-shadow on deposit informatio…
bmc08gt Jul 13, 2026
e97fb29
refactor(navigation): route withdrawal amount step through dispatchin…
bmc08gt Jul 13, 2026
db33d42
refactor(navigation): use single flow navigator handle in contact list
bmc08gt Jul 13, 2026
8084115
refactor(navigation): make swap->verification result nav explicitly a…
bmc08gt Jul 13, 2026
2a5cefd
fix(chat): eliminate message-list reflow on open and back-navigation
bmc08gt Jul 13, 2026
c0d36a6
feat(navigation): add dispatchTarget() and rootNavigator to CodeNavig…
bmc08gt Jul 13, 2026
6187ea0
fix(navigation): register navigateForResult callback on the dispatch …
bmc08gt Jul 13, 2026
9c0eeef
fix(navigation): route openAsSheet through the sheet-hosting root nav…
bmc08gt Jul 13, 2026
7b9837a
refactor(navigation): swap uses single navigator for cross-boundary r…
bmc08gt Jul 13, 2026
373744d
refactor(navigation): login opens the lab sheet via the single navigator
bmc08gt Jul 13, 2026
bf12706
chore(navigation): delete unused flowAnnotatedEntry DSL
bmc08gt Jul 13, 2026
c26eb21
refactor(navigation): remove LocalOuterCodeNavigator
bmc08gt Jul 13, 2026
722fdf7
chore(navigation): remove unused Voyager alias overloads (show, push/…
bmc08gt Jul 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,23 @@ import com.getcode.navigation.core.NavOptions
* open, the current sheet is animated closed before the new one opens.
*/
fun CodeNavigator.openAsSheet(route: AppRoute, innerRoutes: List<AppRoute> = emptyList()) {
// Sheets are hosted by the parent-chain root navigator — the one whose NavDisplay runs the
// ModalBottomSheetSceneStrategy and observes pendingSheetDismiss/sheetGeneration. Route the
// operation there so it works even when called from a flow's inner navigator. For a caller
// that is already the root, `host` is `this`, so behavior is unchanged.
val host = rootNavigator
val destination = AppRoute.Main.Sheet(route, innerRoutes)
val hasSheet = backStack.any { it is AppRoute.Main.Sheet }
val hasSheet = host.backStack.any { it is AppRoute.Main.Sheet }

if (hasSheet) {
pendingSheetDismiss = {
host.pendingSheetDismiss = {
Snapshot.withMutableSnapshot {
sheetGeneration++
navigate(destination)
host.sheetGeneration++
host.navigate(destination)
}
}
} else {
navigate(destination)
host.navigate(destination)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import com.getcode.navigation.flow.PreviewFlowNavigator
import com.getcode.navigation.flow.deliverFlowResult
import com.getcode.navigation.flow.rememberFlowNavigator
import com.getcode.navigation.flow.rememberInitialStack
import com.getcode.navigation.flowAnnotatedEntry
import com.getcode.navigation.results.NavResultOrCanceled
import com.getcode.navigation.results.NavResultStateRegistry
import com.getcode.ui.components.AppBarWithTitle
Expand Down Expand Up @@ -86,7 +85,7 @@ fun DepositFlowScreen(
private fun depositEntryProvider(
showOtherOptions: Boolean,
): (NavKey) -> NavEntry<NavKey> = entryProvider {
flowAnnotatedEntry<DepositStep.UsdcInformational> {
annotatedEntry<DepositStep.UsdcInformational> {
UsdcDepositInformationScreen(showOtherOptions)
}
annotatedEntry<DepositStep.SelectToken> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import com.flipcash.app.permissions.ContactAccessResult
import com.flipcash.app.permissions.rememberContactAccessHandle
import com.flipcash.features.directsend.R
import com.getcode.libs.analytics.LocalAnalytics
import com.getcode.navigation.flow.LocalOuterCodeNavigator
import com.getcode.navigation.flow.flowSharedViewModel
import com.getcode.navigation.flow.rememberFlowNavigator
import com.getcode.theme.CodeTheme
Expand All @@ -48,7 +47,6 @@ import kotlinx.coroutines.flow.filterIsInstance
internal fun ContactListScreen() {
val flowNavigator = rememberFlowNavigator<SendStep, SendResult>()
val viewModel = flowSharedViewModel<SendFlowViewModel>()
val navigator = LocalOuterCodeNavigator.current
val analytics = LocalAnalytics.current

val state by viewModel.stateFlow.collectAsStateWithLifecycle()
Expand All @@ -57,7 +55,9 @@ internal fun ContactListScreen() {
viewModel.eventFlow
.filterIsInstance<SendFlowViewModel.Event.SendInvite>()
.collect { event ->
navigator.show(AppRoute.Main.InviteContact(event.contact.e164))
// App route -> flowNavigator.navigate bubbles it to the app stack (identical to the
// old outerNavigator.show, since show(r) == navigate(r)).
flowNavigator.navigate(AppRoute.Main.InviteContact(event.contact.e164))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.navigation.core.NavOptions
import com.getcode.navigation.flow.FlowExitReason
import com.getcode.navigation.flow.FlowHost
import com.getcode.navigation.flow.LocalOuterCodeNavigator
import com.getcode.navigation.flow.rememberFlowNavigator
import com.getcode.navigation.flow.rememberInitialStack
import com.getcode.navigation.results.NavResultStateRegistry
Expand Down Expand Up @@ -282,7 +281,7 @@ private fun LoginStepContent(seed: String?) {
val vm = hiltViewModel<LoginViewModel>()
val state by vm.stateFlow.collectAsStateWithLifecycle()
val flowNavigator = rememberFlowNavigator<OnboardingStep, OnboardingResult>()
val outerNavigator = LocalOuterCodeNavigator.current
val navigator = LocalCodeNavigator.current
var visible by remember { mutableStateOf(false) }
val activity = LocalActivity.current

Expand Down Expand Up @@ -354,7 +353,7 @@ private fun LoginStepContent(seed: String?) {
login = { flowNavigator.navigateTo(OnboardingStep.SeedInput) },
isLabsOpen = state.betaOptionsVisible,
onLogoTapped = { vm.dispatchEvent(LoginViewModel.Event.OnLogoTapped) },
openBetaFlags = { outerNavigator.openAsSheet(AppRoute.Menu.Lab(onboarding = true)) },
openBetaFlags = { navigator.openAsSheet(AppRoute.Menu.Lab(onboarding = true)) },
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package com.flipcash.app.messenger

import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
Expand All @@ -19,17 +18,14 @@ import com.flipcash.app.messenger.internal.screens.MessengerScreen
import com.getcode.navigation.annotatedEntry
import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.navigation.flow.FlowHost
import com.getcode.navigation.flow.LocalOuterCodeNavigator
import com.getcode.navigation.flow.flowSharedViewModel
import com.getcode.navigation.flow.rememberFlowNavigator
import com.getcode.navigation.flow.rememberInitialStack
import com.getcode.navigation.results.NavResultOrCanceled
import com.getcode.navigation.results.NavResultStateRegistry
import com.getcode.navigation.results.navigateForResult
import com.getcode.navigation.results.resultBackNavigator
import com.getcode.navigation.scenes.LocalSheetNavigator
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map

@Composable
fun ChatFlowScreen(
Expand Down Expand Up @@ -62,12 +58,10 @@ private fun chatEntryProvider(
private fun FlowConversationScreen(identifier: ChatIdentifier) {
val viewModel = flowSharedViewModel<ChatViewModel>()
val navigator = LocalCodeNavigator.current
// The outer app/sheet nav host. Its entryProvider (appEntryProvider) registers
// full AppRoutes like Token.Swap/Info/Discovery; the inner [navigator] only knows
// this flow's ChatStep keys, so full routes must be pushed onto the outer navigator.
val outerNavigator = LocalOuterCodeNavigator.current
// The sheet-owning (root) navigator — the one whose back stack holds this chat's
// Main.Sheet and whose pendingSheetDismiss the dismiss animation observes.
// The sheet-owning (root) navigator — the one whose back stack holds this chat's Main.Sheet and
// whose pendingSheetDismiss the dismiss animation observes. openAsSheet is stack-local (it
// inspects/mutates this navigator's own back stack), so it can't ride the type dispatcher and
// must target the sheet navigator explicitly.
val sheetNavigator = LocalSheetNavigator.current

LaunchedEffect(viewModel, identifier) {
Expand All @@ -78,6 +72,8 @@ private fun FlowConversationScreen(identifier: ChatIdentifier) {
viewModel.eventFlow
.filterIsInstance<ChatViewModel.Event.NavigateToAmountEntry>()
.collect {
// ChatStep.AmountEntry is a FlowStep -> the dispatcher keeps this push on the inner
// flow stack and registers the result callback on the inner store (intra-flow).
navigator.navigateForResult<ChatSendResult>(ChatStep.AmountEntry) { result ->
if (result is NavResultOrCanceled.ReturnValue) {
viewModel.dispatchEvent(ChatViewModel.Event.OnStartMessageInput)
Expand All @@ -91,14 +87,15 @@ private fun FlowConversationScreen(identifier: ChatIdentifier) {
.filterIsInstance<ChatViewModel.Event.OpenScreen>()
.collect { (route, asSheet) ->
if (asSheet) {
// Dismiss this chat sheet and open [route] as a fresh sheet.
// openAsSheet on the sheet-owning navigator animates the current
// sheet closed (via pendingSheetDismiss) before opening the new one.
// Dismiss this chat sheet and open [route] as a fresh sheet. openAsSheet on the
// sheet-owning navigator animates the current sheet closed (via pendingSheetDismiss)
// before opening the new one.
sheetNavigator?.openAsSheet(route)
} else {
// Push onto the outer nav host (which registers full AppRoutes) rather
// than the inner flow navigator, whose backstack only handles ChatSteps.
outerNavigator.navigate(route)
// A full AppRoute (never a ChatStep) -> the dispatcher bubbles it up the parent
// chain to the outer app nav host, the same destination as the old
// outerNavigator.navigate(route).
navigator.navigate(route)
}
}
}
Expand All @@ -110,21 +107,19 @@ private fun FlowConversationScreen(identifier: ChatIdentifier) {
private fun FlowAmountEntryScreen() {
val viewModel = flowSharedViewModel<ChatViewModel>()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val flowNavigator = rememberFlowNavigator<ChatStep, Parcelable>()
val navigator = LocalCodeNavigator.current
val resultBack = resultBackNavigator<ChatSendResult>()

// Re-provide the outer navigator so ChatAmountEntryContent can push
// app-level routes (RegionSelection, TokenSelection, etc.)
val outerNavigator = LocalOuterCodeNavigator.current
CompositionLocalProvider(LocalCodeNavigator provides outerNavigator) {
ChatAmountEntryContent(
amountDelegate = viewModel.amountDelegate,
resolveState = state.resolveState,
token = state.token,
eventFlow = viewModel.eventFlow,
onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnConfirmRequested) },
onSendComplete = { resultBack.returnValue(ChatSendResult) },
onExit = { flowNavigator.back() },
)
}
// No re-shadow: ChatAmountEntryContent reads the inner LocalCodeNavigator, and its
// navigator.push(AppRoute.Main.RegionSelection) / push(AppRoute.Sheets.TokenSelection)
// bubble to the app navigator through the dispatcher.
ChatAmountEntryContent(
amountDelegate = viewModel.amountDelegate,
resolveState = state.resolveState,
token = state.token,
eventFlow = viewModel.eventFlow,
onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnConfirmRequested) },
onSendComplete = { resultBack.returnValue(ChatSendResult) }, // intra-flow result -> Conversation
onExit = { navigator.navigateBack() }, // pop the AmountEntry step
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,17 @@ internal class ChatViewModel @Inject constructor(
is ChatIdentifier.ByChatId -> identifier.chatId
}

// Re-entering the same, already-open chat (e.g. returning from the amount-entry
// step) re-dispatches OnChatOpened. The chat is already resolved and its messages
// are cached in Room, so skip the re-resolve + network reload that would invalidate
// Paging and reflow the message list. Still keep the chat active and clear
// notifications.
if (chatId != null && stateFlow.value.chatId == chatId) {
chatCoordinator.setActiveChatId(chatId)
chatCoordinator.dismissNotifications(chatId)
return@onEach
}

if (chatId != null) {
dispatchEvent(Event.ChatFound(chatId))
chatCoordinator.setActiveChatId(chatId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
Expand Down Expand Up @@ -348,30 +347,39 @@ private fun ChatInputScaffold(
bottomBar: @Composable () -> Unit = {},
content: @Composable (PaddingValues) -> Unit,
) {
val density = LocalDensity.current
var topBarHeight by remember { mutableStateOf(0.dp) }
var bottomBarHeight by remember { mutableStateOf(0.dp) }
// Overlay scaffold: the content fills the whole area (drawn behind the blurred bars) and is
// inset by the bar heights. SubcomposeLayout measures the bars BEFORE the content in the same
// layout pass, so the content receives correct padding on the very first frame. The previous
// onSizeChanged approach fed 0 padding on frame 1 and snapped to the real heights on frame 2,
// which made the message list visibly jump on every open and every pop-back.
SubcomposeLayout(
modifier = Modifier
.imePadding()
.testTag("chat_screen"),
) { constraints ->
val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)

Box(modifier = Modifier.imePadding().testTag("chat_screen")) {
content(
PaddingValues(
top = topBarHeight,
bottom = bottomBarHeight,
)
val topPlaceables = subcompose(ChatScaffoldSlot.Top, topBar).map { it.measure(looseConstraints) }
val bottomPlaceables = subcompose(ChatScaffoldSlot.Bottom, bottomBar).map { it.measure(looseConstraints) }
val topHeight = topPlaceables.maxOfOrNull { it.height } ?: 0
val bottomHeight = bottomPlaceables.maxOfOrNull { it.height } ?: 0

val padding = PaddingValues(
top = topHeight.toDp(),
bottom = bottomHeight.toDp(),
)
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.onSizeChanged { topBarHeight = with(density) { it.height.toDp() } }
) {
topBar()
}
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.onSizeChanged { bottomBarHeight = with(density) { it.height.toDp() } }
) {
bottomBar()

val contentPlaceables = subcompose(ChatScaffoldSlot.Content) { content(padding) }
.map { it.measure(constraints) }

layout(constraints.maxWidth, constraints.maxHeight) {
contentPlaceables.forEach { it.place(0, 0) }
topPlaceables.forEach { it.place((constraints.maxWidth - it.width) / 2, 0) }
bottomPlaceables.forEach {
it.place((constraints.maxWidth - it.width) / 2, constraints.maxHeight - it.height)
}
}
}
}

private enum class ChatScaffoldSlot { Top, Bottom, Content }
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.Snapshot
Expand Down Expand Up @@ -108,7 +109,10 @@ internal fun MessageList(
// Track when the initial Paging refresh has truly completed (Loading → NotLoading).
// This avoids showing the ContactInfoContainer before messages arrive,
// which would cause the list to start scrolled to the wrong position.
var refreshSettled by remember { mutableStateOf(false) }
// rememberSaveable so it survives the screen being torn down and rebuilt when the
// amount-entry step is pushed over the conversation — otherwise the content gate
// re-arms on pop-back and the list re-settles (visible reflow).
var refreshSettled by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
snapshotFlow { messages.loadState.refresh }
.dropWhile { it !is LoadState.Loading }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import com.flipcash.app.core.tokens.SwapStep
import com.getcode.opencode.model.financial.Fiat
import com.getcode.navigation.annotatedEntry
import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.navigation.flowAnnotatedEntry
import com.getcode.navigation.flow.rememberInitialStack
import com.getcode.navigation.flow.FlowExitReason
import com.getcode.navigation.flow.FlowHost
Expand Down Expand Up @@ -72,7 +71,9 @@ private fun swapEntryProvider(
val depositFirstPurpose = (route.purpose as? SwapPurpose.Buy)
?.takeIf { it.fundingSource == FundingSource.Phantom }

flowAnnotatedEntry<SwapStep.Entry> { step ->
// SwapEntryScreen reads only the inner LocalCodeNavigator; its cross-boundary result nav
// resolves to the app navigator via the dispatcher, so a plain annotatedEntry is correct.
annotatedEntry<SwapStep.Entry> { step ->
SwapEntryScreen(step.purpose, step.initialAmount)
}
annotatedEntry<SwapStep.SellReceipt> { SellReceiptScreen() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import com.getcode.navigation.flow.PreviewFlowNavigator
import com.getcode.navigation.flow.deliverFlowResult
import com.getcode.navigation.flow.rememberFlowNavigator
import com.getcode.navigation.flow.rememberInitialStack
import com.getcode.navigation.flowAnnotatedEntry
import com.getcode.navigation.results.NavResultOrCanceled
import com.getcode.navigation.results.NavResultStateRegistry
import com.getcode.ui.components.AppBarWithTitle
Expand Down Expand Up @@ -91,7 +90,7 @@ private fun withdrawalEntryProvider(
annotatedEntry<WithdrawalStep.SelectToken> {
WithdrawalSelectTokenScreen()
}
flowAnnotatedEntry<WithdrawalStep.Amount> { step ->
annotatedEntry<WithdrawalStep.Amount> { step ->
WithdrawalEntryScreen(step.mint)
}
annotatedEntry<WithdrawalStep.Destination> {
Expand Down
3 changes: 3 additions & 0 deletions ui/navigation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,7 @@ dependencies {
api(libs.lifecycle.viewmodel.navigation3)
api(libs.hilt.nav.compose)
api(libs.rinku)

testImplementation(kotlin("test"))
testImplementation(libs.bundles.unit.testing)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,10 @@ package com.getcode.navigation

import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import com.getcode.navigation.results.NavResultKey
import com.getcode.navigation.results.NavigationRetVal
import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.navigation.flow.LocalOuterCodeNavigator
import java.lang.reflect.ParameterizedType
import kotlin.reflect.KClass

Expand All @@ -30,22 +27,6 @@ inline fun <reified T : NavKey> EntryProviderScope<NavKey>.annotatedEntry(
entry(metadata = T::class.metadata(), content = content)
}

/**
* Like [annotatedEntry] but re-provides the outer app-level [CodeNavigator][com.getcode.navigation.core.CodeNavigator]
* as [LocalCodeNavigator] inside [content]. Use this for flow steps that need to push routes onto the
* outer/app nav graph (e.g. region selection) while running inside a [FlowHost][com.getcode.navigation.flow.FlowHost].
*/
inline fun <reified T : NavKey> EntryProviderScope<NavKey>.flowAnnotatedEntry(
noinline content: @Composable (T) -> Unit
) {
entry(metadata = T::class.metadata()) { step: T ->
val outerNavigator = LocalOuterCodeNavigator.current
CompositionLocalProvider(LocalCodeNavigator provides outerNavigator) {
content(step)
}
}
}

/**
* Compute metadata from a [KClass] by inspecting its marker interfaces.
*
Expand Down
Loading
Loading