From aecc01d94c251164c1ff29f59b204f6ba2063764 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 15:25:05 -0400 Subject: [PATCH 1/6] feat(ui): hold a confirm button's success state before moving on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screens that navigate off a confirm button all dispatch success, sleep, then go — UsernameEntryViewModel, NameEntryViewModel, PhotoSelectionViewModel and CurrencyCreatorViewModel each spell out the same four lines, at 400ms in two places and 500ms in the rest. Without the sleep the checkmark is swapped away on the frame it is drawn. dispatchSuccessThen puts the timing on BaseViewModel with the hold as a named default, and runs the continuation on viewModelScope, so backing out mid-hold cancels the navigation instead of pushing into a screen the user has left. Result.onSuccessWithDelay does not cover this: it measures from the start of the operation, so the hold reaches zero exactly when the work was slow enough for the checkmark to need it. It has no callers in either file that defines it. Only the new-chat lookup adopts the new helper here; the sites above are unchanged. --- .../kotlin/com/getcode/view/BaseViewModel.kt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt b/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt index 98855f2f4e..26f639dea0 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt @@ -3,6 +3,8 @@ package com.getcode.view import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -13,6 +15,8 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds abstract class BaseViewModel( initialState: ViewState, @@ -39,6 +43,28 @@ abstract class BaseViewModel( } } + /** + * Show a confirm button's success state, hold it long enough to be read, then move on. + * + * The hold starts when [success] is dispatched, not when the work began: by the time a screen + * has a result to act on, the checkmark has not been drawn yet, and navigating on the same + * frame swaps it away before anyone sees it. That is what separates this from + * `Result.onSuccessWithDelay`, which enforces a minimum duration for the *operation* and so + * holds for nothing once the operation itself is slow. + * + * [then] runs on [viewModelScope], so it is cancelled with the ViewModel rather than pushing a + * route the user has already left. + */ + protected fun dispatchSuccessThen( + success: Event, + hold: Duration = SuccessHoldDuration, + then: suspend () -> Unit, + ): Job = viewModelScope.launch { + dispatchEvent(success) + delay(hold) + then() + } + // Events are dispatched from multiple threads — the UI thread and background flows on // defaultDispatcher — so this must be an atomic compare-and-set, not a plain // read-modify-write. A non-atomic assignment lets concurrent reducers derive from the same @@ -48,6 +74,15 @@ abstract class BaseViewModel( } } +/** + * How long a confirm button holds its checkmark before the screen it sits on moves on. + * + * Matches what the username-claim, name-entry and photo-selection screens already wait; the + * longer holds elsewhere (verification's second, the deposit screen's two) are their own beat, not + * this one. + */ +val SuccessHoldDuration = 500.milliseconds + data class LoadingSuccessState( val loading: Boolean = false, val success: Boolean = false, From 8750a61422e2f741435be17c37aa786bbeeef163 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 15:25:13 -0400 Subject: [PATCH 2/6] feat(chat): start a chat by typing someone's username MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Chats tab could only show conversations that already existed: a tip DM appeared once someone had tipped you, and there was no way to reach a person you had never exchanged money with. The "+" in the title bar now opens a handle entry field (node 9442:5825) that resolves the username and opens the conversation. ChatIdentifier.ByUser carries the resolved user id and profile, and is the only identifier that can open a chat which does not exist yet. The canonical TIP_DM id is derived from the user id offline, so the chat opens on the id the first tip will land on; the header renders from the profile the lookup already returned rather than waiting on a members fetch that would come back empty. An empty tip DM has no composer — typing is gated on a first payment — so SendCashButton takes over the whole bar as a "Send Tip" call to action instead of collapsing to a full-width transparent "$". The entry screen is a top-level route rather than a step of the tipping flow: the Chats list is a tab home, so a step pushed inside it would leave the tab bar sitting over the field. Opening the chat replaces the entry screen, so backing out of the chat lands on the list it belongs to. Handle input is clamped to the server's ^[a-z0-9_]{2,15}$ as it is typed. Unclamped, an over-long paste fails request validation and surfaces as "Something Went Wrong", which tells the user nothing they can act on. --- .../ui/navigation/AppScreenContent.kt | 2 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 12 ++ .../flipcash/app/core/chat/ChatIdentifier.kt | 28 ++- .../core/src/main/res/values/strings.xml | 12 +- .../app/messenger/internal/ChatViewModel.kt | 19 ++ .../screens/components/SendCashButton.kt | 20 +- .../com/flipcash/app/tipping/NewChatScreen.kt | 171 ++++++++++++++++++ .../com/flipcash/app/tipping/TipsScreen.kt | 7 + .../app/tipping/internal/NewChatViewModel.kt | 152 ++++++++++++++++ .../com/getcode/ui/components/TitleBar.kt | 19 ++ 10 files changed, 438 insertions(+), 4 deletions(-) create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/NewChatViewModel.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 30d24ebb05..bcd30a7137 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -25,6 +25,7 @@ import com.flipcash.app.balance.WalletScreen import com.flipcash.app.cash.CashScreen import com.flipcash.app.contact.verification.VerificationFlowScreen import com.flipcash.app.currencycreator.CurrencyCreatorFlowScreen +import com.flipcash.app.tipping.NewChatScreen import com.flipcash.app.tipping.TipAmountEntryScreen import com.flipcash.app.tipping.TippingFlowScreen import com.flipcash.shared.transactionhistory.ActivityHistoryScreen @@ -110,6 +111,7 @@ fun appEntryProvider( annotatedEntry { key -> ChatFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } + annotatedEntry { NewChatScreen() } // Tokens annotatedEntry(testTag = "token_info_screen") { key -> diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 24cd673bc8..abde127e0a 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -324,6 +324,18 @@ sealed interface AppRoute : NavKey, Parcelable { override val initialStack: List get() = listOf(ChatStep.Conversation) } + + /** + * Starting a chat by typing someone's `@handle` (node 9442:5825), reached from the "+" on + * the Chats list. + * + * A top-level route rather than a step of the tipping flow, even though the Chats list it + * is reached from is one: the flow is a tab home, so a step pushed inside it keeps the tab + * bar. This covers it, the way [Chat] does. + */ + @Serializable + @Parcelize + data object NewChat : Messaging } @Serializable diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatIdentifier.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatIdentifier.kt index 872a948a5d..f38fb28f99 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatIdentifier.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatIdentifier.kt @@ -2,7 +2,10 @@ package com.flipcash.app.core.chat import android.os.Parcelable import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId +import com.getcode.opencode.model.core.ID +import com.getcode.utils.hexEncodedString import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @@ -25,4 +28,27 @@ sealed interface ChatIdentifier : Parcelable { ) : ChatIdentifier { override val key: String get() = contact.e164 } -} \ No newline at end of file + + /** + * A tip DM addressed by the counterparty's Flipcash user id — the only identifier that can open + * a conversation which does not exist yet. + * + * [ByChatId] and [ByContact] both name a chat the server already has: one by its id, one by a + * phone number the server pre-derived an id for. Reaching someone by their `@handle` has + * neither, so this carries the user id, which is what the canonical TIP_DM id is derived + * from (`ChatCoordinator.generateChatId`) — deterministic and offline, so the chat opens on the + * derived id and the first tip lands in it. + * + * [profile] rides along because the caller looked it up to get [userId] in the first place: the + * header card renders from it on the first frame rather than waiting on a members fetch, which + * for a chat with no messages would have nothing to return. + */ + @Serializable + @Parcelize + data class ByUser( + val userId: ID, + val profile: UserProfile, + ) : ChatIdentifier { + override val key: String get() = userId.hexEncodedString() + } +} diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 4d0c0d0325..39517a9e92 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -901,7 +901,17 @@ Tips Chats No Chats Yet - Send a tip or share your Tip Card to start chatting + Start a new chat, or share your profile + + + Enter Flipcash username + Enter the Flipcash username of the person you want to chat with + + Username Not Found + Please try a different username + That\'s Your Username + You can\'t start a chat with yourself Receive Tips From Everyone Add your name to receive tips Start Receiving Tips diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 4300147cd5..13fdc5f96a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -324,6 +324,11 @@ internal class ChatViewModel @Inject constructor( is ChatIdentifier.ByContact -> identifier.chatId ?: chatCoordinator.getChatId(identifier.contact).getOrNull() is ChatIdentifier.ByChatId -> identifier.chatId + // Derived, not looked up: the canonical tip-DM id is a function of the two user + // ids, so it is known before the chat exists. Opening on it means the first tip + // lands in the chat the user is already looking at. + is ChatIdentifier.ByUser -> + chatCoordinator.generateChatId(identifier.userId).getOrNull() } // Re-entering the same, already-open chat (e.g. returning from the amount-entry @@ -376,6 +381,10 @@ internal class ChatViewModel @Inject constructor( viewModelScope.launch { chatCoordinator.getOtherMember(identifier.chatId) } } } + // Identity came in with the identifier (the username lookup that produced it + // returned the profile), and the OnChatOpened reducer has already applied it. + // There is nothing to look up: a chat opened this way may have no members yet. + is ChatIdentifier.ByUser -> Unit } } .launchIn(viewModelScope) @@ -841,6 +850,16 @@ internal class ChatViewModel @Inject constructor( chatType = ChatType.CONTACT_DM, ) is ChatIdentifier.ByChatId -> state + // The counterparty is known up front, so the header card and the send gate + // resolve on the first frame. Nothing else can supply them here: a chat + // reached by username may not exist yet, and a chat with no members has no + // profile to observe. + is ChatIdentifier.ByUser -> + state.copy( + participant = ChatParticipant.TipUser(id.userId, id.profile), + chatType = ChatType.TIP_DM, + resolveState = ResolveState.Resolved, + ) } } is Event.OnContactFound -> { state -> diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt index a37649d85d..f5e2573b93 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt @@ -52,14 +52,19 @@ internal fun RowScope.SendCashButton( hazeMaterial: HazeBlurStyle, onClick: () -> Unit, ) { - // Tip chats always use the minimized (dark, symbol-only) button. The normal send flow keeps the + // Tip chats use the minimized (dark, symbol-only) button. The normal send flow keeps the // expanded "Send $" presentation and only collapses to the symbol once the user starts typing. // chatType resolves from the fast local contact lookup, so a tip DM condenses immediately rather // than waiting on the server profile. val isTipChat = state.chatType == ChatType.TIP_DM - val isTyping = isTipChat || state.chatInputState.text.isNotEmpty() val canType = state.typingConstraints.enabled + // ...except before the first payment, when there is no composer to sit beside and this button + // is the entire bar. Condensing it there would leave a full-width transparent "$"; what the + // chat actually needs is its one call to action, so it stays white and says what it does. + val isCallToAction = isTipChat && !canType + val isTyping = !isCallToAction && (isTipChat || state.chatInputState.text.isNotEmpty()) + // Colors ease slowly and independently of the width/label so the fill change reads as one calm // transition instead of snapping with the resize — but NOT on the first settle. A tip chat opens // before its kind is known, briefly reading as a non-tip chat (white); easing that initial commit @@ -133,6 +138,17 @@ internal fun RowScope.SendCashButton( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { + if (isCallToAction) { + Text( + text = stringResource(R.string.title_sendTip), + color = contentColor, + style = CodeTheme.typography.textMedium, + maxLines = 1, + softWrap = false, + ) + return@Row + } + // "action_sendCashViaSymbol" is "Send %1$s" — literally "Send " + the currency symbol. // Keep the symbol mounted at all times and only collapse the "Send " prefix, so the // symbol never crossfades against a wider label (which garbled into "$nd $"). diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt new file mode 100644 index 0000000000..f773ef2937 --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt @@ -0,0 +1,171 @@ +package com.flipcash.app.tipping + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.ui.DisplayTextInput +import com.flipcash.app.tipping.internal.MaxHandleLength +import com.flipcash.app.tipping.internal.NewChatViewModel +import com.flipcash.features.tipping.R +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.utils.rememberKeyboardController +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * Node 9442:5825 — start a chat with someone by their public `@handle`. + * + * Reached from the "+" on the Chats list, and left by becoming the chat itself: the handle resolves + * to a user id, which is enough to open the conversation whether or not it exists yet + * ([com.flipcash.app.core.chat.ChatIdentifier.ByUser]). The chat replaces this screen rather than + * stacking on it, so backing out of the chat lands on the Chats list — the entry field has done its + * job by then, and re-showing it would put a screen between the chat and the list it belongs to. + */ +@Composable +fun NewChatScreen() { + val navigator = LocalCodeNavigator.current + val keyboard = rememberKeyboardController() + + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + // fillMaxSize + weight, rather than letting the scaffold size itself: the scaffold fills what + // it is given, so stacking it under the app bar in a wrap-height column makes the column taller + // than the screen and clips the "Next" bar off the bottom. + Column(modifier = Modifier.fillMaxSize()) { + AppBarWithTitle( + onBackIconClicked = { keyboard.hideIfVisible { navigator.pop() } }, + ) + NewChatScreenContent(state, viewModel::dispatchEvent) + } + + LaunchedEffect(viewModel, navigator) { + viewModel.eventFlow + .filterIsInstance() + .onEach { navigator.replace(AppRoute.Messaging.Chat(it.identifier)) } + .launchIn(this) + } +} + +@Composable +private fun ColumnScope.NewChatScreenContent( + state: NewChatViewModel.State, + dispatchEvent: (NewChatViewModel.Event) -> Unit, +) { + val keyboard = rememberKeyboardController() + CodeScaffold( + modifier = Modifier + .weight(1f) + .padding(horizontal = CodeTheme.dimens.inset), + topBar = { + Text( + modifier = Modifier + .fillMaxWidth(0.7f) + .padding( + top = CodeTheme.dimens.grid.x2, + bottom = CodeTheme.dimens.inset, + ), + text = stringResource(R.string.title_newChat), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + }, + bottomBar = { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding( + top = CodeTheme.dimens.grid.x6, + bottom = CodeTheme.dimens.grid.x3, + ).imePadding(), + text = stringResource(R.string.action_next), + enabled = state.hasUsername && state.processingState.isIdle, + isLoading = state.processingState.loading, + isSuccess = state.processingState.success, + onClick = { + keyboard.hideIfVisible { + dispatchEvent(NewChatViewModel.Event.LookupUsername) + } + }, + ) + } + ) { padding -> + val focusRequester = remember { FocusRequester() } + // Padding on the wrapper rather than the field — on the field it inflates the box and drops + // the sublabel away from the entered text (see UsernameEntryScreen). + Column(modifier = Modifier.padding(padding)) { + DisplayTextInput( + state = state.usernameFieldState, + placeholder = stringResource(R.string.hint_username), + sublabel = stringResource(R.string.subtitle_newChat), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + keyboardOptions = KeyboardOptions( + // A handle is lowercase and unspaced; autocapitalizing it would only produce + // input the transformation below has to undo. + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + ), + onKeyboardAction = { + keyboard.hideIfVisible { + dispatchEvent(NewChatViewModel.Event.LookupUsername) + } + }, + inputTransformation = HandleInputTransformation, + ) + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } +} + +/** + * Holds the field to the same `^[a-z0-9_]+$` charset the claim screen enforces, so a handle pasted + * out of a bio or a message — `@fred_wilson`, `Fred_Wilson` — is looked up as the server stores it + * instead of missing. + * + * Length is clamped to the server's 15, unlike the claim screen, which lets the user over-type so + * it can tell them the handle is too long. Nobody is choosing a handle here, so a longer one is + * only a typo or an over-eager paste — and left unclamped it fails request validation, which + * surfaces as "Something Went Wrong" rather than anything the user can act on. + */ +private val HandleInputTransformation = InputTransformation { + val current = asCharSequence().toString() + val sanitized = current.lowercase() + .filter { it in 'a'..'z' || it in '0'..'9' || it == '_' } + .take(MaxHandleLength) + if (sanitized != current) { + replace(0, length, sanitized) + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipsScreen.kt index 438447a31f..50361e2b86 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipsScreen.kt @@ -37,6 +37,7 @@ import com.flipcash.shared.chat.ui.ConversationReference import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.flowSharedViewModel import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.theme.CodeScaffold @@ -56,6 +57,12 @@ fun TipsScreen() { AppBarWithTitle( title = stringResource(R.string.title_chats), titleTextStyle = CodeTheme.typography.screenTitleLarge, + // Node 9442:5779 — the only way to start a chat with someone who has never paid + // you. A pushed route, not a step of this flow: this list is a tab home, and a step + // pushed inside it would leave the tab bar over the entry screen. + endContent = { + AppBarDefaults.Add { navigator.push(AppRoute.Messaging.NewChat) } + }, ) } ) { padding -> diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/NewChatViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/NewChatViewModel.kt new file mode 100644 index 0000000000..c94bbce2ce --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/NewChatViewModel.kt @@ -0,0 +1,152 @@ +package com.flipcash.app.tipping.internal + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.lifecycle.viewModelScope +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.extensions.onResult +import com.flipcash.features.tipping.R +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.GetUserProfileError +import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import com.getcode.view.LoadingSuccessState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +/** + * The server's handle bounds — `^[a-z0-9_]{2,15}$`, the same pattern the claim screen writes against. + * Enforced as the field is typed so a lookup can't fail request validation, which surfaces only as a + * generic error. + */ +internal const val MinHandleLength = 2 +internal const val MaxHandleLength = 15 + +/** + * Turning a typed `@handle` into an openable chat. + * + * One round trip: the profile fetch answers with the user's id, which is all the chat needs — the + * canonical tip-DM id is derived from it, so the conversation opens whether or not it exists yet. + * + * A handle nobody has claimed is informational, not an error: the user typed it and can retype it. + * Only a failed lookup is ours to apologise for. + */ +@HiltViewModel +internal class NewChatViewModel @Inject constructor( + private val profileController: ProfileController, + private val userManager: UserManager, + private val resources: ResourceHelper, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, +) { + data class State( + val usernameFieldState: TextFieldState = TextFieldState(), + val processingState: LoadingSuccessState = LoadingSuccessState(), + ) { + /** + * Whether the field holds something the server will accept as a handle at all. + * + * Gates on length rather than emptiness: the request is validated against + * [MinHandleLength]..[MaxHandleLength] before it is sent, and a rejection there comes back as + * a generic failure that says nothing about the handle. A disabled button says the same + * thing without the dead end. + */ + val hasUsername: Boolean + get() = usernameFieldState.text.length >= MinHandleLength + } + + sealed interface Event { + /** "Next", or the keyboard's Done — look the typed handle up. */ + data object LookupUsername : Event + data class UpdateProcessingState( + val loading: Boolean = false, + val success: Boolean = false, + ) : Event + + /** The handle resolved; [identifier] is what the chat route opens on. */ + data class UserResolved(val identifier: ChatIdentifier.ByUser) : Event + } + + init { + eventFlow + .filterIsInstance() + .onEach { dispatchEvent(Event.UpdateProcessingState(loading = true)) } + .map { stateFlow.value.usernameFieldState.text.toString().trim() } + .map { username -> resolve(username) } + .onResult( + onSuccess = { identifier -> + dispatchSuccessThen(Event.UpdateProcessingState(success = true)) { + dispatchEvent(Event.UserResolved(identifier)) + dispatchEvent(Event.UpdateProcessingState()) + } + }, + onError = { cause -> + dispatchEvent(Event.UpdateProcessingState()) + announceUnresolvable(cause) + }, + ).launchIn(viewModelScope) + } + + private suspend fun resolve(username: String): Result = + profileController.getProfileForUsername(username) + .mapCatching { profile -> + // A profile with no id can't be chatted with, and is indistinguishable to the user + // from a handle that doesn't exist — so it reads as one. + val userId = profile.userId ?: throw GetUserProfileError.NotFound() + if (userId == userManager.accountId) throw OwnHandle(username) + ChatIdentifier.ByUser(userId, profile) + } + + private fun announceUnresolvable(cause: Throwable) { + when (cause) { + is OwnHandle -> BottomBarManager.showInfo( + title = resources.getString(R.string.error_title_newChatOwnUsername), + message = resources.getString(R.string.error_description_newChatOwnUsername), + ) + + is GetUserProfileError.NotFound -> BottomBarManager.showInfo( + title = resources.getString(R.string.error_title_newChatUsernameNotFound), + message = resources.getString(R.string.error_description_newChatUsernameNotFound), + ) + + else -> BottomBarManager.showError( + title = resources.getString(R.string.error_title_usernameCheckFailed), + message = resources.getString(R.string.error_description_usernameCheckFailed), + ) + } + } + + /** + * The typed handle is the signed-in account's own. + * + * Checked on the id off the wire rather than on the handle, for the reason + * `TippingCoordinator.resolveTipCard` gives: a handle comparison answers "not me" for the whole + * window between signing in and this account's own profile arriving. + */ + private class OwnHandle(username: String) : + IllegalStateException("@$username is the signed-in account's own handle") + + companion object { + private val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + Event.LookupUsername -> { state -> state } + is Event.UpdateProcessingState -> { state -> + state.copy( + processingState = state.processingState.copy( + loading = event.loading, + success = event.success, + ) + ) + } + + is Event.UserResolved -> { state -> state } + } + } + } +} diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt index be4651f45f..991ecb0a58 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.Logout +import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.rounded.RestorePage @@ -85,6 +86,24 @@ object AppBarDefaults { } } + /** Trailing "+" — a title bar's create action (e.g. starting a new chat). */ + @Composable + fun Add(modifier: Modifier = Modifier, hazeState: HazeState? = null, onClick: () -> Unit) { + CircularIconButton( + modifier = modifier, + hazeState = hazeState, + onClick = onClick, + testTag = "action_add" + ) { size -> + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = "", + tint = Color.White, + modifier = Modifier.requiredSize(size), + ) + } + } + @Composable fun Share(modifier: Modifier = Modifier, hazeState: HazeState? = null, onClick: () -> Unit) { CircularIconButton( From c27017d86c9b5e0c5b6ba89754c8052269e99249 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 15:37:02 -0400 Subject: [PATCH 3/6] fix(chat): hide the keyboard before leaving a conversation Leaving a chat popped the screen without dismissing the IME first, so the keyboard collapsed over the pop instead of ahead of it. Every way out converges on the flow host's `onExit`: the top bar's up control pops the inner navigator, which at the flow root reaches `onRootReached`, and system back arrives there too. Hiding there covers all of them, and `hideIfVisible` is a no-op when nothing had focus. --- .../kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 0caf5e4703..5daf6e3267 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -44,11 +44,16 @@ fun ChatFlowScreen( resultStateRegistry: NavResultStateRegistry, ) { val navigator = LocalCodeNavigator.current + val keyboard = rememberKeyboardController() FlowHost( initialStack = route.rememberInitialStack(), resultStateRegistry = resultStateRegistry, - onExit = { _, _ -> navigator.pop() }, + // Put the keyboard away before the chat leaves. Every way out of the conversation lands + // here — the top bar's up control pops the inner navigator, which at the flow root reaches + // onRootReached, and so does system back — so this is the one place that has to do it. + // Popping with the IME still up drags the screen behind it out from under the keyboard. + onExit = { _, _ -> keyboard.hideIfVisible { navigator.pop() } }, entryProvider = chatEntryProvider(route.identifier, route.openKeyboard), ) } From 7d911e4cf5a178d228df86ca9fa1f3689ba686d8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 15:37:08 -0400 Subject: [PATCH 4/6] fix(chat): make the "Send Tip" call to action send a tip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tip DM shows one full-width "Send Tip" button until its first payment unlocks typing. That payment went out as `TipDmPayment.Location.CHAT`, which the server reads as the verb "Sent" — so a button that said tip titled itself "Sent" in the recipient's activity feed, and reported `Sent Cash`. Send `TIPCARD` from that call to action and report `Sent Tip` with it. `Location` has two values and the server treats them as the verb rather than as a place, so `TIPCARD` is the only way to ask for a tip. Every other send from this screen is unaffected: the money button beside the composer only exists once the thread is unlocked, which is exactly when the call to action is gone. The message bubble stops splitting the received side — "You received a tip" collapses into "You received" for both verbs. The sender still reads "You tipped" or "You sent"; which button they pressed isn't the recipient's to be told. --- .../core/src/main/res/values/strings.xml | 1 - .../app/messenger/internal/ChatViewModel.kt | 26 ++++++++++++++----- .../flipcash/shared/chat/ui/MessageBubble.kt | 13 ++++++---- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 39517a9e92..00ea96e45d 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -880,7 +880,6 @@ You tipped You received You sent - You received a tip %1$s of %2$s You sent %1$s You received %1$s diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 13fdc5f96a..f62000ffa4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -699,6 +699,19 @@ internal class ChatViewModel @Inject constructor( } val chatId = stateFlow.value.chatId + + // A tip DM's first payment comes from the "Send Tip" call to action, which is + // the whole bottom bar until that payment unlocks typing. It says tip, so it + // sends one. Every later send comes from the money button beside a composer + // that only exists once the thread is unlocked, and stays a plain send. + // + // `TIPCARD` is how a tip is asked for: `TipDmPayment.Location` has two values, + // and the server reads them as the verb ("Tipped" vs "Sent") rather than as a + // place. Sending `CHAT` here would title the payment "Sent" in the recipient's + // activity feed, under a button that promised a tip. + val isTip = stateFlow.value.chatType == ChatType.TIP_DM && + !stateFlow.value.typingConstraints.enabled + val result = when (val participant = stateFlow.value.participant) { is ChatParticipant.Contact -> contactPaymentDelegate.send( contact = participant.contact, @@ -712,7 +725,7 @@ internal class ChatViewModel @Inject constructor( verifiedFiat = verifiedFiat, token = token, source = source, - origin = TipOrigin.CHAT, + origin = if (isTip) TipOrigin.TIPCARD else TipOrigin.CHAT, ) null -> { dispatchEvent(Event.SendStateUpdated()) @@ -720,12 +733,11 @@ internal class ChatViewModel @Inject constructor( } } - // Only a tip card payment is a tip — the same line the activity feed - // draws, from `ChatMetadata.TipDmPayment.Location`. Every send from this - // screen is `CHAT`, whether the peer is a contact or a tip user, so it - // reports as a plain cash send. `Sent Tip` is left to the tip card flow - // in `TippingCoordinator`. - val transferEvent = Analytics.Transfer.SentCash + // Report what was sent, on the same line `TipDmPayment.Location` draws: the + // tip call to action above is a tip, and every other send from this screen — + // contact DM or unlocked tip DM — is a plain cash send. + val transferEvent = + if (isTip) Analytics.Transfer.SentTip else Analytics.Transfer.SentCash result.onSuccess { dispatchEvent(Event.SendStateUpdated(success = true)) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 2f8b3dff06..26a4622610 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -209,11 +209,14 @@ private fun CashBubble( .padding(top = CodeTheme.dimens.grid.x5, bottom = CodeTheme.dimens.grid.x8), horizontalAlignment = Alignment.CenterHorizontally, ) { - val subtitleRes = when (action) { - MessageContent.Cash.Action.TIPPED -> - if (isFromSelf) R.string.subtitle_youTipped else R.string.subtitle_youReceivedTip - MessageContent.Cash.Action.SENT -> - if (isFromSelf) R.string.subtitle_youSent else R.string.subtitle_youReceived + // The verb splits the two only on the sending side. A recipient reads + // "You received" either way: the money that arrived is the same money, and which + // button the sender pressed to send it isn't something the thread needs to relay. + val subtitleRes = if (!isFromSelf) { + R.string.subtitle_youReceived + } else when (action) { + MessageContent.Cash.Action.TIPPED -> R.string.subtitle_youTipped + MessageContent.Cash.Action.SENT -> R.string.subtitle_youSent } Text( text = stringResource(subtitleRes), From 0b0586dfe88245859170a2e82495f227d8f7c873 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 15:52:03 -0400 Subject: [PATCH 5/6] feat(chat): present in-chat amount entry as a sheet Sending cash from a conversation pushed a full-screen amount entry over the thread, so the conversation you were sending into disappeared while you typed the amount. Marking `ChatStep.AmountEntry` as a `Sheet` and giving the chat flow the modal sheet scene strategy keeps the thread visible behind it, the same mechanism `SwapFlowScreen` already uses for its currency pickers. The app bar's leading up-arrow becomes a trailing close, matching `TipAmountEntryScreen`: dismissing drops the amount rather than stepping back. --- .../kotlin/com/flipcash/app/core/chat/ChatStep.kt | 11 ++++++++++- .../com/flipcash/app/messenger/ChatFlowScreen.kt | 11 +++++++++++ .../internal/screens/cash/ChatAmountEntryScreen.kt | 7 +++++-- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index 2f798bf8bf..b5a11e8ee0 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -1,6 +1,7 @@ package com.flipcash.app.core.chat import android.os.Parcelable +import com.getcode.navigation.Sheet import com.getcode.navigation.flow.FlowStep import com.getcode.navigation.results.NavigationRetVal import kotlinx.parcelize.Parcelize @@ -16,9 +17,17 @@ sealed interface ChatStep : FlowStep, Parcelable { @Serializable data object Conversation : ChatStep + /** + * Amount entry for an in-chat send, presented as a bottom sheet over the conversation. + * + * A [Sheet] rather than a pushed step so the thread stays on screen behind it — the amount is + * being sent to the conversation you can still see, and dismissing it returns you to the + * message you were part-way through. The chat's [com.getcode.navigation.flow.FlowHost] runs + * the sheet scene strategy for this; see `ChatFlowScreen`. + */ @Parcelize @Serializable - data object AmountEntry : ChatStep, NavigationRetVal + data object AmountEntry : ChatStep, NavigationRetVal, Sheet @Parcelize @Serializable diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 5daf6e3267..1e70a9f9c8 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.scene.SinglePaneSceneStrategy import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatParticipant @@ -33,6 +34,7 @@ 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 com.getcode.navigation.scenes.ModalBottomSheetSceneStrategy import com.getcode.ui.utils.rememberKeyboardController import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn @@ -55,6 +57,15 @@ fun ChatFlowScreen( // Popping with the IME still up drags the screen behind it out from under the keyboard. onExit = { _, _ -> keyboard.hideIfVisible { navigator.pop() } }, entryProvider = chatEntryProvider(route.identifier, route.openKeyboard), + // ChatStep.AmountEntry is a Sheet, so the flow needs the sheet strategy to draw it as one; + // without it the step would fall through to SinglePane and cover the thread. Amount entry + // returns its result inside the flow (resultBackNavigator), so the strategy's own + // dismiss-delivers-Canceled path has nothing to address here — hence the null key. A + // swipe-dismiss just leaves the pending callback unclaimed, which is what a cancel means. + sceneStrategies = listOf( + ModalBottomSheetSceneStrategy(navigator.resultStore) { null }, + SinglePaneSceneStrategy(), + ), ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt index da83e3ea6e..00c0c689fb 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt @@ -76,8 +76,11 @@ internal fun ChatAmountEntryContent( ) } }, - leftIcon = { - AppBarDefaults.UpNavigation { onExit() } + // A close X, not an up arrow: this is a sheet over the conversation, and dismissing + // it drops the amount rather than stepping back to a previous screen. Matches the + // other sheet-presented amount entry, TipAmountEntryScreen. + rightContents = { + AppBarDefaults.Close { onExit() } }, ) }, From 29bf0ca54e0004fa4bca2150dd08a478589894e8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 16:01:32 -0400 Subject: [PATCH 6/6] fix(chat): animate the amount entry sheet closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The close control and a completed send both popped the AmountEntry entry outright, which deletes the sheet scene on the spot — the sheet disappeared instead of sliding down, while a swipe or system back animated normally. Both now go through the scene's own dismissal, which settles the sheet at Hidden and pops the entry on completion. `ResultBackNavigator` gains an optional `exit` for this: it delivers the result first, then hands the exit to the screen instead of popping. The default is unchanged. --- .../com/flipcash/app/messenger/ChatFlowScreen.kt | 11 ++++++++--- .../com/getcode/navigation/results/NavResultStore.kt | 11 ++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 1e70a9f9c8..552a258772 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -33,6 +33,7 @@ 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.LocalBottomSheetDismissDispatcher import com.getcode.navigation.scenes.LocalSheetNavigator import com.getcode.navigation.scenes.ModalBottomSheetSceneStrategy import com.getcode.ui.utils.rememberKeyboardController @@ -151,8 +152,12 @@ private fun FlowConversationScreen(identifier: ChatIdentifier, openKeyboard: Boo private fun FlowAmountEntryScreen() { val viewModel = flowSharedViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val navigator = LocalCodeNavigator.current - val resultBack = resultBackNavigator() + // Every way out of this step goes through the sheet's own dismissal, which animates it down to + // Hidden and pops the entry once it settles. Popping the entry directly — navigateBack, or the + // pop ResultBackNavigator does by default — deletes the scene mid-frame, so the sheet vanishes + // instead of closing. + val dismissSheet = LocalBottomSheetDismissDispatcher.current + val resultBack = resultBackNavigator(exit = dismissSheet) // No re-shadow: ChatAmountEntryContent reads the inner LocalCodeNavigator, and its // navigator.push(AppRoute.Main.RegionSelection) / push(AppRoute.Sheets.TokenSelection) @@ -164,7 +169,7 @@ private fun FlowAmountEntryScreen() { eventFlow = viewModel.eventFlow, onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnConfirmRequested) }, onSendComplete = { resultBack.returnValue(ChatSendResult) }, // intra-flow result -> Conversation - onExit = { navigator.navigateBack() }, // pop the AmountEntry step + onExit = dismissSheet, ) } diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResultStore.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResultStore.kt index ce6c8170cb..f7c82f0a8f 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResultStore.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/results/NavResultStore.kt @@ -312,11 +312,13 @@ inline fun resultBackNavigator( @Suppress("UNCHECKED_CAST") route: NavigationRetVal? = LocalNavKey.current as? NavigationRetVal, navigator: CodeNavigator = LocalCodeNavigator.current, navResultStore: NavResultStore = LocalCodeNavigator.current.resultStore, + noinline exit: (() -> Unit)? = null, ): IResultBackNavigator = route?.let { ResultBackNavigator( navResultKey = route.asKey(), navigator = navigator, navResultStore = navResultStore, + exit = exit, ) } ?: run { trace("ResultBackNavigator: Returning default object because route was not a RetVal") @@ -345,10 +347,17 @@ interface IResultBackNavigator { fun returnNoValue() = returnCanceled() } +/** + * @param exit How to leave the screen once the result has been delivered. Defaults to popping the + * entry outright. A screen that animates itself away — a sheet has to settle at Hidden before its + * entry is popped — passes its own dismissal here; an immediate pop would cut that short. The + * result is always delivered first, so the exit can pop on its own schedule. + */ class ResultBackNavigator( private val navResultKey: NavResultKey, T>, private val navigator: CodeNavigator, private val navResultStore: NavResultStore, + private val exit: (() -> Unit)? = null, ) : IResultBackNavigator { private fun returnResult(value: NavResultOrCanceled) { val callerId = navigator.backStack.getOrNull(navigator.backStack.lastIndex - 1) @@ -362,7 +371,7 @@ class ResultBackNavigator( } else { trace("No caller to deliver result to; result will be dropped", type = TraceType.Silent) } - navigator.navigateBack(navigatingForResult = true) + exit?.invoke() ?: navigator.navigateBack(navigatingForResult = true) } override fun returnValue(value: T) {