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/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/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 4d0c0d0325..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 @@ -901,7 +900,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/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 0caf5e4703..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 @@ -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 @@ -32,7 +33,9 @@ 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 import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn @@ -44,12 +47,26 @@ 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), + // 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(), + ), ) } @@ -135,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) @@ -148,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/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..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 @@ -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) @@ -690,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, @@ -703,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()) @@ -711,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)) @@ -841,6 +862,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/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() } }, ) }, 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/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), 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( 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) { 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,