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
@@ -0,0 +1,26 @@
package com.flipcash.app.inject

import com.flipcash.shared.chat.ChatCoordinator
import com.flipcash.shared.chat.ChatHydration
import com.flipcash.shared.transactionhistory.TransactionRouter
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent

@Module
@InstallIn(SingletonComponent::class)
object TransactionRouterModule {
/**
* A send-back's activity row opens its DM only when the chat is stored or the server still has
* it. An absent or unreachable chat opens the details instead.
*/
@Provides
fun providesTransactionRouter(chats: ChatCoordinator): TransactionRouter =
TransactionRouter.forChats { chatId ->
when (chats.hydrateChat(chatId)) {
ChatHydration.Stored, is ChatHydration.Fetched -> true
else -> false
}
}
}
17 changes: 17 additions & 0 deletions apps/flipcash/core/src/androidMain/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2064,6 +2064,23 @@
<item quantity="other">You and %1$d others received</item>
</plurals>
<string name="label_cashFrom">from %1$s</string>
<!-- Send Back: a DM's opening payment returned to its sender. The return's bubble, its sender's view. -->
<string name="subtitle_youSentBack">You sent back</string>
<!-- The return's bubble, read by the person it was returned to; also their activity row title. -->
<string name="subtitle_senderSentBack">%1$s sent back</string>
<!-- The return's foot line, read by the person it was returned to. Capital Y on return bubbles only. -->
<string name="label_cashReturnToYou">to You</string>
<!-- The action row under an opening payment its receiver can return. -->
<string name="action_sendBack">Send Back</string>
<!-- The footer under an opening payment once it has been returned. -->
<string name="label_sentBack">Sent Back</string>
<!-- The Send Back confirm sheet, under the currency. -->
<string name="label_sendBackTo">Send back to %1$s</string>
<string name="action_swipeToSendBack">Swipe to Send Back</string>
<string name="error_title_couldntSendBack">Couldn\'t Send Back</string>
<string name="error_description_couldntSendBack">Something went wrong. Please try again.</string>
<!-- Activity row title for a payment the viewer returned. -->
<string name="title_activitySentBackTo">Sent back to %1$s</string>
<!-- What a cash bubble calls a sender or recipient whose name has not resolved. -->
<string name="label_cashSomeone">Someone</string>
<string name="label_cashDetails_sent">Sent</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.flipcash.app.userflags.UserFlagsCoordinator
import com.flipcash.libs.coroutines.DispatcherProvider
import com.flipcash.services.user.UserManager
import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator
import com.flipcash.shared.transactionhistory.TransactionRouter
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

Expand All @@ -22,6 +23,7 @@ internal class HiltWalletViewModel @Inject constructor(
feedCoordinator: ActivityFeedCoordinator,
tokenCoordinator: TokenCoordinator,
walletReveal: WalletRevealCoordinator,
transactionRouter: TransactionRouter,
) : WalletViewModel(
userManager = userManager,
userFlags = userFlags,
Expand All @@ -31,4 +33,5 @@ internal class HiltWalletViewModel @Inject constructor(
feedCoordinator = feedCoordinator,
tokenCoordinator = tokenCoordinator,
walletReveal = walletReveal,
transactionRouter = transactionRouter,
)
Original file line number Diff line number Diff line change
Expand Up @@ -288,9 +288,7 @@ internal fun WalletScreenContent(
}
.padding(top = grid.x4, bottom = grid.x1),
onItemClick = { item ->
dispatchEvent(
WalletViewModel.Event.OpenScreen(AppRoute.Sheets.TransactionDetails(item.messageId))
)
dispatchEvent(WalletViewModel.Event.OpenTransaction(item))
},
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.flipcash.app.core.AppRoute
import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator
import com.flipcash.shared.transactionhistory.FeedSyncState
import com.flipcash.shared.transactionhistory.TransactionListItem
import com.flipcash.shared.transactionhistory.TransactionRouter
import com.flipcash.app.funding.PurchaseMethodController
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.app.tokens.WalletReveal
Expand Down Expand Up @@ -40,6 +41,7 @@ open class WalletViewModel @Inject constructor(
feedCoordinator: ActivityFeedCoordinator,
tokenCoordinator: TokenCoordinator,
walletReveal: WalletRevealCoordinator,
transactionRouter: TransactionRouter = TransactionRouter.DetailsOnly,
) : BaseViewModel<WalletViewModel.State, WalletViewModel.Event>(
initialState = State(),
updateStateForEvent = updateStateForEvent,
Expand Down Expand Up @@ -117,6 +119,7 @@ open class WalletViewModel @Inject constructor(
data object OpenCurrencySelection : Event

data class OpenScreen(val screen: AppRoute) : Event
data class OpenTransaction(val item: TransactionListItem) : Event
data object PresentDepositOptions: Event
}

Expand Down Expand Up @@ -151,6 +154,11 @@ open class WalletViewModel @Inject constructor(
.onEach { provider -> dispatchEvent(Event.OnPreferredOnRampProviderChanged(provider)) }
.launchIn(viewModelScope)

eventFlow
.filterIsInstance<Event.OpenTransaction>()
.onEach { dispatchEvent(Event.OpenScreen(transactionRouter.routeFor(it.item))) }
.launchIn(viewModelScope)

eventFlow
.filterIsInstance<Event.PresentDepositOptions>()
.mapNotNull {
Expand Down Expand Up @@ -211,6 +219,7 @@ open class WalletViewModel @Inject constructor(
}
Event.PresentDepositOptions -> { state -> state }
is Event.OpenScreen -> { state -> state }
is Event.OpenTransaction -> { state -> state }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ import com.flipcash.shared.payments.ContactPaymentDelegate
import com.flipcash.shared.payments.group.GroupSendInsufficientBalance
import com.flipcash.shared.payments.group.GroupSendPaymentDelegate
import com.flipcash.shared.payments.TipPaymentDelegate
import com.flipcash.shared.payments.sendback.SendBackPaymentDelegate
import com.flipcash.services.controllers.ChatMessagingController
import com.flipcash.app.messenger.internal.sendback.SendBackFlow
import com.getcode.libs.emojis.reactions.EmojiCatalogLoader
import com.getcode.libs.emojis.reactions.EmojiDrawability
import com.getcode.libs.emojis.reactions.RecentReactions
Expand Down Expand Up @@ -327,6 +330,9 @@ internal class ChatViewModel @AssistedInject constructor(
// Last and defaulted so the transcript tests, which hold no saved previews, need not supply one.
linkCardMemory: LinkCardMemory? = null,
private val composerLinkPreview: ComposerLinkPreview? = null,
// Defaulted for the same reason: only the Send Back tests supply them.
sendBackPayments: SendBackPaymentDelegate? = null,
chatMessagingController: ChatMessagingController? = null,
) : BaseViewModel<ChatViewModel.State, ChatViewModel.Event>(
initialState = State(
openingAtMessage = openAtMessageId != null,
Expand Down Expand Up @@ -1400,6 +1406,46 @@ internal class ChatViewModel @AssistedInject constructor(
.flatMapLatest { chatCoordinator.observeChatReactions(it) }
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap())

/**
* Send Back: returning a DM's opening payment. The transcript notes each cash message to it as
* it maps them, and its rows join the built pages as an overlay, like [reactionOverlay]. Null
* where its collaborators are not supplied (tests that don't exercise it).
*/
private val sendBack: SendBackFlow? =
if (sendBackPayments != null && chatMessagingController != null) {
SendBackFlow(
scope = viewModelScope,
payments = sendBackPayments,
returnStatus = chatMessagingController::getCashMessageReturnStatus,
payer = startChattingPayer,
tokenCoordinator = tokenCoordinator,
userManager = userManager,
resources = resources,
analytics = analytics,
chatId = { stateFlow.value.chatId },
peerName = { stateFlow.value.participant?.name },
openScreen = { route -> dispatchEvent(Event.OpenScreen(route)) },
)
} else {
null
}

/** The Send Back confirm sheet, or null when it is not up. */
val sendBackSheet: StateFlow<SendBackFlow.Sheet?> =
sendBack?.sheet ?: MutableStateFlow(null)

fun onSendBack(messageId: Long) {
sendBack?.tap(messageId)
}

fun confirmSendBack() {
sendBack?.confirm()
}

fun dismissSendBack() {
sendBack?.dismiss()
}

/**
* Null for a DM, a map for a group — the distinction the transcript needs, because a DM's
* counterparty is already named in the title bar and attributing each of their bubbles would be
Expand Down Expand Up @@ -1708,6 +1754,10 @@ internal class ChatViewModel @AssistedInject constructor(
}
}

(enriched as? MessageContent.Cash)?.let { cash ->
sendBack?.note(message.messageId, cash, message.senderId, message.isFromSelf)
}

ChatListItem.ContentBubble(
messageId = message.messageId,
contentIndex = index,
Expand Down Expand Up @@ -1804,7 +1854,22 @@ internal class ChatViewModel @AssistedInject constructor(
)
}
}
}.cachedIn(pagingScope)
}
// Send Back's rows, the same way: an overlay on the built pages keyed by message id, so a
// status answer or a tap re-maps one field without re-running the pass above.
.let { pages ->
val rows = sendBack?.rows ?: return@let pages
combine(pages, rows) { data, overlay ->
if (overlay.isEmpty()) return@combine data
data.map { item ->
if (item !is ChatListItem.ContentBubble || item.content !is MessageContent.Cash) {
return@map item
}
overlay[item.messageId]?.let { item.copy(sendBack = it) } ?: item
}
}
}
.cachedIn(pagingScope)

/**
* The transcript as the list draws it: [mappedMessages] with date separators and the unread
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import com.flipcash.app.core.links.LocalTrustedWebsites
import com.flipcash.app.core.tokens.TokenInfoEntry
import com.flipcash.app.messenger.internal.ChatSubject
import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.app.messenger.internal.screens.cash.SendBackSheetHost
import com.flipcash.app.messenger.internal.MemberModerationViewModel
import com.flipcash.app.core.chat.ProfileOrigin
import com.flipcash.app.messenger.internal.screens.profile.memberTapStep
Expand Down Expand Up @@ -464,6 +465,9 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
is ChatAction.RefreshReactionIds -> {
viewModel.dispatchEvent(ChatViewModel.Event.RefreshReactionIds(action.messageIds))
}

// Keyboard first, so the confirm sheet or the balance modal rises over a settled chat.
is ChatAction.SendBack -> keyboard.hideIfVisible { viewModel.onSendBack(action.messageId) }
}

Unit
Expand Down Expand Up @@ -796,6 +800,13 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
}
}

val sendBackSheet by viewModel.sendBackSheet.collectAsStateWithLifecycle()
SendBackSheetHost(
sheet = sendBackSheet,
onConfirm = viewModel::confirmSendBack,
onDismiss = viewModel::dismissSendBack,
)

// In-window, over the conversation and the deck, so the bottom bar draws above it.
RequestPaySheet(
pay = requestPayState,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package com.flipcash.app.messenger.internal.screens.cash

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.flipcash.app.core.ui.TokenSelectionPill
import com.flipcash.app.messenger.internal.sendback.SendBackFlow
import com.flipcash.features.messenger.R
import com.getcode.opencode.model.financial.Fiat
import com.getcode.opencode.model.financial.Token
import com.getcode.theme.CodeTheme
import com.getcode.theme.White10
import com.getcode.ui.components.SlideToConfirm

/**
* The Send Back confirm sheet over the chat, held in-screen like the fee sheet. The sheet stays
* composed until it has slid away, so a dismissal from the flow (the swipe, or a balance that
* dropped meanwhile) animates out like a drag does.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun SendBackSheetHost(
sheet: SendBackFlow.Sheet?,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var shown by remember { mutableStateOf<SendBackFlow.Sheet?>(null) }
LaunchedEffect(sheet) {
if (sheet != null) {
shown = sheet
} else if (shown != null) {
sheetState.hide()
shown = null
}
}
val current = shown ?: return
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = CodeTheme.colors.background,
) {
SendBackSheet(
amount = current.amount,
token = current.token,
recipientName = current.recipientName,
onConfirm = onConfirm,
)
}
}

/**
* Returning a DM's opening payment: the original's amount, its currency read-only (the contract
* fixes currency and mint), who it goes back to, and the swipe. [ChatInitPaymentSheet]'s layout
* with the picker taken out.
*/
@Composable
internal fun SendBackSheet(
amount: Fiat,
token: Token?,
recipientName: String?,
onConfirm: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = CodeTheme.dimens.inset)
.padding(top = CodeTheme.dimens.grid.x6, bottom = CodeTheme.dimens.grid.x2),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
) {
Text(
text = amount.formatted(),
style = CodeTheme.typography.displayLarge,
color = CodeTheme.colors.textMain,
)

TokenSelectionPill(
token = token,
background = White10,
textStyle = CodeTheme.typography.textMedium,
imageSize = CodeTheme.dimens.staticGrid.x3,
contentPadding = PaddingValues(
horizontal = CodeTheme.dimens.grid.x2,
vertical = CodeTheme.dimens.grid.x1,
),
showsPicker = false,
onClick = {},
)

Text(
text = stringResource(
R.string.label_sendBackTo,
recipientName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.title_unnamedUser),
),
style = CodeTheme.typography.caption,
color = CodeTheme.colors.textSecondary,
)
}

SlideToConfirm(
onConfirm = onConfirm,
modifier = Modifier.fillMaxWidth(),
label = stringResource(R.string.action_swipeToSendBack),
)
}
}
Loading
Loading