Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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
Expand Down Expand Up @@ -110,6 +111,7 @@ fun appEntryProvider(
annotatedEntry<AppRoute.Messaging.Chat> { key ->
ChatFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
}
annotatedEntry<AppRoute.Messaging.NewChat> { NewChatScreen() }

// Tokens
annotatedEntry<AppRoute.Token.Info>(testTag = "token_info_screen") { key ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,18 @@ sealed interface AppRoute : NavKey, Parcelable {
override val initialStack: List<NavKey>
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -25,4 +28,27 @@ sealed interface ChatIdentifier : Parcelable {
) : ChatIdentifier {
override val key: String get() = contact.e164
}
}

/**
* 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()
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<ChatSendResult>
data object AmountEntry : ChatStep, NavigationRetVal<ChatSendResult>, Sheet

@Parcelize
@Serializable
Expand Down
13 changes: 11 additions & 2 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -880,7 +880,6 @@
<string name="subtitle_youTipped">You tipped</string>
<string name="subtitle_youReceived">You received</string>
<string name="subtitle_youSent">You sent</string>
<string name="subtitle_youReceivedTip">You received a tip</string>
<string name="label_chat_preview_cash_suffix">%1$s of %2$s</string>
<string name="label_chat_preview_sentCash">You sent %1$s</string>
<string name="label_chat_preview_receivedCash">You received %1$s</string>
Expand All @@ -901,7 +900,17 @@
<string name="title_tips">Tips</string>
<string name="title_chats">Chats</string>
<string name="title_noChatsYet">No Chats Yet</string>
<string name="description_noChatsYet">Send a tip or share your Tip Card to start chatting</string>
<string name="description_noChatsYet">Start a new chat, or share your profile</string>

<!-- Starting a chat by @handle from the Chats tab's "+" (nodes 9442:5825 / 9442:103386). -->
<string name="title_newChat">Enter Flipcash username</string>
<string name="subtitle_newChat">Enter the Flipcash username of the person you want to chat with</string>
<!-- Distinct from error_*_usernameNotFound below, which answers a tapped link; here the user
typed the handle themselves and can just correct it. -->
<string name="error_title_newChatUsernameNotFound">Username Not Found</string>
<string name="error_description_newChatUsernameNotFound">Please try a different username</string>
<string name="error_title_newChatOwnUsername">That\'s Your Username</string>
<string name="error_description_newChatOwnUsername">You can\'t start a chat with yourself</string>
<string name="title_tipIntro">Receive Tips From Everyone</string>
<string name="subtitle_tipIntro">Add your name to receive tips</string>
<string name="action_startReceivingTips">Start Receiving Tips</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -44,12 +47,26 @@ fun ChatFlowScreen(
resultStateRegistry: NavResultStateRegistry,
) {
val navigator = LocalCodeNavigator.current
val keyboard = rememberKeyboardController()

FlowHost<ChatStep, Parcelable>(
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(),
),
)
}

Expand Down Expand Up @@ -135,8 +152,12 @@ private fun FlowConversationScreen(identifier: ChatIdentifier, openKeyboard: Boo
private fun FlowAmountEntryScreen() {
val viewModel = flowSharedViewModel<ChatViewModel>()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val navigator = LocalCodeNavigator.current
val resultBack = resultBackNavigator<ChatSendResult>()
// 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<ChatSendResult>(exit = dismissSheet)

// No re-shadow: ChatAmountEntryContent reads the inner LocalCodeNavigator, and its
// navigator.push(AppRoute.Main.RegionSelection) / push(AppRoute.Sheets.TokenSelection)
Expand All @@ -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,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -703,20 +725,19 @@ 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())
return@launch
}
}

// 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))
Expand Down Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
},
)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 $").
Expand Down
Loading
Loading