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 2ed3e1d666..3c8f6c8ad5 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
@@ -3,6 +3,7 @@ package com.flipcash.app.core.chat
import android.os.Parcelable
import com.flipcash.services.models.chat.ChatId
import com.getcode.solana.keys.Mint
+import com.getcode.navigation.FullscreenSheet
import com.getcode.navigation.HalfSheet
import com.getcode.navigation.Sheet
import com.getcode.navigation.WrapContentSheet
@@ -189,6 +190,28 @@ sealed interface ChatStep : FlowStep, Parcelable {
* sheet (node 10330:18091), opened from the pill row's "$". The sheet picks which reaction from
* the viewer's own. Short and fixed, so it wraps its content.
*/
+ /**
+ * Frames 03–06 — a private group's Request Money history, and the create flow behind its New
+ * Request button, on a back stack of its own. Presented as [ShareMessage] is, a full sheet
+ * with a close at its root; its steps are non-draggable, so the close is the only way out.
+ */
+ @Parcelize
+ @Serializable
+ data class RequestMoney(val chatId: ChatId) : ChatStep, Sheet, FullscreenSheet
+
+ /**
+ * Frames 09, 11, 24 and 35 — one payment request in [chatId]. [requestId] is the request's id
+ * in base58, so the step stays a plain parcelable. A full sheet like [RequestMoney], closed
+ * only from its app bar; `ChatFlowScreen` turns the drag-to-dismiss off. [pay] opens it
+ * straight into paying the viewer's share, as the deck's Pay button does.
+ */
+ @Parcelize
+ @Serializable
+ data class RequestDetails(val chatId: ChatId, val requestId: String, val pay: Boolean = false) :
+ ChatStep,
+ Sheet,
+ FullscreenSheet
+
@Parcelize
@Serializable
data class ReactionBoost(
diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index 624ead7119..1eac9b3f3e 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -1014,6 +1014,159 @@
%1$s sent %2$s
%1$s tipped %2$s
Shared a profile
+ Request
+ You sent request for %1$s
+ %1$s sent request for %2$s
+ You sent request
+ %1$s sent request
+ Send Money
+ Send money to someone in the group, or multiple people
+ Request Money
+ Collect money from people in the group
+
+ Request Money
+ New Request
+ Open Requests
+ Previous Requests
+ %1$s paid, %2$s requested
+ ✓ %1$s paid
+ Amount Per Person
+ New Request
+ per person
+ Name
+ What is the request for?
+ People
+ Timing
+
+ - %1$d day left to pay
+ - %1$d days left to pay
+
+
+ - %1$d hour left to pay
+ - %1$d hours left to pay
+
+
+ - %1$d minute left to pay
+ - %1$d minutes left to pay
+
+ Paid in full
+ Closed
+ Expired
+
+
+ - %1$d day left
+ - %1$d days left
+
+
+ - %1$d hour left
+ - %1$d hours left
+
+
+ - %1$d minute left
+ - %1$d minutes left
+
+
+ You owe %1$s
+ ✓ You paid %1$s
+ Your request
+ you paid %1$s
+ you owe %1$s
+ your request
+ Pay
+ Pay %1$s
+ You owe %1$s for %2$s, %3$s
+
+ %1$s of %2$s
+
+ %1$s, %2$s of %3$s paid, %4$s
+
+ %1$s, %2$s, %3$s of %4$s paid, %5$s
+
+ - %1$d request
+ - %1$d requests
+
+ Next request
+
+ %1$s Request
+ Everyone, Including You
+ Everyone Except You
+ Choose People
+
+ - %1$d Person, Including You
+ - %1$d People, Including You
+
+
+ - %1$d Person
+ - %1$d People
+
+ You\'ll be marked as paid
+
+ - %1$d Day To Pay
+ - %1$d Days To Pay
+
+
+ Until %1$s
+ Request automatically closes after
+
+ - Request %2$s from %1$d Person
+ - Request %2$s from %1$d People
+
+ You
+ 100 People Max
+ A request can include up to 100 people.
+ Time To Pay
+ 1 Day
+ 3 Days
+ 1 Week
+ 2 Weeks
+ 4 Weeks
+ Custom
+
+ - %2$s · closes %1$d day after you send
+ - %2$s · closes %1$d days after you send
+
+
+ - Custom · %1$d day
+ - Custom · %1$d days
+
+ Custom · today
+ Starts
+ Today, on send
+ Closes
+ ≈ %1$s
+ Time
+ Select time
+ Type a time
+ Pick a time on the dial
+ OK
+ Send Request?
+ You will receive and hold the money collected
+ Send Request
+ Can\'t Send Request
+ You can\'t request money in this group.
+ Choose a deadline within the next 4 weeks.
+ Someone you included is no longer in the group. Check who\'s included and try again.
+ Requests aren\'t available in this currency yet.
+
+ Request
+ Requested by %1$s
+ Requested by you
+ ✓ Paid
+ Hasn\'t paid yet
+ Didn\'t pay
+
+ %1$s · Hasn\'t paid yet
+ Send %1$s
+ Request options
+ Close
+ Close Request
+ Close Request?
+ This will stop payments. Anyone who hasn\'t paid will be marked Didn\'t pay. Payments already made won\'t be affected.
+ Mark as Paid
+ Mark %1$s as Paid?
+ Use this if %1$s paid you outside Flipcash. No money will be sent. This action cannot be reversed.
+ Request Closed
+ This request is no longer accepting payments.
Nothing yet
Is Typing…
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 91c2db89a2..32ce5b1874 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
@@ -5,12 +5,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.material3.Text
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
@@ -42,6 +44,9 @@ import com.flipcash.app.messenger.internal.screens.ReactionPickerSheet
import com.flipcash.app.messenger.internal.screens.ReactorsSheet
import com.flipcash.app.messenger.internal.screens.ReactorsViewModel
import com.flipcash.app.messenger.internal.screens.ShareToChatsSubject
+import com.flipcash.app.messenger.internal.request.ChatRequestsViewModel
+import com.flipcash.app.messenger.internal.request.RequestDetailsScreen
+import com.flipcash.app.messenger.internal.request.RequestMoneyScreen
import com.flipcash.app.messenger.internal.screens.boost.ReactionBoostAmountViewModel
import com.flipcash.app.messenger.internal.screens.boost.ReactionBoostCurrencySheet
import com.flipcash.app.messenger.internal.screens.boost.ReactionBoostSheet
@@ -82,11 +87,13 @@ import com.getcode.navigation.scenes.LocalSheetNavigator
import com.getcode.navigation.scenes.ModalBottomSheetSceneStrategy
import com.getcode.ui.components.AppBarDefaults
import com.getcode.ui.components.AppBarWithTitle
+import com.getcode.ui.utils.LocalSheetOverhang
import com.getcode.ui.utils.rememberKeyboardController
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import com.getcode.utils.base58
@Composable
fun ChatFlowScreen(
@@ -198,6 +205,13 @@ private fun chatEntryProvider(
val viewModel = flowSharedViewModel()
val dismissSheet = LocalBottomSheetDismissDispatcher.current
val navigator = LocalCodeNavigator.current
+ // Closed by its X only, as Request details is: no flow host to carry NonDraggableRoute.
+ val sheetNavigator = LocalSheetNavigator.current
+ DisposableEffect(sheetNavigator) {
+ val previous = sheetNavigator?.sheetDragDisabled ?: false
+ sheetNavigator?.sheetDragDisabled = true
+ onDispose { sheetNavigator?.sheetDragDisabled = previous }
+ }
GroupSendDetailsSheet(
tokenName = step.tokenName,
tokenImageUrl = step.tokenImageUrl,
@@ -220,6 +234,48 @@ private fun chatEntryProvider(
)
}
+ annotatedEntry { step ->
+ val navigator = LocalCodeNavigator.current
+ val dismissSheet = LocalBottomSheetDismissDispatcher.current
+ RequestMoneyScreen(
+ chatId = step.chatId,
+ resultStateRegistry = resultStateRegistry,
+ onClose = { dismissSheet() },
+ onOpenScreen = { route -> navigator.navigate(route) },
+ )
+ }
+
+ annotatedEntry { step ->
+ val navigator = LocalCodeNavigator.current
+ val dismissSheet = LocalBottomSheetDismissDispatcher.current
+ // No inner flow host to carry a NonDraggableRoute up to the sheet, so turn the drag off
+ // here, as FlowHost would: the app bar's close is the only way out. The flag is the chat
+ // flow's, shared with a Request Money sheet underneath, so put back what was there.
+ val sheetNavigator = LocalSheetNavigator.current
+ DisposableEffect(sheetNavigator) {
+ val previous = sheetNavigator?.sheetDragDisabled ?: false
+ sheetNavigator?.sheetDragDisabled = true
+ onDispose { sheetNavigator?.sheetDragDisabled = previous }
+ }
+ // Opened from the expanded deck, which stays as it was while the sheet rises over it. Once
+ // the sheet is fully up, snap the deck minimized where nobody sees it, so coming back shows
+ // the chat with the deck in the app bar and no collapse playing as the sheet leaves.
+ val requests = flowSharedViewModel()
+ val overhang = LocalSheetOverhang.current
+ LaunchedEffect(requests) {
+ snapshotFlow { overhang() }.first { it > 1f }
+ snapshotFlow { overhang() }.first { it <= 1f }
+ requests.setExpanded(false)
+ }
+ RequestDetailsScreen(
+ chatId = step.chatId,
+ requestId = step.requestId,
+ payOnOpen = step.pay,
+ onBack = { dismissSheet() },
+ onOpenScreen = { route -> navigator.navigate(route) },
+ )
+ }
+
annotatedEntry { step ->
FlowReactionPickerScreen(step.messageId)
}
@@ -278,6 +334,15 @@ private fun FlowConversationScreen(
}
}
+ LaunchedEffect(viewModel) {
+ viewModel.eventFlow
+ .filterIsInstance()
+ .collect {
+ val chatId = viewModel.stateFlow.value.chatId ?: return@collect
+ keyboard.hideIfVisible { navigator.push(ChatStep.RequestMoney(chatId)) }
+ }
+ }
+
LaunchedEffect(viewModel) {
viewModel.eventFlow
.filterIsInstance()
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 6076b7fd2b..39ca1a62a6 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
@@ -24,6 +24,7 @@ import androidx.paging.cachedIn
import androidx.paging.flatMap
import androidx.paging.map
import com.flipcash.app.featureflags.FeatureFlag
+import com.flipcash.app.funding.bottomBarCardAction
import com.flipcash.analytics.CashLinkChoice
import com.flipcash.analytics.GroupAccess as AnalyticsGroupAccess
import com.flipcash.analytics.GroupGateFunding
@@ -1001,6 +1002,8 @@ internal class ChatViewModel @AssistedInject constructor(
/** @param toMembers whether this is a group send, which opens [ChatStep.GroupSendAmountEntry]. */
data class NavigateToAmountEntry(val toMembers: Boolean) : Event
+ /** Request Money history, for a private group. */
+ data object NavigateToRequestMoney : Event
data object PresentDepositOptions : Event
data class OpenScreen(val route: AppRoute, val asSheet: Boolean = false): Event
@@ -1801,6 +1804,8 @@ internal class ChatViewModel @AssistedInject constructor(
when (body.widget) {
is WidgetContent.ShareProfile ->
resources.getString(R.string.label_chat_preview_sharedProfile)
+ is WidgetContent.PaymentRequest ->
+ resources.getString(R.string.label_paymentRequest_card)
WidgetContent.Unsupported -> ""
}
)
@@ -3601,22 +3606,9 @@ internal class ChatViewModel @AssistedInject constructor(
// Event.OnSendRequested).
.filter { stateFlow.value.participant != null || stateFlow.value.chatType == ChatType.GROUP }
.onEach {
- if (!startChattingPayer.mayProceed(
- onAddMoney = { dispatchEvent(Event.PresentDepositOptions) },
- onDiscoverCurrencies = { dispatchEvent(Event.OpenScreen(AppRoute.Token.Discovery, asSheet = true)) },
- )
- ) {
- return@onEach
- }
- val toMembers = stateFlow.value.sendsToMembers
- amountStyleFlow.value = amountStyle(toMembers)
- dispatchEvent(
- Event.GroupSendUpdated(
- GroupSendState(viewerId = userManager.accountId).takeIf { toMembers }
- )
- )
- amountDelegate.reset()
- dispatchEvent(Event.NavigateToAmountEntry(toMembers))
+ // A private group can also ask its members for money, so the button offers both.
+ // Only sending spends anything, so only sending runs the balance gate.
+ if (stateFlow.value.sendsToMembers) presentMoneyActions() else startSend()
}.launchIn(viewModelScope)
observeGroupSend()
@@ -3814,6 +3806,49 @@ internal class ChatViewModel @AssistedInject constructor(
}
}
+ /** Node 12243:1009 — Send Money or Request Money, as cards like the Add Money sheet's. */
+ private fun presentMoneyActions() {
+ // Two rows and nothing else, as the spec lays the sheet out: no title, no Dismiss.
+ BottomBarManager.showMessage(
+ title = "",
+ actions = listOf(
+ bottomBarCardAction(
+ title = resources.getString(R.string.action_sendMoney),
+ subtitle = resources.getString(R.string.subtitle_sendMoney_group),
+ iconRes = R.drawable.ic_menu_withdraw,
+ testTag = "money_action_send",
+ onClick = { viewModelScope.launch { startSend() } },
+ ),
+ bottomBarCardAction(
+ title = resources.getString(R.string.action_requestMoney),
+ subtitle = resources.getString(R.string.subtitle_requestMoney_group),
+ iconRes = R.drawable.ic_menu_deposit,
+ testTag = "money_action_request",
+ onClick = { dispatchEvent(Event.NavigateToRequestMoney) },
+ ),
+ ),
+ )
+ }
+
+ private suspend fun startSend() {
+ if (!startChattingPayer.mayProceed(
+ onAddMoney = { dispatchEvent(Event.PresentDepositOptions) },
+ onDiscoverCurrencies = { dispatchEvent(Event.OpenScreen(AppRoute.Token.Discovery, asSheet = true)) },
+ )
+ ) {
+ return
+ }
+ val toMembers = stateFlow.value.sendsToMembers
+ amountStyleFlow.value = amountStyle(toMembers)
+ dispatchEvent(
+ Event.GroupSendUpdated(
+ GroupSendState(viewerId = userManager.accountId).takeIf { toMembers }
+ )
+ )
+ amountDelegate.reset()
+ dispatchEvent(Event.NavigateToAmountEntry(toMembers))
+ }
+
/** Select People and the confirm sheet of a private group's send. See [GroupSendState]. */
private fun observeGroupSend() {
fun update(transform: (GroupSendState) -> GroupSendState?) {
@@ -4222,6 +4257,7 @@ internal class ChatViewModel @AssistedInject constructor(
state.copy(stagedPhotos = (event.photos + state.stagedPhotos).take(MAX_STAGED_PHOTOS))
}
is Event.NavigateToAmountEntry -> { state -> state.copy(sendProgress = LoadingSuccessState()) }
+ Event.NavigateToRequestMoney -> { state -> state }
is Event.PresentDepositOptions -> { state -> state }
is Event.OpenScreen -> { state -> state }
is Event.MentionTapped -> { state -> state }
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequests.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequests.kt
new file mode 100644
index 0000000000..f9f9a04329
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequests.kt
@@ -0,0 +1,50 @@
+package com.flipcash.app.messenger.internal.request
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.repeatOnLifecycle
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.shared.chat.ui.request.PaymentRequestCards
+import com.getcode.opencode.model.core.ID
+
+/**
+ * The transcript's payment request cards for a group chat, and the poll that keeps them current
+ * while the chat is on screen with the app in the foreground. Null [chatId] (a DM, or a chat still
+ * resolving) polls nothing and draws request widgets as unsupported.
+ */
+@Composable
+internal fun rememberPaymentRequestCards(
+ viewModel: ChatRequestsViewModel,
+ chatId: ChatId?,
+ onOpen: (ChatId, ID) -> Unit,
+): PaymentRequestCards? {
+ val lifecycleOwner = LocalLifecycleOwner.current
+ LaunchedEffect(chatId, lifecycleOwner) {
+ chatId ?: return@LaunchedEffect
+ lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.poll(chatId) }
+ }
+ chatId ?: return null
+ val held by viewModel.held.collectAsStateWithLifecycle()
+ val missing by viewModel.missing.collectAsStateWithLifecycle()
+ val now by viewModel.now.collectAsStateWithLifecycle()
+ val open by rememberUpdatedState(onOpen)
+ return remember(chatId, held, missing, now) {
+ PaymentRequestCards(
+ chatId = chatId,
+ viewerId = viewModel.viewerId,
+ requests = held,
+ missing = missing,
+ now = now,
+ openedAt = viewModel.openedAt,
+ onNeeded = viewModel::fetchIfAbsent,
+ onRefresh = viewModel::refresh,
+ onOpen = { open(chatId, it) },
+ )
+ }
+}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt
new file mode 100644
index 0000000000..e9cab01c02
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt
@@ -0,0 +1,134 @@
+package com.flipcash.app.messenger.internal.request
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.paymentrequest.PaymentRequest
+import com.flipcash.services.models.paymentrequest.deckOrder
+import com.flipcash.services.user.UserManager
+import com.flipcash.shared.chat.PaymentRequests
+import com.getcode.opencode.model.core.ID
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.Instant
+
+/**
+ * A group chat's payment requests as the conversation screen shows them: the cards in the
+ * transcript and the deck in the app bar. Owns the screen's one-minute clock and its 45 s poll.
+ */
+@HiltViewModel
+internal class ChatRequestsViewModel @Inject constructor(
+ private val requests: PaymentRequests,
+ private val userManager: UserManager,
+) : ViewModel() {
+
+ val viewerId: ID? get() = userManager.accountId
+
+ private val chatId = MutableStateFlow(null)
+
+ private val _now = MutableStateFlow(Clock.System.now())
+
+ /** The time for status lines and the ended state, ticking once a minute. */
+ val now: StateFlow = _now.asStateFlow()
+
+ /** When this screen opened, so a card can tell a message that arrived from one scrolled to. */
+ val openedAt: Instant = Clock.System.now()
+
+ /** How many times the deck was swiped since the last refetch. View state only. */
+ private val cycled = MutableStateFlow(0)
+
+ private val _expanded = MutableStateFlow(false)
+ val expanded: StateFlow = _expanded.asStateFlow()
+
+ val held: StateFlow> = requests.state
+ val missing: StateFlow> = requests.missing
+
+ private val _deck = MutableStateFlow>(emptyList())
+
+ /** The deck, front card first. Empty hides it. */
+ val deck: StateFlow> = _deck.asStateFlow()
+
+ /** Requests refetched because a message about them arrived, so a re-shown row doesn't refetch. */
+ private val refreshed = mutableSetOf()
+
+ init {
+ viewModelScope.launch {
+ combine(
+ chatId.flatMapLatest { id -> id?.let { requests.inChat(it) } ?: flowOf(emptyList()) },
+ _now,
+ cycled,
+ ) { inChat, now, cycled -> deckOrder(inChat, now, viewerId).rotated(cycled) }
+ .collect { _deck.value = it }
+ }
+ viewModelScope.launch {
+ while (true) {
+ delay(1.minutes)
+ _now.value = Clock.System.now()
+ }
+ }
+ }
+
+ /**
+ * Points the deck at [chat] and fills it from the requests already held, so a chat opened with
+ * known requests shows its deck on the first frame instead of fading it in.
+ */
+ fun attach(chat: ChatId) {
+ if (chatId.value == chat) return
+ chatId.value = chat
+ val held = requests.state.value.values.filter { it.chatId == chat }
+ _deck.value = deckOrder(held, _now.value, viewerId).rotated(cycled.value)
+ }
+
+ /**
+ * Loads [chat]'s requests now and every 45 s until cancelled. Run it while the chat is on
+ * screen with the app in the foreground; each pass puts the deck back in its default order.
+ */
+ suspend fun poll(chat: ChatId) {
+ chatId.value = chat
+ while (true) {
+ requests.loadChat(chat).onSuccess { cycled.value = 0 }
+ _now.value = Clock.System.now()
+ delay(POLL_INTERVAL)
+ }
+ }
+
+ fun fetchIfAbsent(id: ID) {
+ viewModelScope.launch { requests.fetchIfAbsent(id) }
+ }
+
+ fun refresh(id: ID) {
+ if (!refreshed.add(id)) return
+ viewModelScope.launch { requests.fetch(id) }
+ }
+
+ /** Swipe down on the deck: the front card goes to the back. */
+ fun cycle() {
+ if (deck.value.size > 1) cycled.update { it + 1 }
+ }
+
+ fun setExpanded(expanded: Boolean) {
+ _expanded.value = expanded && deck.value.isNotEmpty()
+ }
+
+ companion object {
+ val POLL_INTERVAL = 45.seconds
+ }
+}
+
+internal fun List.rotated(by: Int): List {
+ if (size < 2) return this
+ val shift = by.mod(size)
+ return drop(shift) + take(shift)
+}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt
new file mode 100644
index 0000000000..0c8e3f3462
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt
@@ -0,0 +1,145 @@
+package com.flipcash.app.messenger.internal.request
+
+import androidx.compose.animation.core.SeekableTransitionState
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animate
+import androidx.compose.animation.core.spring
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.unit.Velocity
+import com.getcode.opencode.model.core.ID
+import kotlinx.coroutines.flow.collectLatest
+
+/**
+ * Where the deck rests when it isn't expanded, and the owed requests it has already shown the strip
+ * for. Once the strip has collapsed into the pill it stays there for the visit, until a request
+ * the viewer owes on arrives that the strip hasn't shown.
+ */
+internal data class DeckRest(
+ val host: DeckPlacement = DeckPlacement.Pill,
+ val seenOwed: Set = emptySet(),
+) {
+ /** The requests the viewer has an unpaid share in, as of now. */
+ fun owing(owed: Set): DeckRest = when {
+ owed.isEmpty() -> DeckRest(DeckPlacement.Pill, seenOwed)
+ (owed - seenOwed).isNotEmpty() -> DeckRest(DeckPlacement.Strip, seenOwed + owed)
+ else -> copy(seenOwed = seenOwed + owed)
+ }
+
+ /** The strip has shrunk all the way into the pill. */
+ fun collapsed(): DeckRest = copy(host = DeckPlacement.Pill)
+}
+
+/**
+ * The one transition the deck's three hosts (the pill, the strip and the expanded list) show and
+ * hide on, so the front card moves between any two of them as one shared element. Scrolling the
+ * timeline away from the newest messages seeks the strip into the pill, following the scroll.
+ */
+@Stable
+internal class DeckHostState(owed: Set) {
+ var rest by mutableStateOf(DeckRest().owing(owed))
+ private set
+
+ // Requests already held when the chat opens put it in place on the first frame.
+ val transition = SeekableTransitionState(rest.host)
+
+ var expanded by mutableStateOf(false)
+
+ /** The strip's full height with its padding, which a scroll of that length collapses. */
+ var stripPx by mutableFloatStateOf(0f)
+
+ private var collapsePx by mutableFloatStateOf(0f)
+ private var snapNext = false
+ private var hadDeck = owed.isNotEmpty()
+
+ /** Partway between the strip and the pill, where a tap shouldn't expand it. */
+ val collapsing: Boolean get() = collapsePx > 0f
+
+ fun owing(owed: Set, hasDeck: Boolean) {
+ val next = rest.owing(owed)
+ // A deck held when the chat opens is in place on the first frame rather than moving there.
+ if (!hadDeck && hasDeck && next.host == DeckPlacement.Strip) snapNext = true
+ if (hasDeck) hadDeck = true
+ if (next.host == DeckPlacement.Pill) collapsePx = 0f
+ rest = next
+ }
+
+ private val target: DeckPlacement
+ get() = if (expanded) DeckPlacement.Expanded else rest.host
+
+ private val following: Boolean
+ get() = !expanded && rest.host == DeckPlacement.Strip &&
+ transition.currentState == DeckPlacement.Strip && stripPx > 0f
+
+ /**
+ * Collapses the strip by however far the timeline moved toward older messages, and opens it
+ * back by however far it moved toward newer ones. The timeline scrolls as it would anyway.
+ * This list is reverseLayout, so toward older is a positive y.
+ */
+ val scrollConnection = object : NestedScrollConnection {
+ override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
+ if (!following) return Offset.Zero
+ // At the newest messages a drag back toward them moves nothing, so count what's left.
+ val moved = consumed.y + minOf(available.y, 0f)
+ collapsePx = (collapsePx + moved).coerceIn(0f, stripPx)
+ if (collapsePx >= stripPx) pin()
+ return Offset.Zero
+ }
+
+ override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
+ settle()
+ return Velocity.Zero
+ }
+ }
+
+ /** Finishes a collapse released partway, toward whichever end is nearer. */
+ private suspend fun settle() {
+ val from = collapsePx
+ if (from <= 0f || stripPx <= 0f) return
+ val to = if (from >= stripPx / 2) stripPx else 0f
+ animate(from, to, animationSpec = SETTLE_SPRING) { value, _ -> collapsePx = value }
+ if (to > 0f) pin()
+ }
+
+ private fun pin() {
+ rest = rest.collapsed()
+ collapsePx = 0f
+ }
+
+ /** Puts the transition where the state says, for as long as the screen is up. */
+ suspend fun run() {
+ snapshotFlow { Triple(target, collapsePx, stripPx) }.collectLatest { (target, px, full) ->
+ when {
+ snapNext -> {
+ snapNext = false
+ transition.snapTo(target)
+ }
+ target == DeckPlacement.Strip && px > 0f && full > 0f &&
+ transition.currentState == DeckPlacement.Strip ->
+ transition.seekTo((px / full).coerceIn(0f, 1f), DeckPlacement.Pill)
+ transition.currentState != target || transition.targetState != target ->
+ transition.animateTo(target)
+ }
+ }
+ }
+}
+
+private val SETTLE_SPRING = spring(stiffness = Spring.StiffnessMediumLow)
+
+/** A [DeckHostState] for one visit to a chat; reopening the chat starts it over. */
+@Composable
+internal fun rememberDeckHostState(chat: Any?, owed: Set): DeckHostState {
+ val state = remember(chat) { DeckHostState(owed) }
+ LaunchedEffect(state) { state.run() }
+ return state
+}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraft.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraft.kt
new file mode 100644
index 0000000000..25b8c357c9
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraft.kt
@@ -0,0 +1,131 @@
+package com.flipcash.app.messenger.internal.request
+
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.ChatMember
+import com.flipcash.services.models.chat.IdempotencyKey
+import com.flipcash.services.models.paymentrequest.NewSplitRequest
+import com.flipcash.services.models.paymentrequest.PaymentRequestText
+import com.getcode.opencode.model.core.ID
+import com.getcode.opencode.model.financial.Fiat
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.days
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Instant
+
+/**
+ * A payment request a creator is putting together in a private group, from Amount Per Person to
+ * the confirm sheet. Frames 04–06 of the group requests spec.
+ *
+ * @param members who Select People lists: the creator first, then the rest of the roster in its
+ * order. Shares are sent in this order, whatever order the boxes were checked in.
+ * @param selected who is asked, the creator included when [viewerId] is in it.
+ * @param idempotencyKey minted when the confirm sheet first opens, and reused by every retry of
+ * that send.
+ * @param createdRequestId set once Create returns OK. From then on only the message send is
+ * retried; Create is never called again for this confirm.
+ */
+internal data class NewRequestDraft(
+ val viewerId: ID,
+ val members: List,
+ val title: String,
+ val perPerson: Fiat? = null,
+ val selected: Set = if (members.size > MAX_SHARES) emptySet() else members.toSet(),
+ val deadline: Deadline = Deadline.Week,
+ val idempotencyKey: IdempotencyKey? = null,
+ val createdRequestId: ID? = null,
+) {
+ /** Members asked other than the creator: the N in "Request $20 from N People". */
+ val requestedCount: Int get() = selected.count { it != viewerId }
+
+ val includesCreator: Boolean get() = viewerId in selected
+
+ /** Select People's button: at least one member other than the creator. */
+ val canContinue: Boolean get() = requestedCount > 0
+
+ val canSend: Boolean get() = canContinue && perPerson != null && title.isNotBlank()
+
+ /** Who is asked, in [members] order. */
+ val shares: List get() = members.filter { it in selected }
+
+ val people: People
+ get() {
+ val others = members.count { it != viewerId }
+ return when {
+ selected.isEmpty() -> People.Choose
+ includesCreator && requestedCount == others -> People.EveryoneIncludingYou
+ !includesCreator && requestedCount == others -> People.EveryoneExceptYou
+ includesCreator -> People.SomeIncludingYou(requestedCount)
+ else -> People.Some(requestedCount)
+ }
+ }
+
+ /** [selected] with [member] checked or unchecked, or null when checking them would make a 101st share. */
+ fun toggled(member: ID): Set? = when {
+ member in selected -> selected.minusElement(member)
+ selected.size >= MAX_SHARES -> null
+ else -> selected.plusElement(member)
+ }
+
+ /** What Create sends, with [now] the moment Send Request was tapped. */
+ fun toSplit(chatId: ChatId, text: PaymentRequestText.Encrypted, now: Instant): NewSplitRequest? {
+ val amount = perPerson ?: return null
+ if (!canContinue) return null
+ return NewSplitRequest(
+ chatId = chatId,
+ text = text,
+ currency = amount.currencyCode,
+ endsAt = deadline.endsAt(now),
+ shares = shares,
+ perPerson = amount,
+ )
+ }
+
+ /** The summary's People value. The second line shows whenever [markedPaid]. */
+ sealed interface People {
+ val markedPaid: Boolean get() = false
+
+ data object Choose : People
+ data object EveryoneIncludingYou : People { override val markedPaid = true }
+ data object EveryoneExceptYou : People
+ data class SomeIncludingYou(val others: Int) : People { override val markedPaid = true }
+ data class Some(val count: Int) : People
+ }
+
+ /** The Time To Pay choice. A preset resolves against the moment Send is tapped, so it never drifts shorter. */
+ sealed interface Deadline {
+ fun endsAt(now: Instant): Instant
+
+ enum class Preset(val length: Duration, val daysToPay: Int) : Deadline {
+ Day(1.days, 1),
+ ThreeDays(3.days, 3),
+ Week(7.days, 7),
+ TwoWeeks(14.days, 14),
+
+ // Five minutes short of the server's limit, so a device clock running slightly ahead still passes.
+ FourWeeks(28.days - 5.minutes, 28);
+
+ override fun endsAt(now: Instant): Instant = now + length
+ }
+
+ data class Custom(val at: Instant) : Deadline {
+ override fun endsAt(now: Instant): Instant = at
+ }
+
+ companion object {
+ val Week: Deadline = Preset.Week
+
+ /** The custom picker's bounds. */
+ fun customRange(now: Instant): ClosedRange = (now + 1.hours)..(now + Preset.FourWeeks.length)
+ }
+ }
+
+ companion object {
+ /** At most this many shares, the creator's included. */
+ const val MAX_SHARES = 100
+
+ /** The creator first, then the rest of [roster] in order, each once. */
+ fun members(viewerId: ID, roster: List): List =
+ listOf(viewerId) + roster.map { it.userId }.filter { it != viewerId }.distinct()
+ }
+}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestCopy.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestCopy.kt
new file mode 100644
index 0000000000..0a0ee96c4c
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestCopy.kt
@@ -0,0 +1,29 @@
+package com.flipcash.app.messenger.internal.request
+
+import com.flipcash.features.messenger.R
+import com.getcode.util.resources.ResourceHelper
+
+// The New Request summary's wording, out of Compose so the spec's cases can pin it.
+
+/** The People row's value. */
+internal fun peopleValue(people: NewRequestDraft.People, resources: ResourceHelper): String = when (people) {
+ NewRequestDraft.People.Choose -> resources.getString(R.string.value_requestPeople_choose)
+ NewRequestDraft.People.EveryoneIncludingYou -> resources.getString(R.string.value_requestPeople_everyoneIncludingYou)
+ NewRequestDraft.People.EveryoneExceptYou -> resources.getString(R.string.value_requestPeople_everyoneExceptYou)
+ is NewRequestDraft.People.SomeIncludingYou ->
+ resources.getQuantityString(R.plurals.value_requestPeople_someIncludingYou, people.others, people.others)
+ is NewRequestDraft.People.Some ->
+ resources.getQuantityString(R.plurals.value_requestPeople_some, people.count, people.count)
+}
+
+/** The People row's second line: only when the creator is included. */
+internal fun peopleDetail(people: NewRequestDraft.People, resources: ResourceHelper): String? =
+ resources.getString(R.string.subtitle_requestMarkedPaid).takeIf { people.markedPaid }
+
+/** The summary's button, "Request from People", the creator not counted. */
+internal fun requestButtonText(draft: NewRequestDraft, amount: String, resources: ResourceHelper): String =
+ resources.getQuantityString(R.plurals.action_requestFromPeople, draft.requestedCount, draft.requestedCount, amount)
+
+/** The sheet for checking a 101st share: title and body. */
+internal fun maxPeopleNotice(resources: ResourceHelper): Pair =
+ resources.getString(R.string.title_requestMaxPeople) to resources.getString(R.string.description_requestMaxPeople)
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt
new file mode 100644
index 0000000000..2d8b3144b2
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt
@@ -0,0 +1,996 @@
+package com.flipcash.app.messenger.internal.request
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionLayout
+import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.scaleToBounds
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.Transition
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandHorizontally
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.shrinkHorizontally
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.gestures.draggable
+import androidx.compose.foundation.gestures.rememberDraggableState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import kotlinx.coroutines.CoroutineScope
+import androidx.compose.ui.unit.constrainWidth
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.Icons
+import com.flipcash.shared.chat.ui.ActionCapsule
+import com.flipcash.shared.chat.ui.ActionCapsuleButton
+import androidx.compose.ui.layout.positionInWindow
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.util.lerp
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.GraphicsLayerScope
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.withStyle
+import com.flipcash.services.models.paymentrequest.StatusLine
+import com.flipcash.services.models.paymentrequest.unpaidShareOf
+import com.flipcash.services.models.paymentrequest.ViewerStanding
+import com.flipcash.services.models.paymentrequest.viewerStanding
+import com.flipcash.shared.chat.ui.request.PaidBadge
+import com.flipcash.shared.chat.ui.request.ViewerStatusLine
+import com.flipcash.shared.chat.ui.request.description
+import androidx.compose.ui.semantics.CustomAccessibilityAction
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.customActions
+import androidx.compose.ui.semantics.onClick
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.flipcash.core.R
+import com.flipcash.services.models.paymentrequest.PaymentRequest
+import com.flipcash.services.models.paymentrequest.PaymentRequestStatus
+import com.flipcash.services.models.paymentrequest.PaymentRequestText
+import com.flipcash.services.models.paymentrequest.statusLine
+import com.flipcash.shared.chat.ui.request.PaymentRequestCardContent
+import com.flipcash.shared.chat.ui.request.requestText
+import com.flipcash.shared.chat.ui.request.text
+import com.getcode.opencode.model.core.ID
+import com.getcode.theme.CodeTheme
+import kotlinx.coroutines.launch
+import kotlin.math.roundToInt
+import kotlin.time.Instant
+
+/**
+ * The shared-element scope the deck pill (in the app bar) and the expanded list (over the
+ * transcript) share, so the front card can move between them. Null outside [RequestDeckLayout].
+ */
+@OptIn(ExperimentalSharedTransitionApi::class)
+private val LocalDeckTransition = compositionLocalOf { null }
+
+/** Hosts the screen so the deck's front card can move between the app bar and the expanded list. */
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Composable
+internal fun RequestDeckLayout(modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
+ SharedTransitionLayout(modifier = modifier) {
+ CompositionLocalProvider(LocalDeckTransition provides this) {
+ Box(content = content)
+ }
+ }
+}
+
+/** A deck request's title, or null for one the deck cannot draw (unsupported or not opened). */
+internal val PaymentRequest.deckTitle: String?
+ get() = (split?.text as? PaymentRequestText.Plain)?.title
+
+private fun sharedKey(id: ID, part: String) = "payment-request-${id.hashCode()}-$part"
+
+/**
+ * Ties one piece of the front card (the container, the title, the ring or the amounts) to its
+ * counterpart in the other state, so each moves on its own rather than fading with the card.
+ * Text scales to its new bounds, since the pill and the card set it in different styles.
+ */
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Composable
+private fun Modifier.deckShared(
+ id: ID,
+ part: String,
+ visibility: AnimatedVisibilityScope?,
+ shape: androidx.compose.ui.graphics.Shape? = null,
+): Modifier {
+ val transition = LocalDeckTransition.current ?: return this
+ if (visibility == null) return this
+ return with(transition) {
+ val state = rememberSharedContentState(sharedKey(id, part))
+ if (shape != null) {
+ this@deckShared.sharedBounds(
+ sharedContentState = state,
+ animatedVisibilityScope = visibility,
+ resizeMode = scaleToBounds(),
+ clipInOverlayDuringTransition = OverlayClip(shape),
+ )
+ } else {
+ this@deckShared.sharedBounds(
+ sharedContentState = state,
+ animatedVisibilityScope = visibility,
+ resizeMode = scaleToBounds(),
+ )
+ }
+ }
+}
+
+/** Where the deck sits and how loud it is. */
+internal enum class DeckPlacement {
+ /** The grey pill in the app bar: the viewer owes nothing, or the strip has collapsed into it. */
+ Pill,
+
+ /** The full-width card under the app bar: the viewer owes. */
+ Strip,
+
+ /** Every request as a card over the transcript. */
+ Expanded,
+}
+
+/**
+ * The app-bar deck (concept B): the front request with up to two cards peeking beneath it. Swipe
+ * down cycles; tap expands. Shown while [transition] is at the pill; the front card moves from
+ * there to the strip and the expanded list as one shared element.
+ */
+@Composable
+internal fun RequestDeckPill(
+ deck: List,
+ now: Instant,
+ viewerId: ID?,
+ transition: Transition,
+ onExpand: () -> Unit,
+ onCycle: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ // Held through the exit, so the pill doesn't empty as it fades out.
+ var shown by remember { mutableStateOf(deck) }
+ if (deck.isNotEmpty()) shown = deck
+ // Arriving and leaving: a fade and a slight scale in place, while the width eases so the title
+ // makes room. A deck that is there when the chat opens starts visible, so it doesn't fade.
+ AnimatedVisibility(
+ visible = deck.isNotEmpty(),
+ enter = fadeIn(tween(APPEAR_MILLIS)) +
+ scaleIn(tween(APPEAR_MILLIS), initialScale = APPEAR_SCALE) +
+ expandHorizontally(tween(APPEAR_MILLIS), expandFrom = Alignment.End, clip = false),
+ exit = fadeOut(tween(APPEAR_MILLIS)) +
+ scaleOut(tween(APPEAR_MILLIS), targetScale = APPEAR_SCALE) +
+ shrinkHorizontally(tween(APPEAR_MILLIS), shrinkTowards = Alignment.End, clip = false),
+ modifier = modifier,
+ ) {
+ DeckStack(
+ deck = shown,
+ now = now,
+ viewerId = viewerId,
+ style = DeckPlacement.Pill,
+ transition = transition,
+ onExpand = onExpand,
+ onCycle = onCycle,
+ onPay = {},
+ )
+ }
+}
+
+/**
+ * The deck as a full-width card under the app bar (concept node 12670:1262), shown while the
+ * viewer owes and the timeline is at its newest messages. It keeps its room while [expanded], so
+ * the timeline doesn't move under the expanded cards, and gives it up as it leaves for the pill.
+ */
+@Composable
+internal fun RequestDeckStrip(
+ deck: List,
+ now: Instant,
+ viewerId: ID?,
+ transition: Transition,
+ rest: DeckPlacement,
+ onExpand: () -> Unit,
+ onCycle: () -> Unit,
+ onPay: (PaymentRequest) -> Unit,
+ onHeightChange: (Int) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var shown by remember { mutableStateOf(deck) }
+ if (deck.isNotEmpty()) shown = deck
+ // Linear, so a scroll that seeks it into the pill takes its height off at the scroll's pace.
+ transition.AnimatedVisibility(
+ visible = {
+ deck.isNotEmpty() &&
+ (it == DeckPlacement.Strip || it == DeckPlacement.Expanded && rest == DeckPlacement.Strip)
+ },
+ enter = expandVertically(tween(STRIP_MILLIS, easing = LinearEasing), expandFrom = Alignment.Top),
+ exit = shrinkVertically(tween(STRIP_MILLIS, easing = LinearEasing), shrinkTowards = Alignment.Top),
+ modifier = modifier,
+ ) {
+ Box(
+ modifier = Modifier
+ // Its whole height, padding included: the scroll that collapses it into the pill.
+ .onSizeChanged { onHeightChange(it.height) }
+ .fillMaxWidth()
+ .padding(horizontal = CodeTheme.dimens.inset)
+ .padding(top = CodeTheme.dimens.grid.x2)
+ .height(DeckStyle.Strip.height + DeckStyle.Strip.step * 2),
+ ) {
+ DeckStack(
+ deck = shown,
+ now = now,
+ viewerId = viewerId,
+ style = DeckPlacement.Strip,
+ transition = transition,
+ onExpand = onExpand,
+ onCycle = onCycle,
+ onPay = onPay,
+ )
+ }
+ }
+}
+
+/** The stack's measurements in each placement. */
+private class DeckStyle(
+ val height: Dp,
+ val shape: RoundedCornerShape,
+ val inset: Dp,
+ val step: Dp,
+) {
+ companion object {
+ val Pill = DeckStyle(height = 43.dp, shape = RoundedCornerShape(18.dp), inset = 7.dp, step = 5.dp)
+ val Strip = DeckStyle(height = 64.dp, shape = RoundedCornerShape(20.dp), inset = 8.dp, step = 6.dp)
+ }
+}
+
+private val DeckPlacement.deckStyle: DeckStyle
+ get() = if (this == DeckPlacement.Strip) DeckStyle.Strip else DeckStyle.Pill
+
+/**
+ * The front request with up to two cards peeking beneath it, in one of the deck's placements.
+ * Swipe down cycles; tap expands. Shown while [transition] is at [style], whose scope its front
+ * card moves between placements in.
+ */
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Composable
+private fun DeckStack(
+ deck: List,
+ now: Instant,
+ viewerId: ID?,
+ style: DeckPlacement,
+ transition: Transition,
+ onExpand: () -> Unit,
+ onCycle: () -> Unit,
+ onPay: (PaymentRequest) -> Unit,
+) {
+ transition.AnimatedVisibility(
+ visible = { it == style },
+ enter = fadeIn(tween(STRIP_MILLIS)),
+ exit = fadeOut(tween(STRIP_MILLIS)),
+ ) {
+ val front = deck.firstOrNull() ?: return@AnimatedVisibility
+ val geometry = style.deckStyle
+ val shape = geometry.shape
+ val strip = style == DeckPlacement.Strip
+ val transition = LocalDeckTransition.current
+ val density = LocalDensity.current
+ val scope = rememberCoroutineScope()
+ val drag = remember { Animatable(0f) }
+ val arrive = remember { Animatable(1f) }
+ // Where the swipe was let go: how far the front card had travelled, and how far through
+ // the swipe that was. The hand-over starts every card from there.
+ var releaseDrag by remember { mutableFloatStateOf(0f) }
+ var releaseProgress by remember { mutableFloatStateOf(1f) }
+ val pillHeightPx = with(density) { geometry.height.toPx() }
+ val canCycle = deck.size > 1
+ val pillWidth = remember(scope) { PillWidth(scope) }
+
+ val label = deckLabel(deck, now, viewerId, strip)
+ val nextLabel = stringResource(R.string.action_nextRequest)
+ // The strip's Pay is a button of its own, so the deck's label goes on the rest of its card.
+ val deckSemantics = Modifier.clearAndSetSemantics {
+ contentDescription = label
+ role = Role.Button
+ onClick { onExpand(); true }
+ if (canCycle) customActions = listOf(CustomAccessibilityAction(nextLabel) { onCycle(); true })
+ }
+ Box(
+ modifier = Modifier
+ .padding(bottom = geometry.step * 2)
+ .then(if (strip) Modifier else deckSemantics),
+ ) {
+ // How far the front card is through being swiped to the back, 0 to 1. The cards behind
+ // rise a slot with it, so the next request is already in place when the swipe lands.
+ val progress = (drag.value / pillHeightPx).coerceIn(0f, 1f)
+ val insetPx = with(density) { geometry.inset.toPx() }
+ val stepPx = with(density) { geometry.step.toPx() }
+ // The card just swiped off, travelling from below the pill to its slot at the back.
+ val swiped = if (canCycle && arrive.value < 1f) deck.last() else null
+ val swipedDepth = (deck.size - 1).coerceAtMost(2).toFloat()
+ // How far each card still has to rise after a hand-over: the swipe was let go short of
+ // a full pill height, so the cards behind finish the step on the same spring.
+ val handover = (1f - releaseProgress) * (1f - arrive.value)
+
+ @Composable
+ fun Card(request: PaymentRequest, layer: GraphicsLayerScope.() -> Unit, depth: Float, content: Boolean) {
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .graphicsLayer(layer)
+ // The request rising from behind is wider or narrower than the pill until
+ // the swipe is let go; it shows clipped to the card until then.
+ .clip(shape)
+ .background(CodeTheme.colors.background, shape)
+ .background(peekColor(style, depth), shape)
+ .border(1.dp, CodeTheme.colors.border, shape),
+ ) {
+ if (content) {
+ DeckCardFront(
+ request = request,
+ now = now,
+ viewerId = viewerId,
+ style = style,
+ visibility = null,
+ onPay = {},
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+ }
+
+ if (swiped != null) {
+ Card(
+ request = swiped,
+ depth = swipedDepth * arrive.value,
+ content = true,
+ layer = {
+ val t = arrive.value
+ val scale = lerp(1f - DROP_SCALE * releaseProgress, slotScale(swipedDepth, insetPx, size.width), t)
+ scaleX = scale
+ scaleY = scale
+ translationY = lerp(releaseDrag, slotY(swipedDepth, scale, stepPx, size.height), t)
+ // Past the second slot it tucks out of sight rather than stacking up.
+ if (deck.size > 3) alpha = 1f - t
+ },
+ )
+ }
+ // The peeking cards, back first. Each is a scaled copy of the front slot, so the one
+ // rising into it grows with its glyph, title and amounts as one piece.
+ val peeks = deck.drop(1).take(3)
+ peeks.withIndex().reversed().forEach { (index, request) ->
+ if (request == swiped) return@forEach
+ val depth = index + 1 - progress + handover
+ // A third card behind only shows as the others move up.
+ if (depth >= 3f) return@forEach
+ Card(
+ request = request,
+ depth = depth,
+ // The card rising to the front shows its request as soon as the front moves.
+ content = index == 0 && drag.value > 0f,
+ layer = {
+ val scale = slotScale(depth, insetPx, size.width)
+ scaleX = scale
+ scaleY = scale
+ translationY = slotY(depth, scale, stepPx, size.height)
+ if (depth > 2f) alpha = 1f - (depth - 2f)
+ },
+ )
+ }
+ DeckCardFront(
+ request = front,
+ now = now,
+ viewerId = viewerId,
+ style = style,
+ visibility = this@AnimatedVisibility,
+ onPay = { onPay(front) },
+ bodyModifier = if (strip) deckSemantics else Modifier,
+ modifier = Modifier
+ .then(if (strip) Modifier.fillMaxWidth() else pillWidth.modifier)
+ .then(
+ if (transition != null) with(transition) {
+ Modifier.sharedBounds(
+ sharedContentState = rememberSharedContentState(sharedKey(front.id, "card")),
+ animatedVisibilityScope = this@AnimatedVisibility,
+ clipInOverlayDuringTransition = OverlayClip(shape),
+ )
+ } else Modifier
+ )
+ .graphicsLayer {
+ // Falls back as it goes, so it hands off at the size the back card starts from.
+ // Just after a hand-over it is still rising out of the slot behind.
+ val scale = (1f - DROP_SCALE * progress) * slotScale(handover, insetPx, size.width)
+ scaleX = scale
+ scaleY = scale
+ translationY = drag.value + slotY(handover, scale, stepPx, size.height)
+ }
+ .draggable(
+ orientation = Orientation.Vertical,
+ state = rememberDraggableState { delta ->
+ scope.launch {
+ val value = drag.value
+ val next = if (!canCycle) {
+ // One request: a small elastic pull and nothing more.
+ (value + delta * 0.3f).coerceIn(0f, pillHeightPx * 0.25f)
+ } else if (delta > 0f && value >= pillHeightPx) {
+ // Past one pill height the card rubber-bands instead of
+ // sliding on into the chat.
+ val over = (value - pillHeightPx) / (pillHeightPx * RUBBER_BAND)
+ value + delta * 0.5f * (1f - over).coerceAtLeast(0f)
+ } else {
+ (value + delta).coerceAtLeast(0f)
+ }
+ drag.snapTo(next)
+ }
+ },
+ onDragStopped = { velocity ->
+ val past = drag.value > pillHeightPx * CYCLE_THRESHOLD || velocity > FLING_VELOCITY
+ if (canCycle && past) {
+ // Hand over on release: the card that rose becomes the front and the
+ // swiped one drops behind to the back, each from where it was, while
+ // the pill eases to the new front's width on the same spring.
+ releaseDrag = drag.value
+ releaseProgress = (drag.value / pillHeightPx).coerceIn(0f, 1f)
+ drag.snapTo(0f)
+ arrive.snapTo(0f)
+ onCycle()
+ arrive.animateTo(1f, DECK_SPRING)
+ } else {
+ drag.animateTo(0f, DECK_SPRING, initialVelocity = velocity)
+ }
+ },
+ )
+ .clip(shape)
+ .clickable(onClick = onExpand),
+ )
+ }
+ }
+}
+
+/** A peeking card's fill at [depth]: the front's surface one slot back, fading toward the next. */
+@Composable
+private fun peekColor(style: DeckPlacement, depth: Float): Color {
+ val back = (depth - 1f).coerceIn(0f, 1f)
+ return when (style) {
+ DeckPlacement.Pill, DeckPlacement.Expanded -> lerp(CodeTheme.colors.surfaceVariant, CodeTheme.colors.surface, back)
+ DeckPlacement.Strip -> lerp(CodeTheme.colors.surfaceVariant, Color.White.copy(alpha = 0.08f), back)
+ }
+}
+
+/** The front card's face in [style]. [bodyModifier] goes on everything but the strip's Pay button. */
+@Composable
+private fun DeckCardFront(
+ request: PaymentRequest,
+ now: Instant,
+ viewerId: ID?,
+ style: DeckPlacement,
+ visibility: AnimatedVisibilityScope?,
+ onPay: () -> Unit,
+ modifier: Modifier = Modifier,
+ bodyModifier: Modifier = Modifier,
+) {
+ when (style) {
+ DeckPlacement.Pill, DeckPlacement.Expanded -> DeckFront(request = request, now = now, viewerId = viewerId, visibility = visibility, modifier = modifier)
+ DeckPlacement.Strip -> StripFront(request, now, viewerId, visibility, onPay, modifier, bodyModifier)
+ }
+}
+
+/**
+ * The strip's front card (concept node 12670:1262): glyph, title, "You owe · "
+ * and Pay; or the viewer's status line and no Pay on a card the viewer doesn't owe on.
+ */
+@Composable
+private fun StripFront(
+ request: PaymentRequest,
+ now: Instant,
+ viewerId: ID?,
+ visibility: AnimatedVisibilityScope?,
+ onPay: () -> Unit,
+ modifier: Modifier = Modifier,
+ bodyModifier: Modifier = Modifier,
+) {
+ val shape = DeckStyle.Strip.shape
+ val share = request.unpaidShareOf(viewerId, now)
+ val standing = request.viewerStanding(viewerId, now)
+ val ended = !request.isOpen(now)
+ Row(
+ modifier = modifier
+ .height(DeckStyle.Strip.height)
+ .background(CodeTheme.colors.background, shape)
+ .alpha(if (ended) ENDED_ALPHA else 1f)
+ .background(CodeTheme.colors.surfaceVariant, shape)
+ .border(1.dp, if (share != null) CodeTheme.colors.success else CodeTheme.colors.border, shape)
+ .padding(horizontal = CodeTheme.dimens.grid.x3),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3),
+ ) {
+ Row(
+ modifier = Modifier.weight(1f).then(bodyModifier),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3),
+ ) {
+ RequestRing(
+ request = request,
+ now = now,
+ size = STRIP_RING_SIZE,
+ paid = standing is ViewerStanding.Paid,
+ modifier = Modifier.deckShared(request.id, "glyph", visibility, CircleShape),
+ )
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ text = request.deckTitle.orEmpty(),
+ style = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.SemiBold),
+ color = CodeTheme.colors.textMain,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.deckShared(request.id, "title", visibility),
+ )
+ val lineStyle = CodeTheme.typography.textSmall.copy(fontWeight = FontWeight.Medium)
+ if (share != null) {
+ val owe = stringResource(R.string.label_requestYouOwe, share.amount.requestText())
+ val timeLeft = request.statusLine(now)?.timeLeftText().orEmpty()
+ val success = CodeTheme.colors.success
+ Text(
+ text = buildAnnotatedString {
+ withStyle(SpanStyle(color = success)) { append(owe) }
+ if (timeLeft.isNotEmpty()) append(" · $timeLeft")
+ },
+ style = lineStyle,
+ color = CodeTheme.colors.textSecondary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.deckShared(request.id, "amounts", visibility),
+ )
+ } else {
+ ViewerStatusLine(
+ request = request,
+ standing = standing,
+ style = lineStyle,
+ color = CodeTheme.colors.textSecondary,
+ modifier = Modifier.deckShared(request.id, "amounts", visibility),
+ )
+ }
+ }
+ }
+ if (share != null) {
+ val payLabel = stringResource(R.string.cd_requestPayShare, share.amount.requestText())
+ Box(
+ modifier = Modifier
+ .clip(PAY_SHAPE)
+ .background(CodeTheme.colors.success, PAY_SHAPE)
+ .clickable(onClick = onPay)
+ .clearAndSetSemantics {
+ contentDescription = payLabel
+ role = Role.Button
+ onClick { onPay(); true }
+ }
+ .padding(horizontal = 18.dp, vertical = 9.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.action_requestPay),
+ style = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.SemiBold),
+ color = Color.Black,
+ )
+ }
+ }
+ }
+}
+
+/** The status line without "to pay" ("2 days left"); the ended lines as they are. */
+@Composable
+private fun StatusLine.timeLeftText(): String = when (this) {
+ is StatusLine.DaysLeft -> pluralStringResource(R.plurals.label_requestDaysLeftShort, days, days)
+ is StatusLine.HoursLeft -> pluralStringResource(R.plurals.label_requestHoursLeftShort, hours, hours)
+ is StatusLine.MinutesLeft -> pluralStringResource(R.plurals.label_requestMinutesLeftShort, minutes, minutes)
+ else -> text()
+}
+
+@Composable
+private fun DeckFront(
+ request: PaymentRequest,
+ now: Instant,
+ viewerId: ID?,
+ visibility: AnimatedVisibilityScope?,
+ modifier: Modifier = Modifier,
+) {
+ val ended = !request.isOpen(now)
+ val split = request.split
+ val standing = request.viewerStanding(viewerId, now)
+ Row(
+ modifier = modifier
+ .background(CodeTheme.colors.background, PILL_SHAPE)
+ .alpha(if (ended) ENDED_ALPHA else 1f)
+ .background(CodeTheme.colors.surfaceVariant, PILL_SHAPE)
+ .border(1.dp, CodeTheme.colors.border, PILL_SHAPE)
+ .padding(
+ start = CodeTheme.dimens.grid.x1,
+ end = CodeTheme.dimens.grid.x2,
+ top = CodeTheme.dimens.grid.x1,
+ bottom = CodeTheme.dimens.grid.x1,
+ )
+ .widthIn(max = PILL_MAX_WIDTH),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
+ ) {
+ RequestRing(
+ request = request,
+ now = now,
+ size = RING_SIZE,
+ paid = standing is ViewerStanding.Paid,
+ modifier = Modifier
+ .deckShared(request.id, "glyph", visibility, CircleShape),
+ )
+ // The text room the widest pill leaves, so a title ellipsizes only at that width and not
+ // while the pill eases open around it.
+ val textMax = PILL_MAX_WIDTH - RING_SIZE - CodeTheme.dimens.grid.x2
+ Column {
+ Text(
+ text = request.deckTitle.orEmpty(),
+ style = CodeTheme.typography.caption,
+ color = CodeTheme.colors.textMain,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier
+ .measuredUpTo(textMax)
+ .deckShared(request.id, "title", visibility),
+ )
+ if (split != null) {
+ ViewerStatusLine(
+ request = request,
+ standing = standing,
+ style = CodeTheme.typography.caption,
+ color = CodeTheme.colors.textSecondary,
+ modifier = Modifier
+ .measuredUpTo(textMax)
+ .deckShared(request.id, "amounts", visibility),
+ )
+ }
+ }
+ }
+}
+
+/**
+ * The deck's width, sprung to each new front's natural width on the spring the cards move on, so
+ * the pill and every card stretched to it ease together from the moment a swipe is let go. The
+ * front surface draws at the eased width and its content clips inside it.
+ */
+private class PillWidth(private val scope: CoroutineScope) {
+ private val width = Animatable(0f)
+ private var target = 0
+
+ val modifier: Modifier = Modifier.layout { measurable, constraints ->
+ val natural = constraints.constrainWidth(measurable.maxIntrinsicWidth(constraints.maxHeight))
+ if (natural != target) {
+ val first = target == 0
+ target = natural
+ scope.launch {
+ if (first) width.snapTo(natural.toFloat()) else width.animateTo(natural.toFloat(), DECK_SPRING)
+ }
+ }
+ val current = if (width.value == 0f) natural else width.value.roundToInt()
+ val placeable = measurable.measure(constraints.copy(minWidth = current, maxWidth = current))
+ layout(placeable.width, placeable.height) { placeable.place(0, 0) }
+ }
+}
+
+/**
+ * Measures the content as if [max] wide were free, then reports no more than the room it has, so
+ * a container easing open clips the content rather than re-wrapping or ellipsizing it each frame.
+ */
+private fun Modifier.measuredUpTo(max: Dp): Modifier = layout { measurable, constraints ->
+ val placeable = measurable.measure(constraints.copy(maxWidth = max.roundToPx()))
+ layout(placeable.width.coerceAtMost(constraints.maxWidth), placeable.height) {
+ placeable.placeRelative(0, 0)
+ }
+}
+
+/**
+ * The paid fraction as a ring around a "$" glyph, filled clockwise from 12 o'clock. Grey once
+ * the request has ended; full once it completed; a closed request shows "✓".
+ */
+@Composable
+internal fun RequestRing(
+ request: PaymentRequest,
+ now: Instant,
+ size: Dp,
+ modifier: Modifier = Modifier,
+ paid: Boolean = false,
+) {
+ val status = request.resolvedStatus(now)
+ val ended = status != PaymentRequestStatus.Open
+ val fraction = if (status == PaymentRequestStatus.Completed) 1f else request.split?.paidFraction ?: 0f
+ val accent = if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success
+ val track = CodeTheme.colors.divider
+ val inset = size * (4f / 26f)
+ Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) {
+ Canvas(modifier = Modifier.fillMaxSize()) {
+ val stroke = 2.dp.toPx()
+ val arcSize = Size(this.size.width - stroke, this.size.height - stroke)
+ val topLeft = Offset(stroke / 2, stroke / 2)
+ drawArc(track, 0f, 360f, false, topLeft, arcSize, style = Stroke(stroke))
+ if (fraction > 0f) {
+ drawArc(accent, -90f, 360f * fraction.coerceIn(0f, 1f), false, topLeft, arcSize, style = Stroke(stroke))
+ }
+ drawCircle(accent, radius = this.size.minDimension / 2 - inset.toPx())
+ }
+ Text(
+ text = if (status == PaymentRequestStatus.Closed) "✓" else "$",
+ style = if (size > 30.dp) CodeTheme.typography.textMedium else CodeTheme.typography.caption,
+ color = Color.Black,
+ )
+ if (paid) PaidBadge(glyph = size, ended = ended)
+ }
+}
+
+/**
+ * " requests, , , of paid, ", the viewer
+ * status left out with no share; or in the [strip], on a share still owed, "You owe for
+ * , ".
+ */
+@Composable
+private fun deckLabel(deck: List, now: Instant, viewerId: ID?, strip: Boolean): String {
+ val front = deck.first()
+ val split = front.split
+ val share = front.unpaidShareOf(viewerId, now)?.takeIf { strip }
+ val standing = front.viewerStanding(viewerId, now).description()
+ val main = if (share != null) stringResource(
+ R.string.cd_requestOwed,
+ share.amount.requestText(),
+ front.deckTitle.orEmpty(),
+ front.statusLine(now).text(),
+ ) else if (standing != null) stringResource(
+ R.string.cd_requestDeckViewer,
+ front.deckTitle.orEmpty(),
+ standing,
+ split?.paid?.requestText().orEmpty(),
+ split?.requested?.requestText().orEmpty(),
+ front.statusLine(now)?.text().orEmpty(),
+ ) else stringResource(
+ R.string.cd_requestDeck,
+ front.deckTitle.orEmpty(),
+ split?.paid?.requestText().orEmpty(),
+ split?.requested?.requestText().orEmpty(),
+ front.statusLine(now)?.text().orEmpty(),
+ )
+ if (deck.size < 2) return main
+ return pluralStringResource(R.plurals.cd_requestDeckCount, deck.size, deck.size) + ", " + main
+}
+
+/**
+ * The deck expanded over the transcript (concept node 12625:1318): every request as its timeline
+ * card at full width, in deck order, over a dimmed chat. The first card is the front card moved
+ * down from the app bar. Collapses on a tap on the dimmed chat, a drag up, or back.
+ */
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Composable
+internal fun ExpandedRequests(
+ deck: List,
+ now: Instant,
+ viewerId: ID?,
+ expanded: Boolean,
+ transition: Transition,
+ top: Dp,
+ pill: Rect?,
+ onCollapse: () -> Unit,
+ onOpen: (PaymentRequest) -> Unit,
+) {
+ BackHandler(enabled = expanded, onBack = onCollapse)
+ // The chat behind goes under its backdrop (chatBackdrop, on the scaffold); this only takes the
+ // tap that collapses, over the whole screen, the bar's back button and title included. Held
+ // through the collapse, so a tap mid-way lands here rather than on the bar behind.
+ transition.AnimatedVisibility(visible = { it == DeckPlacement.Expanded }, enter = fadeIn(), exit = fadeOut()) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .pointerInput(Unit) { detectTapGestures { onCollapse() } },
+ )
+ }
+ transition.AnimatedVisibility(
+ visible = { it == DeckPlacement.Expanded && deck.isNotEmpty() },
+ enter = fadeIn(),
+ exit = fadeOut(),
+ modifier = Modifier.padding(top = top),
+ ) {
+ val transition = LocalDeckTransition.current
+ val visibility: AnimatedVisibilityScope = this
+ val density = LocalDensity.current
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ // Between the cards is outside them too; a card's own click consumes its taps.
+ .pointerInput(Unit) { detectTapGestures { onCollapse() } }
+ .draggable(
+ orientation = Orientation.Vertical,
+ state = rememberDraggableState { },
+ onDragStopped = { velocity -> if (velocity < -with(density) { 300.dp.toPx() }) onCollapse() },
+ ),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(
+ horizontal = CodeTheme.dimens.inset,
+ vertical = CodeTheme.dimens.grid.x2,
+ ),
+ verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
+ ) {
+ itemsIndexed(deck, key = { _, request -> request.id.hashCode() }) { index, request ->
+ val title = request.deckTitle
+ val card = Modifier
+ .fillMaxWidth()
+ .then(
+ if (index == 0 && transition != null) with(transition) {
+ Modifier.sharedBounds(
+ sharedContentState = rememberSharedContentState(sharedKey(request.id, "card")),
+ animatedVisibilityScope = visibility,
+ clipInOverlayDuringTransition = OverlayClip(CARD_SHAPE),
+ )
+ } else with(visibility) {
+ // The others follow it in from beneath.
+ Modifier.animateEnterExit(
+ enter = slideInVertically { -it * (index + 1) } + fadeIn(),
+ exit = slideOutVertically { -it * (index + 1) } + fadeOut(),
+ )
+ }
+ )
+ // surfaceVariant is translucent; the opaque base keeps the transcript from
+ // showing through, and sits outside the alpha so ended cards stay solid.
+ .background(CodeTheme.colors.background, CARD_SHAPE)
+ .alpha(if (request.isOpen(now)) 1f else ENDED_ALPHA)
+ .background(CodeTheme.colors.surfaceVariant, CARD_SHAPE)
+ .clickable { onOpen(request) }
+ .padding(CodeTheme.dimens.grid.x3)
+ val front = index == 0
+ val detail = if (front) Modifier.expandedOnly(visibility) else Modifier
+ PaymentRequestCardContent(
+ request = request,
+ title = title,
+ now = now,
+ modifier = card,
+ glyphModifier = if (front) Modifier.deckShared(request.id, "glyph", visibility, CircleShape) else Modifier,
+ titleModifier = if (front) Modifier.deckShared(request.id, "title", visibility) else Modifier,
+ amountsModifier = if (front) Modifier.deckShared(request.id, "amounts", visibility) else Modifier,
+ detailModifier = detail,
+ standing = request.viewerStanding(viewerId, now),
+ )
+ }
+ }
+ }
+ // Where the pill sat: a way out at the spot the card came from. Above the backdrop, in with it,
+ // and out first on collapse so it is gone before the pill lands back there. Remove this call to
+ // drop it.
+ DeckCloseButton(expanded = expanded, pill = pill, onCollapse = onCollapse)
+}
+
+/**
+ * The expanded deck's close button, placed over [pill] (the collapsed pill's last bounds in the
+ * window), its end on the pill's end and centred on the front card.
+ */
+@Composable
+private fun DeckCloseButton(expanded: Boolean, pill: Rect?, onCollapse: () -> Unit) {
+ pill ?: return
+ var origin by remember { mutableStateOf(Offset.Zero) }
+ val cardCenter = with(LocalDensity.current) { PILL_HEIGHT.toPx() / 2 }
+ Box(modifier = Modifier.fillMaxSize().onGloballyPositioned { origin = it.positionInWindow() }) {
+ AnimatedVisibility(
+ visible = expanded,
+ enter = fadeIn(spring(stiffness = Spring.StiffnessMediumLow)),
+ exit = fadeOut(tween(DETAIL_FADE_OUT_MS)),
+ modifier = Modifier.layout { measurable, constraints ->
+ val placeable = measurable.measure(constraints.copy(minWidth = 0, minHeight = 0))
+ layout(constraints.maxWidth, constraints.maxHeight) {
+ placeable.place(
+ x = (pill.right - origin.x).roundToInt() - placeable.width,
+ y = (pill.top + cardCenter - origin.y).roundToInt() - placeable.height / 2,
+ )
+ }
+ },
+ ) {
+ // The same capsule Request details closes with.
+ ActionCapsule {
+ ActionCapsuleButton(
+ icon = Icons.Filled.Close,
+ description = stringResource(R.string.cd_close),
+ onClick = onCollapse,
+ tag = "request_deck_close",
+ )
+ }
+ }
+ }
+}
+
+/**
+ * For the pieces only the expanded deck shows: they come in once the card is most of the way
+ * open, and go first on collapse, so none of them is drawn squeezed into the pill's bounds.
+ */
+private fun Modifier.expandedOnly(scope: AnimatedVisibilityScope): Modifier = with(scope) {
+ this@expandedOnly.animateEnterExit(
+ enter = fadeIn(tween(DETAIL_FADE_IN_MS, delayMillis = DETAIL_DELAY_MS)),
+ exit = fadeOut(tween(DETAIL_FADE_OUT_MS)),
+ )
+}
+
+private val PILL_SHAPE = RoundedCornerShape(18.dp)
+private val CARD_SHAPE = RoundedCornerShape(12.dp)
+private val PILL_HEIGHT = 43.dp
+private val PILL_MAX_WIDTH = 150.dp
+private val RING_SIZE = 26.dp
+private val STRIP_RING_SIZE = 40.dp
+private val PAY_SHAPE = RoundedCornerShape(50)
+private const val APPEAR_MILLIS = 250
+
+/** The strip's move to and from the pill, which a scroll seeks through. */
+private const val STRIP_MILLIS = 300
+private const val APPEAR_SCALE = 0.95f
+private val PEEK_STEP = 5.dp
+private val PEEK_INSET = 7.dp
+private const val ENDED_ALPHA = 0.4f
+private const val CYCLE_THRESHOLD = 0.4f
+private const val FLING_VELOCITY = 1_000f
+private const val DROP_SCALE = 0.06f
+// How far past one pill height the front card can be pulled, as a fraction of it.
+private const val RUBBER_BAND = 0.25f
+private val DECK_SPRING = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium)
+
+
+/** A card's scale at [depth] in the stack: each slot back steps in by [insetPx] on both sides. */
+private fun slotScale(depth: Float, insetPx: Float, width: Float): Float =
+ if (width == 0f) 1f else 1f - 2f * insetPx * depth / width
+
+/** A card's offset at [depth], scaled about its centre, so each slot back shows [stepPx] below the one in front. */
+private fun slotY(depth: Float, scale: Float, stepPx: Float, height: Float): Float =
+ stepPx * depth + (1f - scale) * height / 2f
+
+// The card's bounds spring settles in about 300ms; the details start at 60% of that.
+private const val DETAIL_DELAY_MS = 180
+private const val DETAIL_FADE_IN_MS = 120
+private const val DETAIL_FADE_OUT_MS = 90
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt
new file mode 100644
index 0000000000..d3f5114079
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt
@@ -0,0 +1,523 @@
+package com.flipcash.app.messenger.internal.request
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import com.flipcash.shared.chat.ui.ActionCapsuleButton
+import com.flipcash.shared.chat.ui.ActionCapsule
+import androidx.compose.material.icons.filled.MoreHoriz
+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.rememberUpdatedState
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.repeatOnLifecycle
+import com.flipcash.app.core.AppRoute
+import com.flipcash.app.core.ui.TokenSelectionPill
+import com.flipcash.app.messenger.internal.screens.boost.CurrencyPickerContent
+import com.flipcash.features.messenger.R
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.ChatMember
+import com.flipcash.services.models.paymentrequest.PaymentRequest
+import com.flipcash.services.models.paymentrequest.Share
+import com.flipcash.services.models.paymentrequest.statusLine
+import com.flipcash.shared.chat.ui.request.RequestProgressBar
+import com.flipcash.shared.chat.ui.request.progressText
+import com.flipcash.shared.chat.ui.request.requestText
+import com.flipcash.shared.chat.ui.request.text
+import com.flipcash.shared.common.ui.ContactAvatar
+import com.flipcash.shared.common.ui.profile.chatterName
+import com.getcode.opencode.model.core.ID
+import com.getcode.opencode.model.financial.Token
+import com.getcode.theme.CodeTheme
+import com.getcode.theme.White10
+import com.getcode.ui.components.AppBarWithTitle
+import com.getcode.ui.components.SlideToConfirm
+import com.getcode.ui.theme.ButtonState
+import com.getcode.ui.theme.CodeButton
+import com.getcode.ui.theme.CodeScaffold
+import com.getcode.view.LoadingSuccessState
+import kotlin.time.Instant
+
+/**
+ * Request details (frames 09, 11, 24 and 35): the request, one row per share, the viewer's
+ * "Send " button, and the creator's close and mark-paid sheets. Editing is not built.
+ */
+@Composable
+internal fun RequestDetailsScreen(
+ chatId: ChatId,
+ requestId: String,
+ onBack: () -> Unit,
+ onOpenScreen: (AppRoute) -> Unit,
+ pushed: Boolean = false,
+ payOnOpen: Boolean = false,
+) {
+ val viewModel = hiltViewModel()
+ LaunchedEffect(chatId, requestId) {
+ viewModel.open(chatId, requestId)
+ if (payOnOpen) viewModel.payOnOpen()
+ }
+
+ // Fetch on open and every 45 s while the screen is visible with the app in the foreground.
+ val lifecycle = LocalLifecycleOwner.current.lifecycle
+ LaunchedEffect(viewModel, requestId) {
+ lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.poll() }
+ }
+ LaunchedEffect(viewModel) {
+ viewModel.effectFlow.collect { effect ->
+ when (effect) {
+ is RequestDetailsViewModel.Effect.OpenScreen -> onOpenScreen(effect.route)
+ }
+ }
+ }
+
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val request by viewModel.request.collectAsStateWithLifecycle()
+ val members by viewModel.members.collectAsStateWithLifecycle()
+ val token by viewModel.token.collectAsStateWithLifecycle()
+ val now by viewModel.now.collectAsStateWithLifecycle()
+ val viewerId = viewModel.viewerId
+
+ val current = request
+ val isCreatorOnOpen = current != null && viewerId != null &&
+ current.creator == viewerId && current.isOpen(now)
+
+ CodeScaffold(
+ topBar = {
+ AppBarWithTitle(
+ title = stringResource(R.string.title_requestDetails),
+ titleAlignment = Alignment.CenterHorizontally,
+ // Pushed from Request Money's history it goes back; opened from the chat it is a
+ // sheet of its own, closed the way the share sheet is.
+ onBackIconClicked = onBack.takeIf { pushed },
+ endContent = {
+ // One capsule for the options and the close, as iOS combines them.
+ val showsClose = !pushed
+ if (isCreatorOnOpen || showsClose) {
+ ActionCapsule {
+ if (isCreatorOnOpen) {
+ ActionCapsuleButton(
+ icon = Icons.Filled.MoreHoriz,
+ description = stringResource(R.string.cd_requestOptions),
+ onClick = viewModel::showOptions,
+ tag = "request_details_options",
+ )
+ }
+ if (showsClose) {
+ ActionCapsuleButton(
+ icon = Icons.Filled.Close,
+ description = stringResource(R.string.cd_close),
+ onClick = onBack,
+ tag = "action_close",
+ )
+ }
+ }
+ }
+ },
+ )
+ },
+ bottomBar = {
+ val share = current?.let { sendableShareOf(it, viewerId, now, state.hasPaymentIntent) }
+ if (share != null) {
+ CodeButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(horizontal = CodeTheme.dimens.inset)
+ .padding(bottom = CodeTheme.dimens.grid.x2)
+ .testTag("request_details_send"),
+ text = stringResource(R.string.action_sendShare, share.amount.requestText()),
+ buttonState = ButtonState.Filled,
+ isLoading = state.preparing,
+ onClick = viewModel::payTapped,
+ )
+ }
+ },
+ ) { padding ->
+ if (current == null) return@CodeScaffold
+ RequestDetailsContent(
+ request = current,
+ members = members,
+ viewerId = viewerId,
+ now = now,
+ contentPadding = padding,
+ onRowTapped = viewModel::rowTapped,
+ )
+ }
+
+ val unnamed = stringResource(R.string.title_unnamedUser)
+ val name = { userId: ID -> memberName(members[userId], unnamed) }
+ when (val sheet = state.sheet) {
+ RequestDetailsViewModel.Sheet.None -> Unit
+ RequestDetailsViewModel.Sheet.Pay -> {
+ val share = current?.let { unpaidShareOf(it, viewerId, now) }
+ if (current != null && share != null) {
+ PayShareSheet(
+ creator = members[current.creator],
+ share = share,
+ token = token,
+ progress = state.payProgress,
+ onConfirm = viewModel::confirmPay,
+ onDismiss = viewModel::dismissSheet,
+ )
+ }
+ }
+ RequestDetailsViewModel.Sheet.Options -> ActionSheet(
+ title = null,
+ body = null,
+ action = stringResource(R.string.action_closeRequest),
+ destructive = true,
+ working = false,
+ onAction = viewModel::askClose,
+ onDismiss = viewModel::dismissSheet,
+ )
+ RequestDetailsViewModel.Sheet.ConfirmClose -> ActionSheet(
+ title = stringResource(R.string.title_closeRequestConfirm),
+ body = stringResource(R.string.description_closeRequestConfirm),
+ action = stringResource(R.string.action_closeRequest),
+ destructive = true,
+ working = state.working,
+ onAction = viewModel::close,
+ onDismiss = viewModel::dismissSheet,
+ )
+ is RequestDetailsViewModel.Sheet.Member -> {
+ val amount = current?.split?.shareOf(sheet.userId)?.amount?.requestText().orEmpty()
+ ActionSheet(
+ title = name(sheet.userId),
+ body = stringResource(R.string.label_shareUnpaidAmount, amount),
+ action = stringResource(R.string.action_markAsPaid),
+ destructive = false,
+ working = false,
+ onAction = { viewModel.askMarkPaid(sheet.userId) },
+ onDismiss = viewModel::dismissSheet,
+ )
+ }
+ is RequestDetailsViewModel.Sheet.ConfirmMarkPaid -> {
+ val first = firstName(name(sheet.userId))
+ ActionSheet(
+ title = stringResource(R.string.title_markPaidConfirm, first),
+ body = stringResource(R.string.description_markPaidConfirm, first),
+ action = stringResource(R.string.action_markAsPaid),
+ destructive = false,
+ working = state.working,
+ onAction = { viewModel.markPaid(sheet.userId) },
+ onDismiss = viewModel::dismissSheet,
+ )
+ }
+ }
+}
+
+@Composable
+private fun RequestDetailsContent(
+ request: PaymentRequest,
+ members: Map,
+ viewerId: ID?,
+ now: Instant,
+ contentPadding: PaddingValues,
+ onRowTapped: (ID) -> Unit,
+) {
+ val split = request.split ?: return
+ val open = request.isOpen(now)
+ val creator = members[request.creator]
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(contentPadding),
+ contentPadding = PaddingValues(bottom = CodeTheme.dimens.grid.x4),
+ ) {
+ item {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = CodeTheme.dimens.inset)
+ .padding(top = CodeTheme.dimens.grid.x4, bottom = CodeTheme.dimens.grid.x3),
+ verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
+ ) {
+ Text(
+ text = request.deckTitle.orEmpty(),
+ style = CodeTheme.typography.displayCompact,
+ color = CodeTheme.colors.textMain,
+ )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
+ ) {
+ if (creator != null) {
+ ContactAvatar(
+ userProfile = creator.userProfile,
+ modifier = Modifier
+ .requiredSize(CodeTheme.dimens.staticGrid.x4)
+ .clip(CircleShape),
+ )
+ }
+ Text(
+ text = if (request.creator == viewerId) stringResource(R.string.label_requestedByYou)
+ else stringResource(R.string.label_requestedBy, memberName(creator)),
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textSecondary,
+ )
+ }
+ Text(
+ text = request.progressText(),
+ style = CodeTheme.typography.displaySmall,
+ color = CodeTheme.colors.textMain,
+ )
+ RequestProgressBar(fraction = split.paidFraction, ended = !open)
+ Text(
+ text = request.statusLine(now).text(),
+ style = CodeTheme.typography.caption,
+ color = CodeTheme.colors.textSecondary,
+ )
+ }
+ }
+ items(split.shares, key = { it.userId }) { share ->
+ val tappable = canMarkPaid(request, share.userId, viewerId, now)
+ ShareRow(
+ member = members[share.userId],
+ isViewer = share.userId == viewerId,
+ share = share,
+ requestOpen = open,
+ onClick = if (tappable) ({ onRowTapped(share.userId) }) else null,
+ )
+ }
+ }
+}
+
+@Composable
+private fun ShareRow(
+ member: ChatMember?,
+ isViewer: Boolean,
+ share: Share,
+ requestOpen: Boolean,
+ onClick: (() -> Unit)?,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = CodeTheme.dimens.inset)
+ .padding(bottom = CodeTheme.dimens.grid.x2)
+ .clip(CodeTheme.shapes.medium)
+ .background(CodeTheme.colors.surfaceVariant)
+ .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
+ .padding(CodeTheme.dimens.grid.x3),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3),
+ ) {
+ MemberAvatar(member, Modifier.requiredSize(CodeTheme.dimens.staticGrid.x6))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = if (isViewer) stringResource(R.string.title_you) else memberName(member),
+ style = CodeTheme.typography.textMedium,
+ color = CodeTheme.colors.textMain,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = when {
+ share.isPaid -> stringResource(R.string.label_sharePaid)
+ requestOpen -> stringResource(R.string.label_shareUnpaid)
+ else -> stringResource(R.string.label_shareUnpaidEnded)
+ },
+ style = CodeTheme.typography.textSmall,
+ color = if (share.isPaid) CodeTheme.colors.success else CodeTheme.colors.textSecondary,
+ )
+ }
+ Text(
+ text = share.amount.requestText(),
+ style = CodeTheme.typography.textMedium,
+ color = CodeTheme.colors.textMain,
+ )
+ }
+}
+
+/**
+ * Frame 10: who is being paid, the share, the token, and the swipe. The token pill swaps this
+ * sheet's content for the currency list, as on the group send confirm sheet.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun PayShareSheet(
+ creator: ChatMember?,
+ share: Share,
+ token: Token?,
+ progress: LoadingSuccessState,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val locked by rememberUpdatedState(!progress.isIdle)
+ var pickingCurrency by rememberSaveable { mutableStateOf(false) }
+ val sheetState = rememberModalBottomSheetState(
+ skipPartiallyExpanded = true,
+ confirmValueChange = { !locked && !pickingCurrency },
+ )
+ ModalBottomSheet(
+ onDismissRequest = { if (!locked) onDismiss() },
+ sheetState = sheetState,
+ containerColor = CodeTheme.colors.background,
+ ) {
+ BackHandler(enabled = pickingCurrency) { pickingCurrency = false }
+ if (pickingCurrency) {
+ CurrencyPickerContent(onPicked = { pickingCurrency = false })
+ return@ModalBottomSheet
+ }
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(horizontal = CodeTheme.dimens.inset)
+ .padding(bottom = CodeTheme.dimens.grid.x4),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3),
+ ) {
+ MemberAvatar(creator, Modifier.requiredSize(CodeTheme.dimens.staticGrid.x10))
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = memberName(creator),
+ style = CodeTheme.typography.textLarge,
+ color = CodeTheme.colors.textMain,
+ textAlign = TextAlign.Center,
+ )
+ creator?.userProfile?.username?.takeIf { it.isNotBlank() }?.let { username ->
+ Text(
+ text = "@$username",
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textSecondary,
+ )
+ }
+ }
+ Text(
+ text = share.amount.requestText(),
+ style = CodeTheme.typography.displayMedium,
+ color = CodeTheme.colors.textMain,
+ )
+ TokenSelectionPill(
+ token = token,
+ background = White10,
+ textStyle = CodeTheme.typography.textMedium,
+ imageSize = CodeTheme.dimens.staticGrid.x3,
+ contentPadding = PaddingValues(
+ horizontal = CodeTheme.dimens.grid.x1 + 1.dp,
+ vertical = CodeTheme.dimens.grid.x1 - 1.dp,
+ ),
+ onClick = { if (!locked) pickingCurrency = true },
+ )
+ SlideToConfirm(
+ onConfirm = onConfirm,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = CodeTheme.dimens.grid.x3)
+ .testTag("request_pay_swipe"),
+ enabled = progress.isIdle,
+ isLoading = progress.loading,
+ isSuccess = progress.success,
+ label = stringResource(R.string.action_swipeToSend),
+ )
+ }
+ }
+}
+
+/** Frames 28, 29, 33 and 34: an optional title and body, one action, and Cancel. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ActionSheet(
+ title: String?,
+ body: String?,
+ action: String,
+ destructive: Boolean,
+ working: Boolean,
+ onAction: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val sheetState = rememberModalBottomSheetState(
+ skipPartiallyExpanded = true,
+ confirmValueChange = { !working },
+ )
+ ModalBottomSheet(
+ onDismissRequest = { if (!working) onDismiss() },
+ sheetState = sheetState,
+ containerColor = CodeTheme.colors.background,
+ ) {
+ Column(
+ modifier = Modifier
+ .navigationBarsPadding()
+ .padding(horizontal = CodeTheme.dimens.inset)
+ .padding(bottom = CodeTheme.dimens.grid.x4),
+ verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3),
+ ) {
+ title?.let {
+ Text(text = it, style = CodeTheme.typography.screenTitle, color = CodeTheme.colors.textMain)
+ }
+ body?.let {
+ Text(text = it, style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textSecondary)
+ }
+ CodeButton(
+ modifier = Modifier.fillMaxWidth().testTag("request_action_primary"),
+ text = action,
+ buttonState = ButtonState.Filled,
+ textColor = if (destructive) CodeTheme.colors.errorText else Color.Unspecified,
+ isLoading = working,
+ onClick = onAction,
+ )
+ CodeButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.action_cancel),
+ buttonState = ButtonState.Subtle,
+ enabled = !working,
+ onClick = onDismiss,
+ )
+ }
+ }
+}
+
+@Composable
+private fun memberName(member: ChatMember?): String =
+ memberName(member, stringResource(R.string.title_unnamedUser))
+
+private fun memberName(member: ChatMember?, fallback: String): String =
+ member?.let { chatterName(it.userProfile.displayName, it.userProfile.username, fallback) } ?: fallback
+
+/** A member's avatar, or an empty circle of the same size while the roster loads. */
+@Composable
+private fun MemberAvatar(member: ChatMember?, modifier: Modifier) {
+ if (member != null) {
+ ContactAvatar(userProfile = member.userProfile, modifier = modifier.clip(CircleShape))
+ } else {
+ Box(modifier.clip(CircleShape).background(CodeTheme.colors.divider))
+ }
+}
+
+private fun firstName(name: String): String = name.trim().substringBefore(' ').ifEmpty { name }
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt
new file mode 100644
index 0000000000..9c2dbb8144
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt
@@ -0,0 +1,360 @@
+package com.flipcash.app.messenger.internal.request
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.flipcash.app.core.AppRoute
+import com.flipcash.app.core.internal.errors.showNetworkError
+import com.flipcash.app.messenger.internal.payment.StartChattingPayer
+import com.flipcash.features.messenger.R
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.ChatMember
+import com.flipcash.services.models.paymentrequest.PaymentRequest
+import com.flipcash.services.models.paymentrequest.unpaidShareOf
+import com.flipcash.services.user.UserManager
+import com.flipcash.shared.chat.GroupRosters
+import com.flipcash.shared.chat.PaymentRequests
+import com.flipcash.shared.payments.request.PaymentRequestPaymentDelegate
+import com.flipcash.shared.payments.request.ShareToPay
+import com.getcode.manager.BottomBarManager
+import com.getcode.opencode.controllers.TransactionController
+import com.getcode.opencode.exchange.Exchange
+import com.getcode.opencode.model.core.ID
+import com.getcode.opencode.model.core.errors.ComputeVerifiedFiatError
+import com.getcode.opencode.model.financial.Fiat
+import com.getcode.opencode.model.financial.Token
+import com.flipcash.app.tokens.TokenCoordinator
+import com.getcode.util.resources.ResourceHelper
+import com.getcode.utils.ErrorUtils
+import com.getcode.utils.decodeBase58
+import com.getcode.view.LoadingSuccessState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Instant
+
+/**
+ * Request details, frames 09–11, 24 and 35 of the group requests spec: one request, the viewer's
+ * share to pay, and the creator's close and mark-paid actions.
+ *
+ * The share's payment intent id lives in [ready] only, for as long as the swipe sheet is up. It
+ * is never put in [State], so it can't reach a log through the state's toString.
+ */
+@HiltViewModel
+internal class RequestDetailsViewModel @Inject constructor(
+ private val requests: PaymentRequests,
+ private val rosters: GroupRosters,
+ private val payments: PaymentRequestPaymentDelegate,
+ private val payer: StartChattingPayer,
+ private val tokenCoordinator: TokenCoordinator,
+ private val transactionController: TransactionController,
+ private val exchange: Exchange,
+ private val userManager: UserManager,
+ private val resources: ResourceHelper,
+) : ViewModel() {
+
+ sealed interface Sheet {
+ data object None : Sheet
+ /** Frame 10: swipe to pay the viewer's share. */
+ data object Pay : Sheet
+ /** Frame 33: the creator's "•••". */
+ data object Options : Sheet
+ /** Frame 34. */
+ data object ConfirmClose : Sheet
+ /** Frame 28 trimmed to Mark as Paid, for [userId]'s unpaid share. */
+ data class Member(val userId: ID) : Sheet
+ /** Frame 29. */
+ data class ConfirmMarkPaid(val userId: ID) : Sheet
+ }
+
+ data class State(
+ val chatId: ChatId? = null,
+ val requestId: ID? = null,
+ val sheet: Sheet = Sheet.None,
+ /** Fetching the request before the swipe sheet opens: the Send button spins. */
+ val preparing: Boolean = false,
+ val payProgress: LoadingSuccessState = LoadingSuccessState(),
+ /** A close or mark-paid call is out. */
+ val working: Boolean = false,
+ /**
+ * The last fetch carried a payment intent id for the viewer. Without one there is nothing
+ * to pay with, so Send stays hidden (case 20). The id itself is not kept here.
+ */
+ val hasPaymentIntent: Boolean = false,
+ )
+
+ sealed interface Effect {
+ data class OpenScreen(val route: AppRoute) : Effect
+ }
+
+ private val _state = MutableStateFlow(State())
+ val state: StateFlow = _state.asStateFlow()
+
+ private val effects = Channel(Channel.BUFFERED)
+ val effectFlow: Flow = effects.receiveAsFlow()
+
+ private val _now = MutableStateFlow(Clock.System.now())
+
+ /** The time for the status line and row states, ticking once a minute. */
+ val now: StateFlow = _now.asStateFlow()
+
+ val viewerId: ID? get() = userManager.accountId
+
+ val request: StateFlow = _state
+ .flatMapLatest { s -> s.requestId?.let(requests::request) ?: flowOf(null) }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
+
+ /** The chat's members by id, for the creator's and each share's name and avatar. */
+ val members: StateFlow> = combine(_state, rosters.state) { s, all ->
+ all[s.chatId].orEmpty().associateBy { it.userId }
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
+
+ val token: StateFlow = payer.observeSelectedToken()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
+
+ /** The share being paid, with its intent id. Only set while [Sheet.Pay] is up. */
+ private var ready: ShareToPay.Ready? = null
+
+ init {
+ viewModelScope.launch {
+ while (true) {
+ delay(1.minutes)
+ _now.value = Clock.System.now()
+ }
+ }
+ }
+
+ fun open(chatId: ChatId, requestId: String) {
+ val id = runCatching { requestId.decodeBase58().toList() }.getOrNull() ?: return
+ if (_state.value.requestId == id) return
+ _state.update { it.copy(chatId = chatId, requestId = id) }
+ if (rosters.state.value[chatId] == null) viewModelScope.launch { rosters.load(chatId) }
+ }
+
+ /**
+ * Fetches the request now and every 45 s until cancelled. Run it while the screen is visible
+ * with the app in the foreground.
+ */
+ suspend fun poll() {
+ val id = _state.value.requestId ?: return
+ while (true) {
+ requests.fetch(id)
+ .onSuccess { details -> _state.update { it.copy(hasPaymentIntent = details.paymentIntentId != null) } }
+ _now.value = Clock.System.now()
+ delay(ChatRequestsViewModel.POLL_INTERVAL)
+ }
+ }
+
+ private var paidOnOpen = false
+
+ /** The deck's Pay button: [payTapped] once, as the screen opens, and not again on its return. */
+ fun payOnOpen() {
+ if (paidOnOpen) return
+ paidOnOpen = true
+ payTapped()
+ }
+
+ /** "Send ": refetch for the intent id, then show the swipe sheet if there is still a share to pay. */
+ fun payTapped() {
+ val id = _state.value.requestId ?: return
+ val viewer = viewerId ?: return
+ if (_state.value.preparing) return
+ _state.update { it.copy(preparing = true) }
+ viewModelScope.launch {
+ payments.prepare(id, viewer)
+ .onSuccess { found ->
+ _state.update { it.copy(preparing = false) }
+ when (found) {
+ is ShareToPay.Ready -> {
+ ready = found
+ _state.update { it.copy(sheet = Sheet.Pay, payProgress = LoadingSuccessState()) }
+ }
+ is ShareToPay.NothingToPay -> _state.update { it.copy(hasPaymentIntent = false) }
+ is ShareToPay.Closed -> presentClosed()
+ }
+ }
+ .onFailure {
+ _state.update { it.copy(preparing = false) }
+ presentSendFailed()
+ }
+ }
+ }
+
+ /** The swipe on frame 10. A second swipe after a failure pays under the same intent id. */
+ fun confirmPay() {
+ val pending = ready ?: return
+ val token = token.value ?: return
+ val owner = userManager.accountCluster ?: return
+ if (!_state.value.payProgress.isIdle) return
+ if (overBalance(pending.share.amount, token) || overSendLimit(pending.share.amount)) return
+
+ _state.update { it.copy(payProgress = LoadingSuccessState(loading = true)) }
+ viewModelScope.launch {
+ payments.pay(pending, token, owner.withTimelockForToken(token))
+ .onSuccess {
+ _state.update { it.copy(payProgress = LoadingSuccessState(success = true)) }
+ delay(400.milliseconds)
+ dismissSheet()
+ }
+ .onFailure { cause -> afterFailedPayment(pending, cause) }
+ }
+ }
+
+ /**
+ * The outcome is unknown, so refetch before anything else: a share that landed after all is
+ * paid, and a request closed meanwhile is "Request Closed".
+ */
+ private suspend fun afterFailedPayment(pending: ShareToPay.Ready, cause: Throwable) {
+ _state.update { it.copy(payProgress = LoadingSuccessState()) }
+ val viewer = viewerId ?: return
+ when (val found = payments.prepare(pending.request.id, viewer).getOrNull()) {
+ is ShareToPay.Closed -> {
+ dismissSheet()
+ presentClosed()
+ }
+ is ShareToPay.NothingToPay -> dismissSheet()
+ is ShareToPay.Ready -> {
+ ready = found
+ presentPaymentError(cause)
+ }
+ null -> presentPaymentError(cause)
+ }
+ }
+
+ fun showOptions() = setSheet(Sheet.Options)
+
+ fun askClose() = setSheet(Sheet.ConfirmClose)
+
+ fun close() {
+ val id = _state.value.requestId ?: return
+ runCreatorAction { requests.close(id) }
+ }
+
+ /** A tap on a row: only the creator, on an open request, on someone else's unpaid share. */
+ fun rowTapped(userId: ID) {
+ val request = request.value ?: return
+ if (!canMarkPaid(request, userId, viewerId, _now.value)) return
+ setSheet(Sheet.Member(userId))
+ }
+
+ fun askMarkPaid(userId: ID) = setSheet(Sheet.ConfirmMarkPaid(userId))
+
+ fun markPaid(userId: ID) {
+ val id = _state.value.requestId ?: return
+ runCreatorAction { requests.markPaid(id, userId) }
+ }
+
+ fun dismissSheet() {
+ if (!_state.value.payProgress.isIdle && !_state.value.payProgress.success) return
+ if (_state.value.working) return
+ ready = null
+ _state.update { it.copy(sheet = Sheet.None, payProgress = LoadingSuccessState()) }
+ }
+
+ fun presentDeposit() {
+ viewModelScope.launch {
+ payer.depositRoute()?.let { effects.send(Effect.OpenScreen(it)) }
+ }
+ }
+
+ private fun setSheet(sheet: Sheet) {
+ if (_state.value.working) return
+ _state.update { it.copy(sheet = sheet) }
+ }
+
+ private fun runCreatorAction(call: suspend () -> Result) {
+ if (_state.value.working) return
+ _state.update { it.copy(working = true) }
+ viewModelScope.launch {
+ val result = call()
+ _state.update { it.copy(working = false) }
+ result
+ .onSuccess { _state.update { it.copy(sheet = Sheet.None) } }
+ .onFailure {
+ // The request may have changed under the action, e.g. closed or paid elsewhere.
+ _state.value.requestId?.let { id -> requests.fetch(id) }
+ ErrorUtils.showNetworkError(resources)
+ }
+ }
+ }
+
+ private fun overBalance(amount: Fiat, token: Token): Boolean {
+ val rate = exchange.rateFor(amount.currencyCode) ?: return false
+ val balance = tokenCoordinator.balanceForToken(token).convertingTo(rate)
+ val over = amount.valueGreaterThan(balance)
+ if (over) payer.presentInsufficientBalance(onAddMoney = ::presentDeposit)
+ return over
+ }
+
+ private fun overSendLimit(amount: Fiat): Boolean {
+ val limit = transactionController.limits.value?.sendLimitFor(amount.currencyCode) ?: return false
+ val over = amount.toDouble() > limit.nextTransaction
+ if (over) {
+ BottomBarManager.showAlert(
+ resources.getString(R.string.error_title_sendLimitReached),
+ resources.getString(R.string.error_description_sendLimitReached),
+ )
+ }
+ return over
+ }
+
+ private fun presentClosed() {
+ BottomBarManager.showAlert(
+ resources.getString(R.string.error_title_requestClosed),
+ resources.getString(R.string.error_description_requestClosed),
+ )
+ }
+
+ private fun presentSendFailed() {
+ BottomBarManager.showError(
+ title = resources.getString(R.string.error_title_cashFailedToSend),
+ message = resources.getString(R.string.error_description_cashFailedToSend),
+ )
+ }
+
+ private fun presentPaymentError(cause: Throwable) {
+ val (title, message) = when (cause) {
+ is ComputeVerifiedFiatError.AmountBelowMinimum ->
+ R.string.error_title_amountTooSmall to R.string.error_description_amountTooSmall
+ is ComputeVerifiedFiatError ->
+ R.string.error_title_staleRates to R.string.error_description_staleRates
+ else ->
+ R.string.error_title_cashFailedToSend to R.string.error_description_cashFailedToSend
+ }
+ BottomBarManager.showError(title = resources.getString(title), message = resources.getString(message))
+ }
+}
+
+/** Whether the viewer, as creator, can mark [userId]'s share paid: open request, unpaid, not their own. */
+internal fun canMarkPaid(request: PaymentRequest, userId: ID, viewerId: ID?, now: Instant): Boolean {
+ if (viewerId == null || request.creator != viewerId || userId == viewerId) return false
+ if (!request.isOpen(now)) return false
+ val share = request.split?.shareOf(userId) ?: return false
+ return !share.isPaid
+}
+
+/**
+ * The share the "Send " button pays, or null to hide it: the viewer has an unpaid share on
+ * an open request and the server gave them a payment intent id for it.
+ */
+internal fun sendableShareOf(request: PaymentRequest, viewerId: ID?, now: Instant, hasPaymentIntent: Boolean) =
+ if (!hasPaymentIntent) null else unpaidShareOf(request, viewerId, now)
+
+/** Whether the viewer has a share to pay on [request] now. */
+internal fun unpaidShareOf(request: PaymentRequest, viewerId: ID?, now: Instant) =
+ request.unpaidShareOf(viewerId, now)
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyScreen.kt
new file mode 100644
index 0000000000..e928d2aabb
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyScreen.kt
@@ -0,0 +1,566 @@
+package com.flipcash.app.messenger.internal.request
+
+import com.getcode.theme.White05
+import androidx.compose.material3.Icon
+import androidx.compose.ui.draw.clip
+import androidx.compose.foundation.background
+import android.os.Parcelable
+import androidx.activity.compose.BackHandler
+import androidx.navigation3.runtime.entryProvider
+import com.getcode.navigation.annotatedEntry
+import com.getcode.navigation.flow.FlowHost
+import com.getcode.navigation.flow.rememberFlowNavigator
+import com.getcode.navigation.results.NavResultStateRegistry
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.text.input.rememberTextFieldState
+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.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import com.getcode.util.resources.LocalResources
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.flipcash.app.messenger.internal.screens.cash.GroupSelectPeopleContent
+import com.flipcash.app.messenger.internal.screens.profile.edit.NameEditScaffold
+import com.flipcash.features.messenger.R
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.app.core.AppRoute
+import com.flipcash.services.models.paymentrequest.PaymentRequest
+import com.getcode.utils.base58
+import com.flipcash.services.models.paymentrequest.PaymentRequestStatus
+import com.flipcash.services.models.paymentrequest.PaymentRequestText
+import com.flipcash.shared.amountentry.AmountEntryScreen
+import com.getcode.opencode.model.financial.Fiat
+import com.getcode.theme.CodeTheme
+import com.getcode.ui.components.AppBarDefaults
+import com.getcode.ui.components.AppBarWithTitle
+import com.getcode.ui.theme.ButtonState
+import com.getcode.ui.theme.CodeButton
+import com.getcode.ui.theme.CodeScaffold
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+import kotlin.time.Clock
+import kotlin.time.toJavaInstant
+
+/**
+ * Request Money, frames 03–06 and 25 of the group requests spec: history, then the create flow
+ * behind New Request. Its stages live in [RequestMoneyViewModel] rather than as steps of their
+ * own, so the draft has one owner and leaving the screen drops it. The screen hosts its own nav
+ * host so each stage slides in over the last; the stage decides which step is on top.
+ */
+@Composable
+internal fun RequestMoneyScreen(
+ chatId: ChatId,
+ resultStateRegistry: NavResultStateRegistry,
+ onClose: () -> Unit,
+ onOpenScreen: (AppRoute) -> Unit,
+) {
+ // Read here, not inside an entry: an entry's hiltViewModel would be scoped to that step.
+ val viewModel = hiltViewModel()
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ LaunchedEffect(chatId) { viewModel.open(chatId) }
+ LaunchedEffect(viewModel) {
+ viewModel.effectFlow.collect { effect ->
+ when (effect) {
+ RequestMoneyViewModel.Effect.Sent -> onClose()
+ }
+ }
+ }
+ val back = { if (!viewModel.back()) onClose() }
+ val target = stackFor(state.stage)
+ // Seeded once. FlowHost re-seeds, without a transition, when initialStack changes before
+ // the stack has moved, so passing the live target swapped History for Amount unanimated.
+ // FollowStage makes every move after the first frame.
+ val initialStack = remember { target }
+
+ FlowHost(
+ initialStack = initialStack,
+ resultStateRegistry = resultStateRegistry,
+ onExit = { _, _ -> onClose() },
+ entryProvider = entryProvider {
+ annotatedEntry {
+ FollowStage(RequestStep.History, target)
+ val flowNavigator = rememberFlowNavigator()
+ val requests by viewModel.chatRequests.collectAsStateWithLifecycle()
+ RequestHistoryContent(
+ requests = requests,
+ loadingMore = state.loadingPrevious,
+ canLoadMore = !state.previousEnd,
+ onBack = onClose,
+ onNewRequest = viewModel::newRequest,
+ onOpen = { flowNavigator.navigateTo(RequestStep.Details(it.id.base58)) },
+ onLoadMore = viewModel::loadMorePrevious,
+ )
+ }
+ annotatedEntry {
+ FollowStage(RequestStep.Amount, target, onBack = back)
+ AmountEntryScreen(
+ controller = viewModel.amountDelegate,
+ onConfirm = viewModel::amountEntered,
+ largeHeader = true,
+ appBar = {
+ AppBarWithTitle(
+ title = stringResource(R.string.title_amountPerPerson),
+ titleAlignment = Alignment.CenterHorizontally,
+ onBackIconClicked = back,
+ )
+ },
+ )
+ }
+ annotatedEntry {
+ FollowStage(RequestStep.Summary, target, onBack = back)
+ state.draft?.let { draft ->
+ RequestSummaryContent(
+ draft = draft,
+ sending = state.sendProgress.loading,
+ onBack = back,
+ onName = viewModel::editName,
+ onPeople = viewModel::editPeople,
+ onTiming = viewModel::editDeadline,
+ onRequest = viewModel::requestConfirm,
+ )
+ }
+ RequestSheets(state, viewModel)
+ }
+ annotatedEntry {
+ FollowStage(RequestStep.People, target, onBack = back)
+ state.draft?.let { draft ->
+ val members by viewModel.members.collectAsStateWithLifecycle()
+ GroupSelectPeopleContent(
+ candidates = members,
+ selected = draft.shares,
+ onBack = back,
+ onToggle = { viewModel.toggle(it.userId) },
+ onSend = back,
+ buttonText = stringResource(R.string.action_done),
+ buttonEnabled = draft.canContinue,
+ viewerId = viewModel.viewerId,
+ )
+ }
+ }
+ annotatedEntry { step ->
+ val flowNavigator = rememberFlowNavigator()
+ RequestDetailsScreen(
+ chatId = chatId,
+ requestId = step.requestId,
+ onBack = { flowNavigator.back() },
+ onOpenScreen = onOpenScreen,
+ pushed = true,
+ )
+ }
+ annotatedEntry {
+ FollowStage(RequestStep.Name, target, onBack = back)
+ state.draft?.let { draft ->
+ RequestNameContent(title = draft.title, onBack = back, onDone = viewModel::rename)
+ }
+ }
+ },
+ )
+}
+
+/**
+ * Moves the nav host one step towards [target] from [step]'s entry (see [moveFrom]). Back, when
+ * [onBack] is given, goes through the view model, whose stage change pops the step here; the nav
+ * host popping on its own would leave the stage behind.
+ */
+@Composable
+private fun FollowStage(step: RequestStep, target: List, onBack: (() -> Unit)? = null) {
+ val flowNavigator = rememberFlowNavigator()
+ LaunchedEffect(target) {
+ when (val move = moveFrom(step, target)) {
+ StackMove.None -> Unit
+ StackMove.Pop -> flowNavigator.back()
+ is StackMove.Push -> flowNavigator.navigateTo(move.step)
+ }
+ }
+ if (onBack != null) BackHandler { onBack() }
+}
+
+@Composable
+private fun RequestSheets(state: RequestMoneyViewModel.State, viewModel: RequestMoneyViewModel) {
+ when (state.sheet) {
+ RequestMoneyViewModel.Sheet.None -> Unit
+ RequestMoneyViewModel.Sheet.Deadline -> state.draft?.let { draft ->
+ TimeToPaySheet(
+ current = draft.deadline,
+ onChoose = viewModel::chooseDeadline,
+ onDismiss = viewModel::dismissSheet,
+ )
+ }
+ RequestMoneyViewModel.Sheet.Confirm -> ConfirmSendSheet(
+ sending = state.sendProgress.loading,
+ onSend = viewModel::send,
+ onDismiss = viewModel::dismissSheet,
+ )
+ }
+}
+
+@Composable
+private fun RequestHistoryContent(
+ requests: List,
+ loadingMore: Boolean,
+ canLoadMore: Boolean,
+ onBack: () -> Unit,
+ onNewRequest: () -> Unit,
+ onOpen: (PaymentRequest) -> Unit,
+ onLoadMore: () -> Unit,
+) {
+ val now = remember(requests) { Clock.System.now() }
+ val open = requests.filter { it.split != null && it.isOpen(now) }.sortedBy { it.split?.endsAt }
+ val previous = requests
+ .filter { it.split != null && !it.isOpen(now) }
+ .sortedByDescending { it.resolvedClosedAt(now) }
+
+ CodeScaffold(
+ topBar = {
+ AppBarWithTitle(
+ title = stringResource(R.string.title_requestMoneyHistory),
+ titleAlignment = Alignment.CenterHorizontally,
+ // The sheet's root, closed the way the share sheet is.
+ endContent = { AppBarDefaults.Close(onClick = onBack) },
+ )
+ },
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ contentPadding = PaddingValues(CodeTheme.dimens.grid.x5),
+ verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3),
+ ) {
+ item {
+ CodeButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = CodeTheme.dimens.grid.x3)
+ .testTag("request_money_new"),
+ text = stringResource(R.string.action_newRequest),
+ onClick = onNewRequest,
+ )
+ }
+ if (open.isNotEmpty()) {
+ item { SectionHeader(stringResource(R.string.header_openRequests)) }
+ items(open, key = { it.id }) { RequestHistoryRow(it, onClick = { onOpen(it) }) }
+ }
+ if (previous.isNotEmpty()) {
+ item {
+ SectionHeader(
+ text = stringResource(R.string.header_previousRequests),
+ modifier = Modifier.padding(top = if (open.isEmpty()) 0.dp else CodeTheme.dimens.grid.x3),
+ )
+ }
+ items(previous, key = { it.id }) { RequestHistoryRow(it, onClick = { onOpen(it) }) }
+ }
+ if (canLoadMore && !loadingMore) {
+ // Reaching the end of what is loaded asks for the next page of Previous Requests.
+ item { LaunchedEffect(previous.size) { onLoadMore() } }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SectionHeader(text: String, modifier: Modifier = Modifier) {
+ Text(
+ modifier = modifier,
+ text = text,
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textSecondary,
+ )
+}
+
+/** A card per request, as on iOS: title and date on one line, amounts under, a chevron at the end. */
+@Composable
+private fun RequestHistoryRow(request: PaymentRequest, onClick: () -> Unit) {
+ val split = request.split ?: return
+ val title = (split.text as? PaymentRequestText.Plain)?.title.orEmpty()
+ val date = remember(request.createdAt) {
+ DateTimeFormatter.ofPattern(monthDayPattern())
+ .format(request.createdAt.toJavaInstant().atZone(ZoneId.systemDefault()))
+ }
+ val progress = if (request.status == PaymentRequestStatus.Completed) {
+ stringResource(R.string.label_requestCompleted, split.requested.text())
+ } else {
+ stringResource(R.string.label_requestProgress, split.paid.text(), split.requested.text())
+ }
+ val shape = RoundedCornerShape(12.dp)
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(shape)
+ .background(Color.White.copy(alpha = 0.06f), shape)
+ .clickable(onClick = onClick)
+ .padding(CodeTheme.dimens.grid.x4)
+ .semantics(mergeDescendants = true) {},
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3),
+ ) {
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1),
+ ) {
+ Row(horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2)) {
+ Text(
+ modifier = Modifier.weight(1f, fill = false).alignByBaseline(),
+ text = title,
+ style = CodeTheme.typography.textLarge,
+ color = CodeTheme.colors.textMain,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ modifier = Modifier.alignByBaseline(),
+ text = date,
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textSecondary,
+ maxLines = 1,
+ )
+ }
+ Text(text = progress, style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary)
+ }
+ Icon(
+ modifier = Modifier.size(14.dp),
+ painter = painterResource(R.drawable.ic_chevron_right),
+ contentDescription = null,
+ tint = CodeTheme.colors.textSecondary,
+ )
+ }
+}
+
+@Composable
+private fun RequestSummaryContent(
+ draft: NewRequestDraft,
+ sending: Boolean,
+ onBack: () -> Unit,
+ onName: () -> Unit,
+ onPeople: () -> Unit,
+ onTiming: () -> Unit,
+ onRequest: () -> Unit,
+) {
+ val amount = draft.perPerson?.text().orEmpty()
+ val resources = LocalResources.current
+ CodeScaffold(
+ topBar = {
+ AppBarWithTitle(
+ title = stringResource(R.string.title_newRequest),
+ titleAlignment = Alignment.CenterHorizontally,
+ onBackIconClicked = onBack,
+ )
+ },
+ bottomBar = {
+ CodeButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(horizontal = CodeTheme.dimens.inset)
+ .padding(bottom = CodeTheme.dimens.grid.x2)
+ .testTag("request_summary_send"),
+ text = requestButtonText(draft, amount, resources),
+ enabled = draft.canSend,
+ isLoading = sending,
+ onClick = onRequest,
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(horizontal = CodeTheme.dimens.inset),
+ ) {
+ Spacer(Modifier.height(CodeTheme.dimens.grid.x6))
+ Text(
+ text = amount,
+ style = CodeTheme.typography.displayLarge,
+ color = CodeTheme.colors.textMain,
+ )
+ Text(
+ text = stringResource(R.string.label_perPerson),
+ style = CodeTheme.typography.textMedium,
+ color = CodeTheme.colors.textSecondary,
+ )
+ Spacer(Modifier.height(CodeTheme.dimens.grid.x6))
+ SummaryRow(
+ label = stringResource(R.string.label_requestName),
+ value = draft.title,
+ detail = null,
+ onClick = onName,
+ )
+ SummaryRow(
+ label = stringResource(R.string.label_requestPeople),
+ value = peopleValue(draft.people, resources),
+ detail = peopleDetail(draft.people, resources),
+ onClick = onPeople,
+ )
+ SummaryRow(
+ label = stringResource(R.string.label_requestTiming),
+ value = deadlineValue(draft.deadline),
+ detail = stringResource(R.string.subtitle_requestAutoCloses),
+ onClick = onTiming,
+ )
+ }
+ }
+}
+
+@Composable
+private fun SummaryRow(label: String, value: String, detail: String?, onClick: () -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = CodeTheme.dimens.grid.x3)
+ .clip(CodeTheme.shapes.medium)
+ .background(White05)
+ .clickable(onClick = onClick)
+ .padding(horizontal = CodeTheme.dimens.grid.x3, vertical = CodeTheme.dimens.grid.x3),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
+ ) {
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1)) {
+ Text(text = label, style = CodeTheme.typography.caption, color = CodeTheme.colors.textSecondary)
+ Text(
+ text = value,
+ style = CodeTheme.typography.textLarge,
+ color = CodeTheme.colors.textMain,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (detail != null) {
+ Text(text = detail, style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary)
+ }
+ }
+ Icon(
+ painter = painterResource(R.drawable.ic_chevron_right),
+ contentDescription = null,
+ tint = CodeTheme.colors.textSecondary,
+ )
+ }
+}
+
+@Composable
+private fun deadlineValue(deadline: NewRequestDraft.Deadline): String = when (deadline) {
+ is NewRequestDraft.Deadline.Preset ->
+ pluralStringResource(R.plurals.value_requestDaysToPay, deadline.daysToPay, deadline.daysToPay)
+ is NewRequestDraft.Deadline.Custom -> {
+ val at = deadline.at.toJavaInstant().atZone(ZoneId.systemDefault())
+ val date = DateTimeFormatter.ofPattern(monthDayPattern(abbreviated = true)).format(at)
+ val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).format(at)
+ stringResource(R.string.value_requestUntil, "$date, $time")
+ }
+}
+
+/** The request's name, in the editor the group's name uses. */
+@Composable
+private fun RequestNameContent(title: String, onBack: () -> Unit, onDone: (String) -> Unit) {
+ val field = rememberTextFieldState(initialText = title)
+ var length by remember { mutableIntStateOf(field.text.length) }
+ var valid by remember { mutableStateOf(field.text.isNotBlank()) }
+ LaunchedEffect(field) {
+ snapshotFlow { field.text.toString() }.collect { text ->
+ if (text.length > RequestMoneyViewModel.MAX_TITLE) {
+ field.edit { replace(0, length, text.take(RequestMoneyViewModel.MAX_TITLE)) }
+ }
+ length = field.text.length
+ valid = text.isNotBlank()
+ }
+ }
+ NameEditScaffold(
+ heading = stringResource(R.string.title_requestName),
+ placeholder = stringResource(R.string.label_requestName),
+ field = field,
+ buttonText = stringResource(R.string.action_done),
+ canSubmit = valid,
+ remaining = RequestMoneyViewModel.MAX_TITLE - length,
+ capitalization = KeyboardCapitalization.Sentences,
+ onBack = onBack,
+ onSubmit = { onDone(field.text.toString()) },
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ConfirmSendSheet(sending: Boolean, onSend: () -> Unit, onDismiss: () -> Unit) {
+ val sheetState = rememberModalBottomSheetState(
+ skipPartiallyExpanded = true,
+ confirmValueChange = { !sending },
+ )
+ ModalBottomSheet(
+ onDismissRequest = { if (!sending) onDismiss() },
+ sheetState = sheetState,
+ containerColor = CodeTheme.colors.background,
+ ) {
+ Column(
+ modifier = Modifier
+ .navigationBarsPadding()
+ .padding(horizontal = CodeTheme.dimens.inset)
+ .padding(bottom = CodeTheme.dimens.grid.x4),
+ verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3),
+ ) {
+ Text(
+ text = stringResource(R.string.title_sendRequestConfirm),
+ style = CodeTheme.typography.screenTitle,
+ color = CodeTheme.colors.textMain,
+ )
+ Text(
+ text = stringResource(R.string.description_sendRequestConfirm),
+ style = CodeTheme.typography.textMedium,
+ color = CodeTheme.colors.textSecondary,
+ )
+ CodeButton(
+ modifier = Modifier.fillMaxWidth().testTag("request_confirm_send"),
+ text = stringResource(R.string.action_sendRequest),
+ isLoading = sending,
+ onClick = onSend,
+ )
+ CodeButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.action_cancel),
+ buttonState = ButtonState.Subtle,
+ enabled = !sending,
+ onClick = onDismiss,
+ )
+ }
+ }
+}
+
+private fun Fiat.text(): String = formatted(Fiat.FormattingRule.Truncated)
+
+/** The locale's month-and-day pattern ("October 10"), without an ordinal or a year. */
+private fun monthDayPattern(abbreviated: Boolean = false): String =
+ android.text.format.DateFormat.getBestDateTimePattern(
+ java.util.Locale.getDefault(),
+ if (abbreviated) "MMMd" else "MMMMd",
+ )
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyViewModel.kt
new file mode 100644
index 0000000000..9af3ca5ce5
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyViewModel.kt
@@ -0,0 +1,317 @@
+package com.flipcash.app.messenger.internal.request
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.flipcash.app.core.internal.errors.showNetworkError
+import com.flipcash.app.core.ui.ConfirmationStyle
+import com.flipcash.features.messenger.R
+import com.flipcash.services.chat.GroupContentCrypto
+import com.flipcash.services.models.CreatePaymentRequestError
+import com.flipcash.services.models.PagingToken
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.ChatMember
+import com.flipcash.services.models.chat.IdempotencyKey
+import com.flipcash.services.models.paymentrequest.PaymentRequest
+import com.flipcash.services.user.UserManager
+import com.flipcash.shared.amountentry.AmountEntryDelegate
+import com.flipcash.shared.amountentry.AmountEntryLabel
+import com.flipcash.shared.amountentry.AmountEntryStyle
+import com.flipcash.shared.chat.ChatCoordinator
+import com.flipcash.shared.chat.GroupRosters
+import com.flipcash.shared.chat.PaymentRequests
+import com.getcode.manager.BottomBarAction
+import com.getcode.manager.BottomBarManager
+import com.flipcash.services.models.chat.MessageContent
+import com.flipcash.services.models.chat.WidgetContent
+import com.flipcash.shared.chat.PaymentRequestNotSent
+import com.getcode.opencode.exchange.Exchange
+import com.getcode.opencode.model.core.ID
+import com.getcode.opencode.model.financial.Fiat
+import com.getcode.utils.ErrorUtils
+import com.getcode.utils.trace
+import com.getcode.util.resources.ResourceHelper
+import com.getcode.view.LoadingSuccessState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import java.time.LocalDate
+import java.time.format.TextStyle
+import java.util.Locale
+import javax.inject.Inject
+import kotlin.time.Clock
+import kotlin.time.Instant
+
+/**
+ * Request Money history and the create flow behind its New Request button, frames 03–06 and 25 of
+ * the group requests spec. One instance per opening of the step, so a draft never outlives it.
+ */
+@HiltViewModel
+internal class RequestMoneyViewModel @Inject constructor(
+ exchange: Exchange,
+ private val resources: ResourceHelper,
+ private val requests: PaymentRequests,
+ private val rosters: GroupRosters,
+ private val crypto: GroupContentCrypto,
+ private val chatCoordinator: ChatCoordinator,
+ private val userManager: UserManager,
+) : ViewModel() {
+
+ enum class Stage { History, Amount, Summary, People, Name }
+
+ enum class Sheet { None, Deadline, Confirm }
+
+ data class State(
+ val chatId: ChatId? = null,
+ val stage: Stage = Stage.History,
+ val sheet: Sheet = Sheet.None,
+ val draft: NewRequestDraft? = null,
+ val sendProgress: LoadingSuccessState = LoadingSuccessState(),
+ val previousToken: PagingToken? = null,
+ val previousEnd: Boolean = false,
+ val loadingPrevious: Boolean = false,
+ )
+
+ sealed interface Effect {
+ /** The request is out (or its message is queued to retry from the chat): back to the chat. */
+ data object Sent : Effect
+ }
+
+ private val _state = MutableStateFlow(State())
+ val state: StateFlow = _state.asStateFlow()
+
+ private val effects = Channel(Channel.BUFFERED)
+ val effectFlow: Flow = effects.receiveAsFlow()
+
+ private val currency = exchange.preferredRate.currency
+
+ val amountDelegate = AmountEntryDelegate(
+ exchange = exchange,
+ scope = viewModelScope,
+ style = AmountEntryStyle(
+ actionLabel = AmountEntryLabel.Plain(resources.getString(R.string.action_next)),
+ actionStyle = ConfirmationStyle.Button,
+ ),
+ ).also { delegate ->
+ // Without a currency the keypad draws a bare number and enters with no fraction digits.
+ exchange.getCurrency(currency.name)?.let(delegate::onCurrencyChanged)
+ }
+
+ /** The chat's requests, refetched on open. History reads both sections from these. */
+ val chatRequests: StateFlow> = _state
+ .flatMapLatest { s -> s.chatId?.let(requests::inChat) ?: flowOf(emptyList()) }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+
+ /** Who Select People lists, the creator first. A member the roster doesn't know yet is left out. */
+ val members: StateFlow> = combine(_state, rosters.state) { s, all ->
+ val chatId = s.chatId ?: return@combine emptyList()
+ val roster = all[chatId].orEmpty()
+ val viewerId = userManager.accountId ?: return@combine emptyList()
+ NewRequestDraft.members(viewerId, roster).mapNotNull { id -> roster.firstOrNull { it.userId == id } }
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+
+ val viewerId: ID? get() = userManager.accountId
+
+ fun open(chatId: ChatId) {
+ if (_state.value.chatId == chatId) return
+ _state.update { it.copy(chatId = chatId) }
+ viewModelScope.launch { requests.loadChat(chatId) }
+ viewModelScope.launch { rosters.load(chatId) }
+ loadMorePrevious()
+ }
+
+ /** The next page of Previous Requests, while there is one. */
+ fun loadMorePrevious() {
+ val s = _state.value
+ val chatId = s.chatId ?: return
+ if (s.loadingPrevious || s.previousEnd) return
+ _state.update { it.copy(loadingPrevious = true) }
+ viewModelScope.launch {
+ requests.resolvedPage(chatId, s.previousToken)
+ .onSuccess { page ->
+ _state.update {
+ it.copy(
+ loadingPrevious = false,
+ previousToken = page.pagingToken,
+ previousEnd = !page.hasMore || page.pagingToken == null,
+ )
+ }
+ }
+ .onFailure { _state.update { it.copy(loadingPrevious = false) } }
+ }
+ }
+
+ fun newRequest() {
+ amountDelegate.reset()
+ _state.update { it.copy(stage = Stage.Amount, draft = null) }
+ }
+
+ /** Amount Per Person's Next. Zero stays put. */
+ fun amountEntered() {
+ val entered = amountDelegate.state.value.enteredAmount
+ if (entered <= 0.0) return
+ val perPerson = Fiat(entered, currency)
+ val viewerId = userManager.accountId ?: return
+ val chatId = _state.value.chatId ?: return
+ val roster = rosters.state.value[chatId].orEmpty()
+ _state.update { s ->
+ val draft = s.draft?.copy(perPerson = perPerson) ?: NewRequestDraft(
+ viewerId = viewerId,
+ members = NewRequestDraft.members(viewerId, roster),
+ title = defaultTitle(),
+ perPerson = perPerson,
+ )
+ s.copy(stage = Stage.Summary, draft = draft)
+ }
+ }
+
+ fun editPeople() = _state.update { it.copy(stage = Stage.People) }
+
+ fun editName() = _state.update { it.copy(stage = Stage.Name) }
+
+ fun editDeadline() = _state.update { it.copy(sheet = Sheet.Deadline) }
+
+ fun toggle(member: ID) {
+ val draft = _state.value.draft ?: return
+ // Members who arrived in the roster after the draft began join its order at the end.
+ val withMember = if (member in draft.members) draft else draft.copy(members = draft.members + listOf(member))
+ val selected = withMember.toggled(member)
+ if (selected == null) {
+ val (title, message) = maxPeopleNotice(resources)
+ BottomBarManager.showInfo(title = title, message = message)
+ return
+ }
+ _state.update { it.copy(draft = withMember.copy(selected = selected)) }
+ }
+
+ fun rename(title: String) {
+ val trimmed = title.trim()
+ if (trimmed.isEmpty()) return
+ _state.update { it.copy(stage = Stage.Summary, draft = it.draft?.copy(title = trimmed.take(MAX_TITLE))) }
+ }
+
+ fun chooseDeadline(deadline: NewRequestDraft.Deadline) {
+ _state.update { it.copy(sheet = Sheet.None, draft = it.draft?.copy(deadline = deadline)) }
+ }
+
+ fun dismissSheet() {
+ if (_state.value.sendProgress.loading) return
+ _state.update { it.copy(sheet = Sheet.None) }
+ }
+
+ /** The summary's button: the confirm sheet, with the send's idempotency key minted on first open. */
+ fun requestConfirm() {
+ val draft = _state.value.draft ?: return
+ if (!draft.canSend) return
+ _state.update {
+ it.copy(
+ sheet = Sheet.Confirm,
+ draft = draft.copy(idempotencyKey = draft.idempotencyKey ?: IdempotencyKey.random()),
+ )
+ }
+ }
+
+ /** Back within the flow. Returns false at History, where back leaves the step. */
+ fun back(): Boolean {
+ val s = _state.value
+ if (s.sheet != Sheet.None) {
+ dismissSheet()
+ return true
+ }
+ val previous = when (s.stage) {
+ Stage.History -> return false
+ Stage.Amount -> Stage.History
+ Stage.Summary -> Stage.Amount
+ Stage.People, Stage.Name -> Stage.Summary
+ }
+ // Leaving Amount back to History drops the draft; the request was never sent.
+ _state.update { it.copy(stage = previous, draft = if (previous == Stage.History) null else it.draft) }
+ return true
+ }
+
+ /** Confirm's Send Request: Create once per confirm, then the widget message. */
+ fun send() {
+ val s = _state.value
+ val chatId = s.chatId ?: return
+ val draft = s.draft ?: return
+ val key = draft.idempotencyKey ?: return
+ val viewerId = userManager.accountId ?: return
+ if (s.sendProgress.loading) return
+ _state.update { it.copy(sendProgress = LoadingSuccessState(loading = true)) }
+ viewModelScope.launch {
+ val requestId = draft.createdRequestId ?: run {
+ val text = crypto.sealPaymentRequestText(chatId, viewerId, draft.title, "")
+ .getOrElse { return@launch failed(it, chatId) }
+ val split = draft.toSplit(chatId, text, Clock.System.now())
+ ?: return@launch failed(IllegalStateException("draft not sendable"), chatId)
+ requests.create(split, key).getOrElse { return@launch failed(it, chatId) }.id
+ }
+ _state.update { it.copy(draft = it.draft?.copy(createdRequestId = requestId)) }
+ // The request is live, so the deck shows it whatever happens here. Only the message is
+ // retried, under the same request id; Create is never called again for this confirm.
+ chatCoordinator.sendPaymentRequest(chatId, requestId)
+ .onFailure { cause -> (cause as? PaymentRequestNotSent)?.let { messageNotSent(chatId, requestId, it) } }
+ _state.update { it.copy(sheet = Sheet.None, sendProgress = LoadingSuccessState(success = true)) }
+ effects.send(Effect.Sent)
+ }
+ }
+
+ /** The chat's own "Message Not Sent" error, retrying the same pending row. */
+ private fun messageNotSent(chatId: ChatId, requestId: ID, failure: PaymentRequestNotSent) {
+ BottomBarManager.showInfo(
+ title = resources.getString(R.string.title_messageNotSent),
+ message = resources.getString(R.string.description_messageNotSent),
+ actions = listOf(
+ BottomBarAction(text = resources.getString(R.string.action_retry)) {
+ viewModelScope.launch {
+ val content = MessageContent.Widget(WidgetContent.PaymentRequest(requestId))
+ chatCoordinator.retryMessage(chatId, failure.pendingClientIdHex, listOf(content))
+ }
+ },
+ ),
+ showCancel = true,
+ )
+ }
+
+ private suspend fun failed(error: Throwable, chatId: ChatId) {
+ _state.update { it.copy(sheet = Sheet.None, sendProgress = LoadingSuccessState()) }
+ val message = when (error) {
+ is CreatePaymentRequestError.Denied -> R.string.error_description_requestDenied
+ is CreatePaymentRequestError.InvalidEndsAt -> R.string.error_description_requestEndsAt
+ is CreatePaymentRequestError.InvalidShares -> {
+ rosters.load(chatId)
+ R.string.error_description_requestShares
+ }
+ is CreatePaymentRequestError.InvalidCurrency -> R.string.error_description_requestCurrency
+ else -> {
+ // Unreachable results from a private group, and network failures: the app's
+ // generic error.
+ trace(tag = "RequestMoney", message = "create failed: ${error::class.simpleName}", error = error)
+ return ErrorUtils.showNetworkError(resources)
+ }
+ }
+ BottomBarManager.showError(
+ title = resources.getString(R.string.error_title_cantSendRequest),
+ message = resources.getString(message),
+ )
+ }
+
+ private fun defaultTitle(): String = resources.getString(
+ R.string.label_defaultRequestTitle,
+ LocalDate.now().dayOfWeek.getDisplayName(TextStyle.FULL, Locale.getDefault()),
+ )
+
+ companion object {
+ const val MAX_TITLE = 100
+ }
+}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestStep.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestStep.kt
new file mode 100644
index 0000000000..2de466acec
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestStep.kt
@@ -0,0 +1,74 @@
+package com.flipcash.app.messenger.internal.request
+
+import android.os.Parcelable
+import com.getcode.navigation.NonDraggableRoute
+import com.getcode.navigation.flow.FlowStep
+import kotlinx.parcelize.Parcelize
+import kotlinx.serialization.Serializable
+
+/**
+ * The screens inside Request Money, on the screen's own nav host so each one slides in over the
+ * last rather than replacing it. [RequestMoneyViewModel.Stage] decides which is on top; the
+ * deadline and confirm sheets are drawn over [Summary], not steps.
+ *
+ * [NonDraggableRoute], as `ReportStep` is: the enclosing route is a sheet, and dragging it away
+ * would drop a half-made request. The app bar's close leaves instead.
+ */
+internal sealed interface RequestStep : FlowStep, Parcelable, NonDraggableRoute {
+ @Parcelize
+ @Serializable
+ data object History : RequestStep
+
+ @Parcelize
+ @Serializable
+ data object Amount : RequestStep
+
+ @Parcelize
+ @Serializable
+ data object Summary : RequestStep
+
+ @Parcelize
+ @Serializable
+ data object People : RequestStep
+
+ @Parcelize
+ @Serializable
+ data object Name : RequestStep
+
+ /** A request opened from [History]. Pushed and popped by the nav host; no stage follows it. */
+ @Parcelize
+ @Serializable
+ data class Details(val requestId: String) : RequestStep
+}
+
+/** The back stack that puts [stage] on top: History, then Amount, then Summary and its editors. */
+internal fun stackFor(stage: RequestMoneyViewModel.Stage): List {
+ val toSummary = listOf(RequestStep.History, RequestStep.Amount, RequestStep.Summary)
+ return when (stage) {
+ RequestMoneyViewModel.Stage.History -> listOf(RequestStep.History)
+ RequestMoneyViewModel.Stage.Amount -> listOf(RequestStep.History, RequestStep.Amount)
+ RequestMoneyViewModel.Stage.Summary -> toSummary
+ RequestMoneyViewModel.Stage.People -> toSummary + RequestStep.People
+ RequestMoneyViewModel.Stage.Name -> toSummary + RequestStep.Name
+ }
+}
+
+/**
+ * What the nav host does, seen from [step], to reach [target]: pop when [step] is no longer on the
+ * stack, push the next step when [step] is below the top, and nothing when it is the top. Applied
+ * one step at a time, each entry moving the stack on as it composes.
+ */
+internal sealed interface StackMove {
+ data object None : StackMove
+ data object Pop : StackMove
+ data class Push(val step: RequestStep) : StackMove
+}
+
+internal fun moveFrom(step: RequestStep, target: List): StackMove {
+ val index = target.indexOf(step)
+ return when {
+ index < 0 -> StackMove.Pop
+ index == target.lastIndex -> StackMove.None
+ else -> StackMove.Push(target[index + 1])
+ }
+}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPay.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPay.kt
new file mode 100644
index 0000000000..d2387b2d05
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPay.kt
@@ -0,0 +1,204 @@
+package com.flipcash.app.messenger.internal.request
+
+import com.flipcash.app.messenger.internal.request.NewRequestDraft.Deadline
+import java.time.DayOfWeek
+import java.time.LocalDate
+import java.time.LocalTime
+import java.time.ZoneId
+import java.time.ZonedDateTime
+import java.time.temporal.ChronoUnit
+import java.time.temporal.TemporalAdjusters
+import kotlin.time.Instant
+import kotlin.time.toJavaInstant
+import kotlin.time.toKotlinInstant
+
+/**
+ * What the Time To Pay sheet has chosen so far, before Done applies it to the draft. Swiping the
+ * sheet away drops it, so the draft keeps its previous [Deadline].
+ *
+ * @param now when the sheet opened; the grid, the custom bounds and the readout are measured from it.
+ * @param setTime the last time the user set in the Time row, which a tapped day keeps.
+ */
+internal data class TimeToPay(
+ val now: Instant,
+ val zone: ZoneId,
+ val choice: Choice,
+ val setTime: LocalTime? = null,
+) {
+ sealed interface Choice {
+ data class Preset(val preset: Deadline.Preset) : Choice
+ data class Custom(val day: LocalDate, val time: LocalTime) : Choice
+ }
+
+ val today: LocalDate get() = now.local().toLocalDate()
+
+ /** The last day the grid enables: today + 28 days. */
+ val lastDay: LocalDate get() = today.plusDays(LAST_DAY_OFFSET)
+
+ fun enabled(day: LocalDate): Boolean = !day.isBefore(today) && !day.isAfter(lastDay)
+
+ /** The grid's highlighted day: a preset's "around" date, or the custom day. */
+ val selectedDay: LocalDate
+ get() = when (choice) {
+ is Choice.Preset -> choice.preset.endsAt(now).local().toLocalDate()
+ is Choice.Custom -> choice.day
+ }
+
+ /** The custom choice as an instant, or null for a preset. */
+ val customAt: Instant?
+ get() = (choice as? Choice.Custom)?.let { ZonedDateTime.of(it.day, it.time, zone).toInstant().toKotlinInstant() }
+
+ /** How [day] draws in the range from today to [selectedDay]. */
+ fun range(day: LocalDate): RangeCell {
+ val end = selectedDay
+ return when {
+ day == end -> RangeCell(RangeCell.Mark.End, leading = day != today, trailing = false)
+ day == today -> RangeCell(RangeCell.Mark.Start, leading = false, trailing = true)
+ day.isAfter(today) && day.isBefore(end) -> RangeCell(RangeCell.Mark.None, leading = true, trailing = true)
+ else -> RangeCell.Outside
+ }
+ }
+
+ /** The tone of [day]'s month label, for a 1st of the month. */
+ fun monthTone(day: LocalDate): MonthTone {
+ val cell = range(day)
+ return when {
+ cell.mark == RangeCell.Mark.End -> MonthTone.End
+ cell.mark == RangeCell.Mark.Start -> MonthTone.Start
+ cell.leading && cell.trailing -> MonthTone.Band
+ else -> MonthTone.Plain
+ }
+ }
+
+ /**
+ * One week row's part of the range band, from [from] to [to] in columns from the row's start.
+ * An end is rounded only where the range wraps past the row's edge; where it meets the start or
+ * end circle it is square, so the band runs straight up to the circle.
+ */
+ data class BandSpan(
+ val row: Int,
+ val from: Float,
+ val to: Float,
+ val roundFrom: Boolean = false,
+ val roundTo: Boolean = false,
+ )
+
+ /** A month label's colour: on no range, inside the band, on the closing circle, on today's circle. */
+ enum class MonthTone { Plain, Band, End, Start }
+
+ /**
+ * One grid day's part of the range: its circle, and which halves the band covers. The band
+ * runs from the centre of the start circle to the centre of the end circle.
+ */
+ data class RangeCell(val mark: Mark, val leading: Boolean, val trailing: Boolean) {
+ enum class Mark { None, Start, End }
+
+ companion object {
+ val Outside = RangeCell(Mark.None, leading = false, trailing = false)
+ }
+ }
+
+ /**
+ * The Time row's value. A preset closes at the time of day the request is sent, so it reads
+ * the current time.
+ */
+ val time: LocalTime
+ get() = when (choice) {
+ is Choice.Preset -> now.local().toLocalTime().truncatedTo(ChronoUnit.MINUTES)
+ is Choice.Custom -> choice.time
+ }
+
+ /** Calendar days from today to the custom day. */
+ val daysFromNow: Long get() = ChronoUnit.DAYS.between(today, selectedDay)
+
+ fun choosePreset(preset: Deadline.Preset): TimeToPay = copy(choice = Choice.Preset(preset))
+
+ /**
+ * Makes [day] the custom choice, keeping the last time the user set or 6:00 PM, moved inside
+ * the custom bounds.
+ */
+ fun chooseDay(day: LocalDate): TimeToPay {
+ if (!enabled(day)) return this
+ return copy(choice = clamped(day, setTime ?: DEFAULT_TIME))
+ }
+
+ /**
+ * The Time row's new time, moved inside the custom bounds. On a preset it clears the chip:
+ * the preset's closing day at [time] becomes the custom choice.
+ */
+ fun chooseTime(time: LocalTime): TimeToPay =
+ copy(choice = clamped(selectedDay, time), setTime = time.truncatedTo(ChronoUnit.MINUTES))
+
+ /** What Done applies to the draft. */
+ fun toDeadline(): Deadline = when (choice) {
+ is Choice.Preset -> choice.preset
+ is Choice.Custom -> Deadline.Custom(customAt!!)
+ }
+
+ private fun clamped(day: LocalDate, time: LocalTime): Choice.Custom {
+ val range = Deadline.customRange(now)
+ // Rounded to the minute inward, so the bound itself stays inside the range.
+ val earliest = range.start.local().let { if (it.second == 0 && it.nano == 0) it else it.truncatedTo(ChronoUnit.MINUTES).plusMinutes(1) }
+ val latest = range.endInclusive.local().truncatedTo(ChronoUnit.MINUTES)
+ val at = ZonedDateTime.of(day, time.truncatedTo(ChronoUnit.MINUTES), zone)
+ val bounded = when {
+ at.isBefore(earliest) -> earliest
+ at.isAfter(latest) -> latest
+ else -> at
+ }
+ return Choice.Custom(bounded.toLocalDate(), bounded.toLocalTime())
+ }
+
+ private fun Instant.local(): ZonedDateTime = toJavaInstant().atZone(zone)
+
+ companion object {
+ const val GRID_DAYS = 35
+ private const val LAST_DAY_OFFSET = 28L
+ val DEFAULT_TIME: LocalTime = LocalTime.of(18, 0)
+
+ /** The sheet reopened on the draft's current [deadline]: its chip, or its day and time. */
+ fun of(deadline: Deadline, now: Instant, zone: ZoneId): TimeToPay {
+ val choice = when (deadline) {
+ is Deadline.Preset -> Choice.Preset(deadline)
+ is Deadline.Custom -> deadline.at.toJavaInstant().atZone(zone).let {
+ Choice.Custom(it.toLocalDate(), it.toLocalTime().truncatedTo(ChronoUnit.MINUTES))
+ }
+ }
+ return TimeToPay(now, zone, choice, setTime = (choice as? Choice.Custom)?.time)
+ }
+
+ /** The grid's 35 days, from the locale's first weekday of the week that holds [today]. */
+ /**
+ * The range band as one span per week row, in column units, from the centre of the start
+ * cell at [startIndex] to the centre of the (possibly moving) end at [endIndex]. A range
+ * that wraps ends at one row's edge and starts again at the next row's edge. Drawing a
+ * span per row rather than a piece per cell keeps a moving band free of seams.
+ */
+ fun bandSpans(startIndex: Int, endIndex: Float): List {
+ val from = startIndex + 0.5f
+ val to = endIndex + 0.5f
+ if (to <= from) return emptyList()
+ return (startIndex / 7..(to.toInt().coerceAtMost(GRID_DAYS - 1)) / 7).mapNotNull { row ->
+ val rowStart = row * 7f
+ val start = maxOf(from, rowStart)
+ val end = minOf(to, rowStart + 7)
+ if (end > start) {
+ BandSpan(
+ row = row,
+ from = start - rowStart,
+ to = end - rowStart,
+ roundFrom = from < rowStart,
+ roundTo = to > rowStart + 7,
+ )
+ } else {
+ null
+ }
+ }
+ }
+
+ fun grid(today: LocalDate, firstDayOfWeek: DayOfWeek): List {
+ val start = today.with(TemporalAdjusters.previousOrSame(firstDayOfWeek))
+ return List(GRID_DAYS) { start.plusDays(it.toLong()) }
+ }
+ }
+}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPaySheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPaySheet.kt
new file mode 100644
index 0000000000..42b2a5f2b4
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPaySheet.kt
@@ -0,0 +1,688 @@
+package com.flipcash.app.messenger.internal.request
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.VectorConverter
+import androidx.compose.animation.core.snap
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Keyboard
+import androidx.compose.material.icons.outlined.Schedule
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TimeInput
+import androidx.compose.material3.TimePicker
+import androidx.compose.material3.Typography
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.material3.rememberTimePickerState
+import androidx.compose.material3.ripple
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
+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.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.RoundRect
+import androidx.compose.ui.layout.onPlaced
+import androidx.compose.ui.layout.positionInParent
+import androidx.compose.ui.unit.toSize
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.invisibleToUser
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.selected
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.foundation.text.TextAutoSize
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.LineHeightStyle
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import com.flipcash.app.messenger.internal.request.NewRequestDraft.Deadline
+import com.flipcash.features.messenger.R
+import com.getcode.theme.CodeTheme
+import com.getcode.theme.White
+import com.getcode.theme.White05
+import com.getcode.theme.White10
+import com.getcode.ui.theme.CodeButton
+import com.flipcash.shared.chat.ui.media.rememberReducedMotion
+import kotlinx.coroutines.launch
+import kotlin.math.hypot
+import java.time.LocalDate
+import java.time.LocalTime
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+import java.time.format.TextStyle
+import java.time.temporal.WeekFields
+import java.util.Locale
+import kotlin.time.Clock
+import kotlin.time.toJavaInstant
+
+/**
+ * Time To Pay: Starts and Closes with a summary, the preset chips, a grid of the days a request can
+ * run to with the range drawn across it, the Time row and Done. The chip highlight, the closing-day
+ * circle and the band move to a new choice rather than swapping. Figma row H (nodes
+ * 12704:67, 12704:204, 12708:236). Done applies the choice; any other dismissal keeps [current].
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun TimeToPaySheet(
+ current: Deadline,
+ onChoose: (Deadline) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val zone = remember { ZoneId.systemDefault() }
+ var sheet by remember { mutableStateOf(TimeToPay.of(current, Clock.System.now(), zone)) }
+ var pickingTime by remember { mutableStateOf(false) }
+ val reducedMotion = rememberReducedMotion()
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ containerColor = CodeTheme.colors.background,
+ ) {
+ Column(
+ modifier = Modifier
+ .navigationBarsPadding()
+ .padding(horizontal = CodeTheme.dimens.inset)
+ .padding(bottom = CodeTheme.dimens.grid.x4),
+ verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3),
+ ) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.title_timeToPay),
+ style = CodeTheme.typography.screenTitle,
+ color = CodeTheme.colors.textMain,
+ textAlign = TextAlign.Center,
+ )
+ RangeFields(sheet)
+ PresetChips(
+ selected = (sheet.choice as? TimeToPay.Choice.Preset)?.preset,
+ reducedMotion = reducedMotion,
+ onChoose = { sheet = sheet.choosePreset(it) },
+ )
+ DayGrid(sheet, reducedMotion, onChoose = { sheet = sheet.chooseDay(it) })
+ TimeRow(time = sheet.time, onClick = { pickingTime = true })
+ CodeButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.action_done),
+ onClick = { onChoose(sheet.toDeadline()) },
+ )
+ }
+ }
+
+ if (pickingTime) {
+ TimeDialog(
+ initial = sheet.time,
+ onPicked = { pickingTime = false; sheet = sheet.chooseTime(it) },
+ onDismiss = { pickingTime = false },
+ )
+ }
+}
+
+@Composable
+private fun RangeFields(sheet: TimeToPay) {
+ val closes: String
+ val summary: String
+ when (val choice = sheet.choice) {
+ is TimeToPay.Choice.Preset -> {
+ val around = sheet.selectedDay.format(DateTimeFormatter.ofPattern(bestPattern("EEEMMMd")))
+ closes = stringResource(R.string.value_timeToPayClosesAround, around)
+ summary = pluralStringResource(
+ R.plurals.description_timeToPayPreset,
+ choice.preset.daysToPay,
+ choice.preset.daysToPay,
+ stringResource(choice.preset.label),
+ )
+ }
+ is TimeToPay.Choice.Custom -> {
+ val local = sheet.customAt!!.toJavaInstant().atZone(sheet.zone)
+ val date = DateTimeFormatter.ofPattern(bestPattern("EEEMMMd")).format(local)
+ val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).format(local)
+ closes = "$date, $time"
+ val days = sheet.daysFromNow.toInt()
+ summary = if (days == 0) stringResource(R.string.description_timeToPayCustomToday)
+ else pluralStringResource(R.plurals.description_timeToPayCustom, days, days)
+ }
+ }
+ Column(verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2)) {
+ RangeField(
+ modifier = Modifier.weight(1f),
+ label = stringResource(R.string.label_timeToPayStarts),
+ value = stringResource(R.string.value_timeToPayStarts),
+ outlined = false,
+ )
+ // Closes is the value this sheet edits, so it is the one read out when it changes.
+ RangeField(
+ modifier = Modifier.weight(1f).semantics { liveRegion = LiveRegionMode.Polite },
+ label = stringResource(R.string.label_timeToPayCloses),
+ value = closes,
+ outlined = true,
+ )
+ }
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = summary,
+ style = CodeTheme.typography.caption,
+ color = CodeTheme.colors.textSecondary,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
+@Composable
+private fun RangeField(label: String, value: String, outlined: Boolean, modifier: Modifier = Modifier) {
+ val shape = RoundedCornerShape(12.dp)
+ Column(
+ modifier = modifier
+ .clip(shape)
+ .background(White10)
+ .then(if (outlined) Modifier.border(1.5.dp, White, shape) else Modifier)
+ .semantics(mergeDescendants = true) {}
+ .padding(horizontal = CodeTheme.dimens.grid.x3, vertical = CodeTheme.dimens.grid.x2),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Text(text = label, style = CodeTheme.typography.caption, color = CodeTheme.colors.textSecondary)
+ Text(
+ text = value,
+ style = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.SemiBold),
+ color = CodeTheme.colors.textMain,
+ // A custom day and time is wider than a phone-width half; shrink it before cutting it.
+ autoSize = TextAutoSize.StepBased(
+ minFontSize = 12.sp,
+ maxFontSize = CodeTheme.typography.textMedium.fontSize,
+ ),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
+
+/**
+ * The preset chips. One white highlight slides to the chosen chip and fades out when the choice
+ * becomes custom.
+ */
+@Composable
+private fun PresetChips(selected: Deadline.Preset?, reducedMotion: Boolean, onChoose: (Deadline.Preset) -> Unit) {
+ val bounds = remember { mutableStateMapOf() }
+ val highlight = remember { Animatable(Rect.Zero, Rect.VectorConverter) }
+ val shown = remember { Animatable(0f) }
+ var placed by remember { mutableStateOf(false) }
+ val target = selected?.let { bounds[it] }
+ LaunchedEffect(selected, target) {
+ val spec = TimeToPayMotion.spec(reducedMotion)
+ when {
+ selected == null -> shown.animateTo(0f, spec)
+ target == null -> Unit
+ !placed -> {
+ highlight.snapTo(target)
+ shown.snapTo(1f)
+ placed = true
+ }
+ else -> {
+ launch { highlight.animateTo(target, TimeToPayMotion.spec(reducedMotion)) }
+ shown.animateTo(1f, spec)
+ }
+ }
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .horizontalScroll(rememberScrollState())
+ .drawBehind {
+ bounds.values.forEach { chip ->
+ drawRoundRect(White10, chip.topLeft, chip.size, CornerRadius(chip.height / 2))
+ }
+ val chip = highlight.value
+ drawRoundRect(White, chip.topLeft, chip.size, CornerRadius(chip.height / 2), alpha = shown.value)
+ },
+ horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
+ ) {
+ Deadline.Preset.entries.forEach { preset ->
+ val isSelected = preset == selected
+ val color by animateColorAsState(
+ targetValue = if (isSelected) Color.Black else CodeTheme.colors.textMain,
+ animationSpec = TimeToPayMotion.spec(reducedMotion),
+ label = "chip text",
+ )
+ Text(
+ modifier = Modifier
+ .onPlaced { bounds[preset] = Rect(it.positionInParent(), it.size.toSize()) }
+ .clip(CircleShape)
+ .clickable(role = Role.Button) { onChoose(preset) }
+ .semantics { this.selected = isSelected }
+ .padding(horizontal = 10.dp, vertical = 7.dp),
+ text = stringResource(preset.label),
+ style = CodeTheme.typography.caption,
+ color = color,
+ maxLines = 1,
+ )
+ }
+ }
+}
+
+/**
+ * The days a request can run to. The range draws over the grid as one layer: the band as one span
+ * per week row, today's grey circle, and one white closing-day circle that slides to a new day,
+ * across rows when the change crosses a week.
+ */
+@Composable
+private fun DayGrid(sheet: TimeToPay, reducedMotion: Boolean, onChoose: (LocalDate) -> Unit) {
+ val locale = Locale.getDefault()
+ val days = remember(sheet.today) { TimeToPay.grid(sheet.today, WeekFields.of(locale).firstDayOfWeek) }
+ val monthLabel = remember { DateTimeFormatter.ofPattern("MMM", locale) }
+ val fullDate = remember { DateTimeFormatter.ofPattern(bestPattern("EEEEMMMMd"), locale) }
+ val startIndex = days.indexOf(sheet.today)
+ val endIndex = days.indexOf(sheet.selectedDay)
+ // The end as a grid index for the band, and as a column and row for the circle.
+ val bandEnd = remember { Animatable(endIndex.toFloat()) }
+ val circle = remember { Animatable(cellOf(endIndex), Offset.VectorConverter) }
+ LaunchedEffect(endIndex) {
+ launch { bandEnd.animateTo(endIndex.toFloat(), TimeToPayMotion.spec(reducedMotion)) }
+ circle.animateTo(cellOf(endIndex), TimeToPayMotion.spec(reducedMotion))
+ }
+ val gap = CodeTheme.dimens.grid.x1
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(White05)
+ .padding(CodeTheme.dimens.grid.x2),
+ verticalArrangement = Arrangement.spacedBy(gap),
+ ) {
+ Row(modifier = Modifier.fillMaxWidth().semantics { invisibleToUser() }) {
+ days.take(7).forEach { day ->
+ Text(
+ modifier = Modifier.weight(1f),
+ text = day.dayOfWeek.getDisplayName(TextStyle.NARROW, locale),
+ style = CodeTheme.typography.caption,
+ color = CodeTheme.colors.textSecondary,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .drawBehind { rangeLayer(startIndex, endIndex, bandEnd.value, circle.value) },
+ verticalArrangement = Arrangement.spacedBy(TimeToPayGrid.RowGap),
+ ) {
+ days.chunked(7).forEachIndexed { row, week ->
+ Row(modifier = Modifier.fillMaxWidth()) {
+ week.forEachIndexed { column, day ->
+ val index = row * 7 + column
+ // A day turns black as the moving circle settles over it, not as it passes.
+ val cover = coverOf(circle.value, column, row)
+ DayCell(
+ modifier = Modifier.weight(1f),
+ day = day,
+ enabled = sheet.enabled(day),
+ mark = when {
+ cover > 0f -> TimeToPay.RangeCell.Mark.End
+ index == startIndex && endIndex != startIndex -> TimeToPay.RangeCell.Mark.Start
+ else -> TimeToPay.RangeCell.Mark.None
+ },
+ selected = index == endIndex,
+ cover = cover,
+ month = if (day.dayOfMonth == 1) day.format(monthLabel).uppercase(locale) else null,
+ monthTone = sheet.monthTone(day),
+ label = day.format(fullDate),
+ onClick = { onChoose(day) },
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun cellOf(index: Int) = Offset((index % 7).toFloat(), (index / 7).toFloat())
+
+/** How far the circle at [circle] covers the day at [column], [row]: 1 within a quarter cell, 0 past half. */
+private fun coverOf(circle: Offset, column: Int, row: Int): Float {
+ val distance = hypot(circle.x - column, circle.y - row)
+ return ((0.5f - distance) / 0.25f).coerceIn(0f, 1f)
+}
+
+/** The band, today's circle and the closing-day circle at [circle] (column, row), under the day numbers. */
+private fun DrawScope.rangeLayer(startIndex: Int, endIndex: Int, bandEnd: Float, circle: Offset) {
+ val cell = size.width / 7
+ val cellHeight = TimeToPayGrid.CellHeight.toPx()
+ val gap = TimeToPayGrid.RowGap.toPx()
+ val height = TimeToPayGrid.Circle.toPx()
+ val rtl = layoutDirection == LayoutDirection.Rtl
+ fun x(column: Float) = if (rtl) size.width - column * cell else column * cell
+ fun rowTop(row: Float) = row * (cellHeight + gap)
+ TimeToPay.bandSpans(startIndex, bandEnd).forEach { span ->
+ val left = minOf(x(span.from), x(span.to))
+ val right = maxOf(x(span.from), x(span.to))
+ // A span narrower than it is tall would draw as a thin capsule at the row's edge; fade it in.
+ val top = rowTop(span.row.toFloat()) + (cellHeight - height) / 2
+ val cap = CornerRadius(height / 2)
+ val roundLeft = if (rtl) span.roundTo else span.roundFrom
+ val roundRight = if (rtl) span.roundFrom else span.roundTo
+ drawPath(
+ path = Path().apply {
+ addRoundRect(
+ RoundRect(
+ left = left,
+ top = top,
+ right = right,
+ bottom = top + height,
+ topLeftCornerRadius = if (roundLeft) cap else CornerRadius.Zero,
+ bottomLeftCornerRadius = if (roundLeft) cap else CornerRadius.Zero,
+ topRightCornerRadius = if (roundRight) cap else CornerRadius.Zero,
+ bottomRightCornerRadius = if (roundRight) cap else CornerRadius.Zero,
+ )
+ )
+ },
+ color = RangeBand,
+ alpha = ((right - left) / height).coerceIn(0f, 1f),
+ )
+ }
+ if (endIndex != startIndex) {
+ val start = cellOf(startIndex)
+ drawCircle(RangeStart, height / 2, Offset(x(start.x + 0.5f), rowTop(start.y) + cellHeight / 2))
+ }
+ drawCircle(White, height / 2, Offset(x(circle.x + 0.5f), rowTop(circle.y) + cellHeight / 2))
+}
+
+@Composable
+private fun DayCell(
+ day: LocalDate,
+ enabled: Boolean,
+ mark: TimeToPay.RangeCell.Mark,
+ selected: Boolean,
+ cover: Float,
+ month: String?,
+ monthTone: TimeToPay.MonthTone,
+ label: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier
+ .height(TimeToPayGrid.CellHeight)
+ .then(
+ if (enabled) {
+ Modifier
+ .clickable(
+ interactionSource = null,
+ indication = DayRipple,
+ role = Role.Button,
+ onClick = onClick,
+ )
+ .clearAndSetSemantics {
+ contentDescription = label
+ role = Role.Button
+ this.selected = selected
+ }
+ } else {
+ Modifier.clearAndSetSemantics { invisibleToUser() }
+ }
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ val text = when (mark) {
+ TimeToPay.RangeCell.Mark.End -> lerp(CodeTheme.colors.textMain, Color.Black, cover)
+ TimeToPay.RangeCell.Mark.Start -> White
+ TimeToPay.RangeCell.Mark.None ->
+ if (enabled) CodeTheme.colors.textMain else CodeTheme.colors.textSecondary.copy(alpha = 0.5f)
+ }
+ val number = CodeTheme.typography.textMedium.let {
+ if (mark == TimeToPay.RangeCell.Mark.None) it else it.copy(fontWeight = FontWeight.Bold)
+ }
+ if (month == null) {
+ Text(text = day.dayOfMonth.toString(), style = number, color = text)
+ } else {
+ // Label and number centre as one group in the circle, trimmed to their glyphs so the
+ // space above the label matches the space below the number.
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = month,
+ style = number.copy(
+ fontSize = MonthLabelSize,
+ fontWeight = FontWeight.SemiBold,
+ lineHeight = MonthLabelSize,
+ lineHeightStyle = TightLines,
+ ),
+ color = monthColor(monthTone, cover, enabled),
+ )
+ Text(
+ text = day.dayOfMonth.toString(),
+ style = number.copy(lineHeight = number.fontSize, lineHeightStyle = TightLines),
+ color = text,
+ )
+ }
+ }
+ }
+}
+
+/** The month label's grey for [tone], blending to the closing circle's as it settles over the day. */
+private fun monthColor(tone: TimeToPay.MonthTone, cover: Float, enabled: Boolean): Color = when {
+ tone == TimeToPay.MonthTone.Start -> MonthOnStart
+ tone == TimeToPay.MonthTone.End || cover > 0f ->
+ lerp(if (tone == TimeToPay.MonthTone.Band) MonthInBand else MonthPlain, MonthOnEnd, cover)
+ tone == TimeToPay.MonthTone.Band && enabled -> MonthInBand
+ else -> MonthPlain
+}
+
+/** Clips the press ripple to the 34 dp selection circle; an unbounded ripple centres on the cell. */
+private val DayRipple = ripple(bounded = false, radius = TimeToPayGrid.Circle / 2)
+
+/** The day grid's geometry, matching iOS: rows of 38 dp cells 6 dp apart, a 34 dp circle centred in each. */
+internal object TimeToPayGrid {
+ val CellHeight = 38.dp
+ val RowGap = 6.dp
+ val Circle = 34.dp
+}
+
+private val MonthLabelSize = 8.sp
+private val TightLines = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.Both)
+private val MonthPlain = Color(0xFF7B7B7B)
+private val MonthInBand = Color(0xFF9E9E9E)
+private val MonthOnEnd = Color(0xFF595959)
+private val MonthOnStart = Color(0xFFD9D9D9)
+
+/** How the sheet's selection moves: a spring, or straight to the new place with reduce motion on. */
+internal object TimeToPayMotion {
+ fun spec(reducedMotion: Boolean): AnimationSpec =
+ if (reducedMotion) snap() else spring(Spring.DampingRatioNoBouncy, Spring.StiffnessMediumLow)
+}
+
+private val RangeStart = Color(0xFF6B6B6E)
+private val RangeBand = Color.White.copy(alpha = 0.12f)
+
+@Composable
+private fun TimeRow(time: LocalTime, onClick: () -> Unit) {
+ val text = time.format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT))
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(White05)
+ .clickable(role = Role.Button, onClick = onClick)
+ .padding(horizontal = CodeTheme.dimens.grid.x3, vertical = CodeTheme.dimens.grid.x2),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = stringResource(R.string.label_timeToPayTime),
+ style = CodeTheme.typography.textMedium,
+ color = CodeTheme.colors.textMain,
+ )
+ Text(
+ modifier = Modifier
+ .clip(RoundedCornerShape(8.dp))
+ .background(White10)
+ .padding(horizontal = CodeTheme.dimens.grid.x2, vertical = CodeTheme.dimens.grid.x1),
+ text = text,
+ style = CodeTheme.typography.textMedium,
+ color = CodeTheme.colors.textMain,
+ )
+ }
+}
+
+/** Material 3's time picker in a dialog, dial or keyboard. Cancel keeps the old time; OK applies it. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun TimeDialog(initial: LocalTime, onPicked: (LocalTime) -> Unit, onDismiss: () -> Unit) = PickerTheme {
+ val state = rememberTimePickerState(initialHour = initial.hour, initialMinute = initial.minute)
+ var typing by remember { mutableStateOf(false) }
+ Dialog(onDismissRequest = onDismiss) {
+ Column(
+ modifier = Modifier
+ .clip(CodeTheme.shapes.large)
+ // surfaceVariant is translucent; lay it on the background so the sheet doesn't show through.
+ .background(CodeTheme.colors.background)
+ .background(CodeTheme.colors.surfaceVariant)
+ .padding(CodeTheme.dimens.inset),
+ ) {
+ Text(
+ modifier = Modifier.padding(bottom = CodeTheme.dimens.grid.x3),
+ text = stringResource(R.string.title_timeToPaySelectTime),
+ style = CodeTheme.typography.caption,
+ color = CodeTheme.colors.textSecondary,
+ )
+ Box(modifier = Modifier.align(Alignment.CenterHorizontally)) {
+ if (typing) TimeInput(state = state) else TimePicker(state = state)
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconButton(onClick = { typing = !typing }) {
+ Icon(
+ imageVector = if (typing) Icons.Outlined.Schedule else Icons.Outlined.Keyboard,
+ contentDescription = stringResource(
+ if (typing) R.string.cd_timeToPayDial else R.string.cd_timeToPayKeyboard,
+ ),
+ tint = CodeTheme.colors.textSecondary,
+ )
+ }
+ Spacer(Modifier.weight(1f))
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
+ TextButton(onClick = { onPicked(LocalTime.of(state.hour, state.minute)) }) {
+ Text(stringResource(R.string.action_timeToPayOk))
+ }
+ }
+ }
+ }
+}
+
+private val Deadline.Preset.label: Int
+ get() = when (this) {
+ Deadline.Preset.Day -> R.string.option_timeToPay_day
+ Deadline.Preset.ThreeDays -> R.string.option_timeToPay_threeDays
+ Deadline.Preset.Week -> R.string.option_timeToPay_week
+ Deadline.Preset.TwoWeeks -> R.string.option_timeToPay_twoWeeks
+ Deadline.Preset.FourWeeks -> R.string.option_timeToPay_fourWeeks
+ }
+
+/** "Until Nov 5, 6:00 PM": the summary's custom value and the custom readout. */
+@Composable
+internal fun untilText(at: kotlin.time.Instant): String {
+ val local = at.toJavaInstant().atZone(ZoneId.systemDefault())
+ val date = DateTimeFormatter.ofPattern(bestPattern("MMMd")).format(local)
+ val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).format(local)
+ return stringResource(R.string.value_requestUntil, "$date, $time")
+}
+
+private fun bestPattern(skeleton: String): String =
+ android.text.format.DateFormat.getBestDateTimePattern(Locale.getDefault(), skeleton)
+
+/**
+ * Material 3's pickers read `MaterialTheme`, which the app never sets up (it runs on Material 2).
+ * This maps the app's colors and type onto it so the pickers look like the rest of the app.
+ */
+@Composable
+private fun PickerTheme(content: @Composable () -> Unit) {
+ val colors = CodeTheme.colors
+ val type = CodeTheme.typography
+ MaterialTheme(
+ colorScheme = darkColorScheme(
+ primary = colors.textMain,
+ onPrimary = colors.background,
+ primaryContainer = colors.textMain,
+ onPrimaryContainer = colors.background,
+ secondaryContainer = White10,
+ onSecondaryContainer = colors.textMain,
+ tertiaryContainer = White10,
+ onTertiaryContainer = colors.textMain,
+ background = colors.background,
+ onBackground = colors.textMain,
+ surface = colors.surfaceVariant,
+ onSurface = colors.textMain,
+ surfaceVariant = White10,
+ onSurfaceVariant = colors.textMain,
+ surfaceContainer = colors.surfaceVariant,
+ surfaceContainerLow = colors.surfaceVariant,
+ surfaceContainerHigh = colors.surfaceVariant,
+ surfaceContainerHighest = White10,
+ surfaceTint = Color.Transparent,
+ outline = colors.border,
+ outlineVariant = colors.divider,
+ error = colors.errorText,
+ ),
+ typography = Typography(
+ displayLarge = type.displaySmall,
+ headlineLarge = type.screenTitleLarge,
+ headlineMedium = type.screenTitle,
+ headlineSmall = type.screenTitle,
+ titleLarge = type.screenTitle,
+ titleMedium = type.textLarge,
+ titleSmall = type.textMedium,
+ bodyLarge = type.textLarge,
+ bodyMedium = type.textMedium,
+ bodySmall = type.textSmall,
+ labelLarge = type.textMedium,
+ labelMedium = type.textSmall,
+ labelSmall = type.caption,
+ ),
+ content = content,
+ )
+}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt
index a0610d66d7..34d6b08945 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt
@@ -1,13 +1,14 @@
package com.flipcash.app.messenger.internal.screens
+import com.flipcash.services.models.paymentrequest.unpaidShareOf
+import com.flipcash.app.messenger.internal.request.RequestDeckStrip
+import com.flipcash.app.messenger.internal.request.rememberDeckHostState
import android.os.Parcelable
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
-import androidx.compose.animation.core.animateDpAsState
-import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
@@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
+import androidx.compose.animation.core.rememberTransition
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import com.flipcash.shared.chat.ui.media.ChatPhotoOverlay
@@ -28,6 +30,7 @@ import com.flipcash.app.updates.LocalAppUpdater
import com.flipcash.features.messenger.R
import androidx.compose.ui.res.stringResource
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.rememberUpdatedState
import com.flipcash.shared.chat.alerts.LocalReactionAlerts
import com.flipcash.shared.chat.alerts.ReactionAlertAction
@@ -37,9 +40,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.BlurredEdgeTreatment
-import androidx.compose.ui.draw.blur
-import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.boundsInWindow
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
@@ -57,6 +61,7 @@ import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.app.core.chat.ChatParticipant
import com.flipcash.app.core.extensions.navigateAll
import com.flipcash.app.core.chat.ChatStep
+import com.getcode.navigation.flow.flowSharedViewModel
import com.getcode.navigation.flow.rememberFlowNavigator
import com.flipcash.app.core.chat.ProfileAddress
import com.flipcash.app.core.links.ExternalLinkUriHandler
@@ -70,6 +75,8 @@ import com.flipcash.app.messenger.internal.withLiveSelfReactions
import com.flipcash.app.messenger.internal.MentionDestination
import com.flipcash.app.messenger.internal.link.CashCardTap
import com.flipcash.app.messenger.internal.screens.components.ChatTopBar
+import com.flipcash.app.messenger.internal.screens.components.deckBackdropSpec
+import com.flipcash.app.messenger.internal.screens.components.chatBackdrop
import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge
import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge.softTopEdge
import com.flipcash.app.messenger.internal.screens.components.GatedLandingRow
@@ -100,6 +107,16 @@ import kotlinx.coroutines.flow.filterIsInstance
import com.flipcash.services.models.chat.BlobAccessContext
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.shared.common.ui.profile.PrefetchProfileCover
+import com.flipcash.app.messenger.internal.request.ChatRequestsViewModel
+import com.flipcash.app.messenger.internal.request.rememberPaymentRequestCards
+import com.flipcash.app.messenger.internal.request.ExpandedRequests
+import com.flipcash.app.messenger.internal.request.RequestDeckLayout
+import com.flipcash.app.messenger.internal.request.RequestDeckPill
+import com.flipcash.app.messenger.internal.request.deckTitle
+import com.flipcash.shared.chat.ui.request.LocalPaymentRequestCards
+import com.flipcash.services.models.chat.ChatType
+import com.getcode.utils.base58
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@Composable
internal fun MessengerScreen(viewModel: ChatViewModel) {
@@ -449,14 +466,57 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
}
// The group's profile is one tap away; have its cover downloaded before that tap.
+ // The flow's, so the Request details step can minimize the deck once it covers the chat.
+ val requestsViewModel = flowSharedViewModel()
+ val requestCards = rememberPaymentRequestCards(
+ viewModel = requestsViewModel,
+ chatId = state.chatId.takeIf { state.chatType == ChatType.GROUP },
+ onOpen = { chatId, id -> keyboard.hideIfVisible { navigator.push(ChatStep.RequestDetails(chatId, id.base58)) } },
+ )
+
(state.subject as? ChatSubject.Group)?.let { group ->
PrefetchProfileCover(group.coverPicture, BlobAccessContext.ChatProfile(group.chatId))
}
- Box(modifier = Modifier.fillMaxSize()) {
+ // Before the deck is read, so requests already held are there on the first frame.
+ val deckChat = state.chatId.takeIf { state.chatType == ChatType.GROUP }
+ remember(deckChat) { deckChat?.let(requestsViewModel::attach) }
+ val deck by requestsViewModel.deck.collectAsStateWithLifecycle()
+ val deckExpanded by requestsViewModel.expanded.collectAsStateWithLifecycle()
+ val deckNow by requestsViewModel.now.collectAsStateWithLifecycle()
+ val drawnDeck = remember(deck) { deck.filter { it.deckTitle != null } }
+ val viewerId = requestsViewModel.viewerId
+ // One per visit: reopening the chat while still owing brings the strip back.
+ val owed = drawnDeck.filter { it.unpaidShareOf(viewerId, deckNow) != null }.map { it.id }.toSet()
+ val deckHosts = rememberDeckHostState(deckChat, owed)
+ val deckTransition = rememberTransition(deckHosts.transition, label = "request deck")
+ // Read here rather than in the effect, so expanding recomposes this scope and reaches the hosts.
+ val expandedNow = deckExpanded
+ SideEffect {
+ deckHosts.owing(owed, hasDeck = drawnDeck.isNotEmpty())
+ deckHosts.expanded = expandedNow
+ }
+ val expandDeck = {
+ // Partway into the pill, a tap waits for the scroll to settle it.
+ if (!deckHosts.collapsing) keyboard.hideIfVisible { requestsViewModel.setExpanded(true) }
+ }
+ val openRequest: (com.flipcash.services.models.paymentrequest.PaymentRequest) -> Unit = { request ->
+ // The deck stays expanded while the details slide over it; the step minimizes it once covered.
+ state.chatId?.let { chatId ->
+ keyboard.hideIfVisible { navigator.push(ChatStep.RequestDetails(chatId, request.id.base58)) }
+ }
+ }
+
+ // The collapsed pill's last bounds, where the expanded deck puts its close button.
+ var deckPill by remember { mutableStateOf(null) }
+
+ RequestDeckLayout(modifier = Modifier.fillMaxSize()) {
CodeScaffold(
// The input bar rides the keyboard; the message list is inset by it either way.
- modifier = Modifier.imePadding(),
+ // The expanded request deck raises its cards over the whole screen, the bars included.
+ modifier = Modifier
+ .imePadding()
+ .chatBackdrop(behind = deckExpanded, animationSpec = deckBackdropSpec(deckExpanded)),
// The list runs the full height and passes under both bars, each of which fades it out
// against the background at its own edge.
barPlacement = ScaffoldBarPlacement.Overlay,
@@ -471,7 +531,42 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
onBarHeightChange = { barHeight = it },
chatActionHandler = chatActionHandler,
dispatch = viewModel::dispatchEvent,
+ trailing = {
+ if (state.chatType == ChatType.GROUP) {
+ RequestDeckPill(
+ deck = drawnDeck,
+ now = deckNow,
+ viewerId = viewerId,
+ transition = deckTransition,
+ onExpand = expandDeck,
+ onCycle = requestsViewModel::cycle,
+ modifier = Modifier.onGloballyPositioned {
+ // Kept from the last frame it had a size; it empties while expanded.
+ if (it.size.width > 0) deckPill = it.boundsInWindow()
+ },
+ )
+ }
+ },
)
+ if (state.chatType == ChatType.GROUP) {
+ RequestDeckStrip(
+ deck = drawnDeck,
+ now = deckNow,
+ viewerId = viewerId,
+ transition = deckTransition,
+ rest = deckHosts.rest.host,
+ onExpand = expandDeck,
+ onCycle = requestsViewModel::cycle,
+ onPay = { request ->
+ state.chatId?.let { chatId ->
+ keyboard.hideIfVisible {
+ navigator.push(ChatStep.RequestDetails(chatId, request.id.base58, pay = true))
+ }
+ }
+ },
+ onHeightChange = { deckHosts.stripPx = it.toFloat() },
+ )
+ }
// Hold the last list through the exit, so the card doesn't empty as it slides away.
var lastRequests by remember { mutableStateOf(state.joinRequests) }
if (state.joinRequests.isNotEmpty()) lastRequests = state.joinRequests
@@ -512,20 +607,8 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
// as its rows are, and a tap on it dismisses the selection as a tap on them does. An
// edit raises a message too, but it is the composer doing the editing, so it stays sharp.
val behindBackdrop = state.selection != null && state.editing == null
- val dimAlpha by animateFloatAsState(
- targetValue = if (behindBackdrop) 0.4f else 1f,
- label = "composerDim",
- )
- val dimBlur by animateDpAsState(
- targetValue = if (behindBackdrop) 8.dp else 0.dp,
- label = "composerBlur",
- )
Box {
- Box(
- modifier = Modifier
- .blur(dimBlur, BlurredEdgeTreatment.Unbounded)
- .graphicsLayer { alpha = dimAlpha },
- ) {
+ Box(modifier = Modifier.chatBackdrop(behind = behindBackdrop)) {
UserControlBottomBar(
state = state,
hazeState = hazeState,
@@ -560,6 +643,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
LocalChatMediaProgress provides mediaProgressOf,
LocalChatPhotoSources provides photoSources,
LocalUpdateAppAction provides updateAppAction,
+ LocalPaymentRequestCards provides requestCards,
) {
// Held for the saved link previews, 300 ms at most and the first chat only.
if (previewsReady) {
@@ -567,6 +651,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
modifier = Modifier
.fillMaxSize()
.testTag("chat_message_list")
+ .nestedScroll(deckHosts.scrollConnection)
.softTopEdge(ChatTopEdge.blurHold(barHeight))
.hazeSource(hazeState)
// Inside the edge blur and the haze, so a photo landing under the bars
@@ -609,6 +694,18 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
}
}
+ ExpandedRequests(
+ deck = drawnDeck,
+ now = deckNow,
+ viewerId = viewerId,
+ expanded = deckExpanded,
+ transition = deckTransition,
+ top = barHeight,
+ pill = deckPill,
+ onCollapse = { requestsViewModel.setExpanded(false) },
+ onOpen = openRequest,
+ )
+
openPhoto?.let { opened ->
// The bubble the photo opened from is a snapshot; the strip highlights the live picks.
val live by remember(opened.messageId) { viewModel.selfReactionsOf(opened.messageId) }
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt
index c36ebc2906..71ac8d3c69 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt
@@ -71,6 +71,10 @@ internal fun GroupSelectPeopleContent(
onBack: () -> Unit,
onToggle: (ChatMember) -> Unit,
onSend: () -> Unit,
+ buttonText: String = pluralStringResource(R.plurals.action_sendToPeople, selected.size, selected.size),
+ buttonEnabled: Boolean = selected.isNotEmpty(),
+ /** Shown as "You" rather than by name, for a list the viewer is on. */
+ viewerId: ID? = null,
) {
val selectedIds = selected.toSet()
CodeScaffold(
@@ -88,13 +92,14 @@ internal fun GroupSelectPeopleContent(
.navigationBarsPadding()
.padding(horizontal = CodeTheme.dimens.inset)
.padding(bottom = CodeTheme.dimens.grid.x2),
- text = pluralStringResource(R.plurals.action_sendToPeople, selected.size, selected.size),
- enabled = selected.isNotEmpty(),
+ text = buttonText,
+ enabled = buttonEnabled,
onClick = onSend,
)
},
) { innerPadding ->
val unknownName = stringResource(R.string.title_unnamedUser)
+ val you = stringResource(R.string.label_selectPeople_you)
LazyColumn(
modifier = Modifier
.fillMaxSize()
@@ -114,7 +119,11 @@ internal fun GroupSelectPeopleContent(
items(candidates, key = { it.userId }) { member ->
MemberCheckRow(
member = member,
- name = chatterName(member.userProfile.displayName, member.userProfile.username, unknownName),
+ name = if (member.userId == viewerId) {
+ you
+ } else {
+ chatterName(member.userProfile.displayName, member.userProfile.username, unknownName)
+ },
selected = member.userId in selectedIds,
onClick = { onToggle(member) },
)
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBackdrop.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBackdrop.kt
new file mode 100644
index 0000000000..487ffe66a2
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBackdrop.kt
@@ -0,0 +1,48 @@
+package com.flipcash.app.messenger.internal.screens.components
+
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.BlurredEdgeTreatment
+import androidx.compose.ui.draw.blur
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.unit.dp
+
+/**
+ * The chat's backdrop: what sits [behind] a raised message, or the expanded request deck, is
+ * dimmed and blurred. Applied to each surface that goes behind, rather than drawn as a layer over
+ * them, so the thing raised in front stays sharp wherever it sits in the tree.
+ *
+ * [animationSpec] runs 0 (sharp) to 1 (behind); a caller pairing the backdrop with a transition of
+ * its own passes that transition's spec so the two move together.
+ */
+@Composable
+internal fun Modifier.chatBackdrop(
+ behind: Boolean,
+ animationSpec: AnimationSpec = spring(),
+): Modifier {
+ val fraction by animateFloatAsState(
+ targetValue = if (behind) 1f else 0f,
+ animationSpec = animationSpec,
+ label = "chatBackdrop",
+ )
+ // Unbounded: the rectangle treatment would clip the blur at each surface's own edges and leave
+ // a hard seam between neighbouring rows.
+ return blur(BACKDROP_BLUR * fraction, BlurredEdgeTreatment.Unbounded)
+ .graphicsLayer { alpha = 1f - (1f - BACKDROP_ALPHA) * fraction }
+}
+
+/**
+ * The request deck's backdrop. In, it keeps the pace of the spring sharedBounds moves the card on.
+ * Out, it runs stiffer: the pill lands in the app bar, which is behind the backdrop, and on the
+ * matching spring the blur's tail would still be on the pill for a moment after it settles.
+ */
+internal fun deckBackdropSpec(expanded: Boolean): AnimationSpec =
+ spring(stiffness = if (expanded) Spring.StiffnessMediumLow else Spring.StiffnessMedium)
+
+private val BACKDROP_BLUR = 8.dp
+private const val BACKDROP_ALPHA = 0.4f
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt
index 4ab2e49c1f..59202ee4ca 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt
@@ -79,6 +79,7 @@ internal fun ChatTopBar(
onBarHeightChange: (Dp) -> Unit,
chatActionHandler: ChatActionHandler,
dispatch: (ChatViewModel.Event) -> Unit,
+ trailing: @Composable () -> Unit = {},
) {
val bgColor = CodeTheme.colors.background
val statusBars = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
@@ -107,7 +108,7 @@ internal fun ChatTopBar(
label = "chat top bar",
) { target ->
when (target) {
- TopBarMode.Conversation -> ConversationTitleBar(navigator, state, chatActionHandler)
+ TopBarMode.Conversation -> ConversationTitleBar(navigator, state, chatActionHandler, trailing)
TopBarMode.Editing -> EditingBar(dispatch)
is TopBarMode.Selecting -> MessageSelectionBar(
selection = target.selection,
@@ -169,6 +170,7 @@ private fun ConversationTitleBar(
navigator: CodeNavigator,
state: ChatViewModel.State,
chatActionHandler: ChatActionHandler,
+ trailing: @Composable () -> Unit,
) {
AppBarWithTitle(
leftIcon = {
@@ -255,6 +257,9 @@ private fun ConversationTitleBar(
)
}
}
+ // The payment request deck, beside the title rather than in the actions slot, so
+ // the bar keeps its height (group requests spec, concept B).
+ trailing()
}
}
)
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt
index 76134c0e2d..a3da262819 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt
@@ -41,8 +41,6 @@ import com.flipcash.shared.chat.ui.BubblePosition
import com.getcode.ui.components.chat.TypingDots
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
-import androidx.compose.ui.draw.BlurredEdgeTreatment
-import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
@@ -202,15 +200,6 @@ internal fun MessageRow(
}
}
- val dimAlpha by animateFloatAsState(
- targetValue = if (focused) 1f else 0.4f,
- label = "messageDim",
- )
- val dimBlur by animateDpAsState(
- targetValue = if (focused) 0.dp else 8.dp,
- label = "messageBlur",
- )
-
// The row answers the finger before the long-press resolves: it dips while held,
// then springs up and stays lifted for as long as it is the selected message.
val pressed by interactionSource.collectIsPressedAsState()
@@ -236,14 +225,6 @@ internal fun MessageRow(
// The selected message's own pills stay behind the backdrop with the rest of the transcript,
// so they take the same dim and blur the other rows do.
val pillsBehindBackdrop = selecting && focused
- val pillDimAlpha by animateFloatAsState(
- targetValue = if (pillsBehindBackdrop) 0.4f else 1f,
- label = "pillDim",
- )
- val pillDimBlur by animateDpAsState(
- targetValue = if (pillsBehindBackdrop) 8.dp else 0.dp,
- label = "pillBlur",
- )
val swipe = rememberSwipeToReply(
enabled = bubble != null &&
@@ -263,10 +244,7 @@ internal fun MessageRow(
Box(
modifier = Modifier
.padding(bottom = bottomSpacing)
- // Unbounded: the rectangle treatment would clip the blur at the row's own
- // edges and leave a hard seam between neighbouring rows.
- .blur(dimBlur, BlurredEdgeTreatment.Unbounded)
- .graphicsLayer { alpha = dimAlpha }
+ .chatBackdrop(behind = !focused)
// No row gestures while the backdrop is up: the rows are behind it, and a
// press there would move the selection out from under the message the bar —
// or the composer — is already acting on.
@@ -494,8 +472,7 @@ internal fun MessageRow(
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
- .blur(pillDimBlur, BlurredEdgeTreatment.Unbounded)
- .graphicsLayer { alpha = pillDimAlpha }
+ .chatBackdrop(behind = pillsBehindBackdrop)
.addIf(selecting) { Modifier.blockPointerInput() },
) {
ReactionPillRow(
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameScreen.kt
index 1087bc1d3c..01df045f3d 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameScreen.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameScreen.kt
@@ -1,37 +1,18 @@
package com.flipcash.app.messenger.internal.screens.profile.edit
import android.os.Parcelable
-import androidx.compose.foundation.layout.Column
-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.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.chat.ChatStep
-import com.flipcash.app.core.ui.DisplayTextInput
import com.flipcash.app.messenger.internal.ChatSubject
import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.features.messenger.R
import com.getcode.navigation.flow.rememberFlowNavigator
-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
@@ -51,7 +32,6 @@ internal fun EditGroupNameScreen(chatViewModel: ChatViewModel) {
val viewModel = hiltViewModel()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
- val keyboard = rememberKeyboardController()
// Keyed on the chat rather than Unit: the seed has to wait for the group to resolve, and must
// not run again afterwards or it would overwrite the edit in progress.
@@ -74,83 +54,17 @@ internal fun EditGroupNameScreen(chatViewModel: ChatViewModel) {
.launchIn(this)
}
- // Laid out as NameEntryScreen lays out the same edit: the bar carries the back control and no
- // title, and the heading sits inside the scaffold above the field, so it scrolls and insets
- // with the content rather than floating in a bar over it.
- Column {
- AppBarWithTitle(
- onBackIconClicked = { keyboard.hideIfVisible { flowNavigator.back() } },
- )
- CodeScaffold(
- modifier = Modifier.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_setGroupName),
- style = CodeTheme.typography.textLarge,
- color = CodeTheme.colors.textMain,
- )
- },
- bottomBar = {
- Column(
- modifier = Modifier
- .navigationBarsPadding()
- .imePadding(),
- ) {
- // Only once the end is near, so it reads as a warning and not as furniture.
- val remaining = state.remaining
- if (remaining < ChatTitle.COUNTER_THRESHOLD) {
- Text(
- modifier = Modifier.padding(bottom = CodeTheme.dimens.grid.x3),
- text = stringResource(R.string.label_groupNameRemaining, remaining),
- style = CodeTheme.typography.textSmall,
- color = CodeTheme.colors.textSecondary,
- )
- }
- CodeButton(
- modifier = Modifier
- .fillMaxWidth()
- .padding(bottom = CodeTheme.dimens.grid.x3),
- text = stringResource(R.string.action_save),
- enabled = state.canSubmit,
- isLoading = state.processingState.loading,
- isSuccess = state.processingState.success,
- onClick = {
- keyboard.hideIfVisible {
- viewModel.dispatchEvent(EditGroupNameViewModel.Event.SaveClicked)
- }
- },
- )
- }
- },
- ) { padding ->
- val focusRequester = remember { FocusRequester() }
- Column(modifier = Modifier.padding(padding)) {
- DisplayTextInput(
- state = state.titleFieldState,
- placeholder = stringResource(R.string.hint_groupNameEdit),
- modifier = Modifier
- .fillMaxWidth()
- .focusRequester(focusRequester),
- keyboardOptions = KeyboardOptions(
- capitalization = KeyboardCapitalization.Words,
- keyboardType = KeyboardType.Text,
- imeAction = ImeAction.Done,
- ),
- onKeyboardAction = {
- keyboard.hideIfVisible {
- viewModel.dispatchEvent(EditGroupNameViewModel.Event.SaveClicked)
- }
- },
- )
- }
-
- LaunchedEffect(Unit) { focusRequester.requestFocus() }
- }
- }
+ NameEditScaffold(
+ heading = stringResource(R.string.title_setGroupName),
+ placeholder = stringResource(R.string.hint_groupNameEdit),
+ field = state.titleFieldState,
+ buttonText = stringResource(R.string.action_save),
+ canSubmit = state.canSubmit,
+ remaining = state.remaining,
+ capitalization = KeyboardCapitalization.Words,
+ isLoading = state.processingState.loading,
+ isSuccess = state.processingState.success,
+ onBack = { flowNavigator.back() },
+ onSubmit = { viewModel.dispatchEvent(EditGroupNameViewModel.Event.SaveClicked) },
+ )
}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/NameEditScaffold.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/NameEditScaffold.kt
new file mode 100644
index 0000000000..05813a5eee
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/NameEditScaffold.kt
@@ -0,0 +1,121 @@
+package com.flipcash.app.messenger.internal.screens.profile.edit
+
+import androidx.compose.foundation.layout.Column
+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.TextFieldState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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 com.flipcash.app.core.ui.DisplayTextInput
+import com.flipcash.features.messenger.R
+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
+
+/**
+ * The single-field name editor the group's title uses, laid out as NameEntryScreen lays out the
+ * same edit: the bar carries the back control and no title, and the heading sits inside the
+ * scaffold above the field, so it scrolls and insets with the content rather than floating in a
+ * bar over it. A payment request's name reuses it so both edits look and behave alike.
+ *
+ * [remaining] shows a counter once fewer than [ChatTitle.COUNTER_THRESHOLD] characters are left.
+ */
+@Composable
+internal fun NameEditScaffold(
+ heading: String,
+ placeholder: String,
+ field: TextFieldState,
+ buttonText: String,
+ canSubmit: Boolean,
+ remaining: Int,
+ capitalization: KeyboardCapitalization,
+ onBack: () -> Unit,
+ onSubmit: () -> Unit,
+ isLoading: Boolean = false,
+ isSuccess: Boolean = false,
+) {
+ val keyboard = rememberKeyboardController()
+ val submit = { if (canSubmit) keyboard.hideIfVisible(onSubmit) }
+
+ Column {
+ AppBarWithTitle(
+ onBackIconClicked = { keyboard.hideIfVisible(onBack) },
+ )
+ CodeScaffold(
+ modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset),
+ topBar = {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth(0.7f)
+ .padding(
+ top = CodeTheme.dimens.grid.x2,
+ bottom = CodeTheme.dimens.inset,
+ ),
+ text = heading,
+ style = CodeTheme.typography.textLarge,
+ color = CodeTheme.colors.textMain,
+ )
+ },
+ bottomBar = {
+ Column(
+ modifier = Modifier
+ .navigationBarsPadding()
+ .imePadding(),
+ ) {
+ // Only once the end is near, so it reads as a warning and not as furniture.
+ if (remaining < ChatTitle.COUNTER_THRESHOLD) {
+ Text(
+ modifier = Modifier.padding(bottom = CodeTheme.dimens.grid.x3),
+ text = stringResource(R.string.label_groupNameRemaining, remaining),
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textSecondary,
+ )
+ }
+ CodeButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = CodeTheme.dimens.grid.x3),
+ text = buttonText,
+ enabled = canSubmit,
+ isLoading = isLoading,
+ isSuccess = isSuccess,
+ onClick = submit,
+ )
+ }
+ },
+ ) { padding ->
+ val focusRequester = remember { FocusRequester() }
+ Column(modifier = Modifier.padding(padding)) {
+ DisplayTextInput(
+ state = field,
+ placeholder = placeholder,
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ keyboardOptions = KeyboardOptions(
+ capitalization = capitalization,
+ keyboardType = KeyboardType.Text,
+ imeAction = ImeAction.Done,
+ ),
+ onKeyboardAction = { submit() },
+ )
+ }
+
+ LaunchedEffect(Unit) { focusRequester.requestFocus() }
+ }
+ }
+}
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt
index 015900c114..f4f5f5e070 100644
--- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt
@@ -239,6 +239,30 @@ class ChatGroupCashLinkTest {
assertTrue(events.any { it is ChatViewModel.Event.NavigateToAmountEntry })
}
+ @Test
+ fun `a private group's cash button offers Send Money or Request Money`() = runTest(mainCoroutineRule.dispatcher) {
+ val vm = createViewModel()
+ val events = recordEvents(vm)
+ vm.openGroup(isPrivate = true)
+
+ vm.dispatchEvent(ChatViewModel.Event.OnSendCash)
+ advanceUntilIdle()
+
+ val sheet = BottomBarManager.messages.value.single()
+ val actions = sheet.actions
+ // The spec's sheet is two rows: no title and no Dismiss.
+ assertEquals("", sheet.title)
+ assertEquals(listOf("money_action_send", "money_action_request"), actions.map { it.testTag })
+ assertTrue(events.none { it is ChatViewModel.Event.NavigateToAmountEntry })
+
+ actions.first { it.testTag == "money_action_request" }.onClick()
+ advanceUntilIdle()
+
+ assertTrue(ChatViewModel.Event.NavigateToRequestMoney in events)
+ // Asking for money spends nothing, so it skips the balance gate.
+ coVerify(exactly = 0) { startChattingPayer.mayProceed(any(), any()) }
+ }
+
@Test
fun `a group send posts a cash link and reports it`() = runTest(mainCoroutineRule.dispatcher) {
val vm = createViewModel()
@@ -328,6 +352,12 @@ class ChatGroupCashLinkTest {
vm.dispatchEvent(ChatViewModel.Event.TokenUpdated(token))
vm.dispatchEvent(ChatViewModel.Event.OnSendCash)
advanceUntilIdle()
+ // A private group's cash button asks Send Money or Request Money first.
+ BottomBarManager.messages.value.firstOrNull()?.let { sheet ->
+ sheet.actions.first { it.testTag == "money_action_send" }.onClick()
+ BottomBarManager.clear()
+ advanceUntilIdle()
+ }
vm.amountDelegate.onCurrencyChanged(Currency(code = "USD", name = "US Dollar"))
vm.amountDelegate.setAmount(total)
}
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckRestTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckRestTest.kt
new file mode 100644
index 0000000000..25fc7a5310
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckRestTest.kt
@@ -0,0 +1,58 @@
+package com.flipcash.app.messenger.internal.request
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class DeckRestTest {
+
+ private val dinner = listOf(1)
+ private val sunday = listOf(2)
+
+ @Test
+ fun `the strip shows while the viewer owes`() {
+ assertEquals(DeckPlacement.Strip, DeckRest().owing(setOf(dinner)).host)
+ }
+
+ @Test
+ fun `the pill shows while the viewer owes nothing`() {
+ assertEquals(DeckPlacement.Pill, DeckRest().owing(emptySet()).host)
+ }
+
+ // Test 26: scrolled away, the strip collapses into the pill and stays there.
+ @Test
+ fun `a collapsed strip stays in the pill while the same requests are owed`() {
+ val collapsed = DeckRest().owing(setOf(dinner)).collapsed()
+
+ assertEquals(DeckPlacement.Pill, collapsed.owing(setOf(dinner)).host)
+ }
+
+ // Test 27: reopening the chat starts a fresh rest, so the strip is back.
+ @Test
+ fun `a new visit shows the strip again while the viewer still owes`() {
+ DeckRest().owing(setOf(dinner)).collapsed()
+
+ assertEquals(DeckPlacement.Strip, DeckRest().owing(setOf(dinner)).host)
+ }
+
+ // Test 28: a request the strip hasn't shown brings it back.
+ @Test
+ fun `a new owed request brings a collapsed strip back`() {
+ val collapsed = DeckRest().owing(setOf(dinner)).collapsed()
+
+ assertEquals(DeckPlacement.Strip, collapsed.owing(setOf(dinner, sunday)).host)
+ }
+
+ @Test
+ fun `paying one of two owed requests leaves a collapsed strip in the pill`() {
+ val collapsed = DeckRest().owing(setOf(dinner, sunday)).collapsed()
+
+ assertEquals(DeckPlacement.Pill, collapsed.owing(setOf(sunday)).host)
+ }
+
+ @Test
+ fun `paying the last owed share moves the strip to the pill`() {
+ val strip = DeckRest().owing(setOf(dinner))
+
+ assertEquals(DeckPlacement.Pill, strip.owing(emptySet()).host)
+ }
+}
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckRotationTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckRotationTest.kt
new file mode 100644
index 0000000000..2da33cfc16
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckRotationTest.kt
@@ -0,0 +1,23 @@
+package com.flipcash.app.messenger.internal.request
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class DeckRotationTest {
+
+ @Test
+ fun `a swipe sends the front card to the back`() {
+ assertEquals(listOf(2, 3, 1), listOf(1, 2, 3).rotated(1))
+ }
+
+ @Test
+ fun `cycling past the end comes back round`() {
+ assertEquals(listOf(1, 2, 3), listOf(1, 2, 3).rotated(3))
+ assertEquals(listOf(3, 1, 2), listOf(1, 2, 3).rotated(5))
+ }
+
+ @Test
+ fun `one card does not move`() {
+ assertEquals(listOf(1), listOf(1).rotated(4))
+ }
+}
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraftTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraftTest.kt
new file mode 100644
index 0000000000..161206b6cf
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraftTest.kt
@@ -0,0 +1,133 @@
+package com.flipcash.app.messenger.internal.request
+
+import com.flipcash.app.messenger.internal.request.NewRequestDraft.Deadline
+import com.flipcash.app.messenger.internal.request.NewRequestDraft.People
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.ChatMember
+import com.flipcash.services.models.paymentrequest.PaymentRequestText
+import com.flipcash.services.models.UserProfile
+import com.getcode.opencode.model.core.ID
+import com.getcode.opencode.model.financial.CurrencyCode
+import com.getcode.opencode.model.financial.Fiat
+import io.mockk.mockk
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.days
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Instant
+
+class NewRequestDraftTest {
+
+ private val viewer = id(0)
+ private val twenty = Fiat(20.0, CurrencyCode.USD)
+ private val now = Instant.fromEpochSeconds(1_800_000_000)
+
+ private fun id(n: Int): ID = listOf((n shr 8).toByte(), n.toByte())
+
+ private fun draft(memberCount: Int, rosterViewerAt: Int = 3) = run {
+ // The viewer sits mid-roster to show the creator still goes first.
+ val others = (1 until memberCount).map(::id)
+ val roster = others.toMutableList().apply { add(rosterViewerAt.coerceAtMost(size), viewer) }
+ .map { ChatMember(it, mockk(relaxed = true), emptyList()) }
+ NewRequestDraft(
+ viewerId = viewer,
+ members = NewRequestDraft.members(viewer, roster),
+ title = "Saturday Request",
+ perPerson = twenty,
+ )
+ }
+
+ // 14
+ @Test
+ fun `everyone selected asks 25 and sends 26 shares creator first`() {
+ val draft = draft(26)
+
+ assertEquals(People.EveryoneIncludingYou, draft.people)
+ assertTrue(draft.people.markedPaid)
+ assertEquals(25, draft.requestedCount)
+ assertEquals(26, draft.shares.size)
+ assertEquals(viewer, draft.shares.first())
+ assertEquals((1 until 26).map(::id), draft.shares.drop(1))
+ }
+
+ // 15
+ @Test
+ fun `creator and one other reads one person including you`() {
+ val draft = draft(26).copy(selected = setOf(viewer, id(4)))
+
+ assertEquals(People.SomeIncludingYou(1), draft.people)
+ assertEquals(1, draft.requestedCount)
+ assertEquals(listOf(viewer, id(4)), draft.shares)
+ }
+
+ // 16
+ @Test
+ fun `creator deselected reads everyone except you and leaves the creator out`() {
+ val start = draft(26)
+ val draft = start.copy(selected = start.toggled(viewer)!!)
+
+ assertEquals(People.EveryoneExceptYou, draft.people)
+ assertFalse(draft.people.markedPaid)
+ assertEquals(25, draft.shares.size)
+ assertFalse(viewer in draft.shares)
+ }
+
+ // 17
+ @Test
+ fun `only the creator selected cannot continue`() {
+ val draft = draft(26).copy(selected = setOf(viewer))
+
+ assertFalse(draft.canContinue)
+ assertNull(draft.toSplit(ChatId(byteArrayOf(1)), sealed(), now))
+ }
+
+ // 18
+ @Test
+ fun `checking a 101st share is refused and the selection is unchanged`() {
+ val draft = draft(150)
+ assertEquals(People.Choose, draft.people)
+ assertFalse(draft.canContinue)
+
+ val hundred = draft.members.take(100).toSet()
+ val full = draft.copy(selected = hundred)
+
+ assertNull(full.toggled(draft.members[100]))
+ assertEquals(hundred, full.selected)
+ assertEquals(hundred.minusElement(draft.members[5]), full.toggled(draft.members[5]))
+ }
+
+ @Test
+ fun `some members without the creator count them all`() {
+ val draft = draft(26).copy(selected = setOf(id(1), id(2)))
+
+ assertEquals(People.Some(2), draft.people)
+ assertFalse(draft.people.markedPaid)
+ }
+
+ @Test
+ fun `a preset resolves against the moment send is tapped`() {
+ val draft = draft(5).copy(deadline = Deadline.Preset.FourWeeks)
+
+ val split = draft.toSplit(ChatId(byteArrayOf(1)), sealed(), now)!!
+
+ assertEquals(now + 28.days - 5.minutes, split.endsAt)
+ assertEquals(CurrencyCode.USD, split.currency)
+ assertEquals(twenty, split.perPerson)
+ }
+
+ @Test
+ fun `presets and the custom range`() {
+ assertEquals(now + 7.days, Deadline.Week.endsAt(now))
+ assertEquals(now + 1.days, Deadline.Preset.Day.endsAt(now))
+ assertEquals(now + 3.days, Deadline.Preset.ThreeDays.endsAt(now))
+ assertEquals(now + 14.days, Deadline.Preset.TwoWeeks.endsAt(now))
+ assertEquals((now + 1.hours)..(now + 28.days - 5.minutes), Deadline.customRange(now))
+ assertEquals(now + 2.days, Deadline.Custom(now + 2.days).endsAt(now + 1.days))
+ }
+
+ private fun sealed() = PaymentRequestText.Encrypted(1, ByteArray(24), ByteArray(32))
+}
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestCopyTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestCopyTest.kt
new file mode 100644
index 0000000000..2a6d1622d7
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestCopyTest.kt
@@ -0,0 +1,83 @@
+package com.flipcash.app.messenger.internal.request
+
+import com.flipcash.services.models.UserProfile
+import com.flipcash.services.models.chat.ChatMember
+import com.getcode.opencode.model.core.ID
+import com.getcode.opencode.model.financial.CurrencyCode
+import com.getcode.opencode.model.financial.Fiat
+import com.getcode.util.resources.AndroidResources
+import io.mockk.mockk
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+
+/** The summary copy for the spec's cases 14, 15, 17 and 18. */
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE)
+class RequestCopyTest {
+
+ private val resources = AndroidResources(RuntimeEnvironment.getApplication())
+ private val viewer = id(0)
+
+ private fun id(n: Int): ID = listOf((n shr 8).toByte(), n.toByte())
+
+ private fun draft(memberCount: Int) = NewRequestDraft(
+ viewerId = viewer,
+ members = NewRequestDraft.members(
+ viewer,
+ (0 until memberCount).map { ChatMember(id(it), mockk(relaxed = true), emptyList()) },
+ ),
+ title = "Saturday Request",
+ perPerson = Fiat(20.0, CurrencyCode.USD),
+ )
+
+ // 14
+ @Test
+ fun `everyone selected reads everyone including you and requests from 25 people`() {
+ val draft = draft(26)
+
+ assertEquals("Everyone, Including You", peopleValue(draft.people, resources))
+ assertEquals("You'll be marked as paid", peopleDetail(draft.people, resources))
+ assertEquals("Request $20 from 25 People", requestButtonText(draft, "$20", resources))
+ }
+
+ // 15
+ @Test
+ fun `creator and one other read in the singular`() {
+ val draft = draft(26).copy(selected = setOf(viewer, id(4)))
+
+ assertEquals("1 Person, Including You", peopleValue(draft.people, resources))
+ assertEquals("Request $20 from 1 Person", requestButtonText(draft, "$20", resources))
+ }
+
+ // 16
+ @Test
+ fun `creator deselected has no second line`() {
+ val start = draft(26)
+ val draft = start.copy(selected = start.toggled(viewer)!!)
+
+ assertEquals("Everyone Except You", peopleValue(draft.people, resources))
+ assertNull(peopleDetail(draft.people, resources))
+ }
+
+ // 17: the Select People button takes canContinue as its enabled state.
+ @Test
+ fun `only the creator selected disables the button`() {
+ assertFalse(draft(26).copy(selected = setOf(viewer)).canContinue)
+ }
+
+ // 18: the view model shows this notice when toggled() refuses the 101st share.
+ @Test
+ fun `the 101st share shows 100 people max`() {
+ val draft = draft(150)
+ val full = draft.copy(selected = draft.members.take(100).toSet())
+
+ assertNull(full.toggled(draft.members[100]))
+ assertEquals("100 People Max", maxPeopleNotice(resources).first)
+ }
+}
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsRulesTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsRulesTest.kt
new file mode 100644
index 0000000000..78cb7651b0
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsRulesTest.kt
@@ -0,0 +1,90 @@
+package com.flipcash.app.messenger.internal.request
+
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.paymentrequest.PaidShare
+import com.flipcash.services.models.paymentrequest.PaymentRequest
+import com.flipcash.services.models.paymentrequest.PaymentRequestStatus
+import com.flipcash.services.models.paymentrequest.PaymentRequestText
+import com.flipcash.services.models.paymentrequest.Share
+import com.flipcash.services.models.paymentrequest.Split
+import com.getcode.opencode.model.financial.CurrencyCode
+import com.getcode.opencode.model.financial.Fiat
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import kotlin.time.Instant
+
+/** Who can mark a share paid and who sees "Send " on Request details. */
+class RequestDetailsRulesTest {
+
+ private val creator = List(16) { 1 }
+ private val debtor = List(16) { 2 }
+ private val payer = List(16) { 3 }
+ private val now = Instant.fromEpochSeconds(500)
+ private val twenty = Fiat(20.0, CurrencyCode.USD)
+
+ private fun request(status: PaymentRequestStatus = PaymentRequestStatus.Open, endsAt: Long = 1_000) = PaymentRequest(
+ id = List(16) { 9 },
+ creator = creator,
+ createdAt = Instant.fromEpochSeconds(0),
+ status = status,
+ closedAt = null,
+ version = 1,
+ kind = PaymentRequest.Kind.GroupSplit(
+ chatId = ChatId(ByteArray(32) { 3 }),
+ split = Split(
+ text = PaymentRequestText.Plain("Dinner", ""),
+ currency = CurrencyCode.USD,
+ endsAt = Instant.fromEpochSeconds(endsAt),
+ shares = listOf(
+ Share(creator, twenty, PaidShare(Instant.fromEpochSeconds(0), PaidShare.How.Creator)),
+ Share(debtor, twenty, null),
+ Share(payer, twenty, PaidShare(Instant.fromEpochSeconds(10), PaidShare.How.Payment)),
+ ),
+ ),
+ ),
+ )
+
+ @Test
+ fun `the creator can mark another member's unpaid share on an open request`() {
+ assertTrue(canMarkPaid(request(), debtor, viewerId = creator, now = now))
+ }
+
+ @Test
+ fun `paid rows and the creator's own row do nothing`() {
+ assertFalse(canMarkPaid(request(), payer, creator, now))
+ assertFalse(canMarkPaid(request(), creator, creator, now))
+ }
+
+ @Test
+ fun `a member who did not create the request cannot mark shares`() {
+ assertFalse(canMarkPaid(request(), debtor, viewerId = payer, now = now))
+ }
+
+ @Test
+ fun `nothing can be marked once the request has ended`() {
+ assertFalse(canMarkPaid(request(PaymentRequestStatus.Closed), debtor, creator, now))
+ assertFalse(canMarkPaid(request(endsAt = 400), debtor, creator, now))
+ }
+
+ @Test
+ fun `a debtor with an unpaid share on an open request sees Send`() {
+ assertEquals(twenty, unpaidShareOf(request(), debtor, now)?.amount)
+ }
+
+ @Test
+ fun `no Send for a paid share, a non-member, or an ended request`() {
+ assertNull(unpaidShareOf(request(), payer, now))
+ assertNull(unpaidShareOf(request(), List(16) { 7 }, now))
+ assertNull(unpaidShareOf(request(PaymentRequestStatus.Expired), debtor, now))
+ assertNull(unpaidShareOf(request(endsAt = 400), debtor, now))
+ }
+
+ @Test
+ fun `case 20 - Send is hidden when the viewer has no payment intent id`() {
+ assertNull(sendableShareOf(request(), debtor, now, hasPaymentIntent = false))
+ assertEquals(twenty, sendableShareOf(request(), debtor, now, hasPaymentIntent = true)?.amount)
+ }
+}
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestStepTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestStepTest.kt
new file mode 100644
index 0000000000..1efad41d64
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestStepTest.kt
@@ -0,0 +1,32 @@
+package com.flipcash.app.messenger.internal.request
+
+import com.flipcash.app.messenger.internal.request.RequestMoneyViewModel.Stage
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class RequestStepTest {
+
+ @Test
+ fun `each stage puts its own step on top of the path to it`() {
+ assertEquals(listOf(RequestStep.History), stackFor(Stage.History))
+ assertEquals(listOf(RequestStep.History, RequestStep.Amount), stackFor(Stage.Amount))
+ assertEquals(
+ listOf(RequestStep.History, RequestStep.Amount, RequestStep.Summary, RequestStep.People),
+ stackFor(Stage.People),
+ )
+ assertEquals(RequestStep.Name, stackFor(Stage.Name).last())
+ }
+
+ @Test
+ fun `a step below the target's top pushes the next one`() {
+ assertEquals(StackMove.Push(RequestStep.Amount), moveFrom(RequestStep.History, stackFor(Stage.Amount)))
+ assertEquals(StackMove.Push(RequestStep.People), moveFrom(RequestStep.Summary, stackFor(Stage.People)))
+ }
+
+ @Test
+ fun `the top step stays and a step off the target pops`() {
+ assertEquals(StackMove.None, moveFrom(RequestStep.Summary, stackFor(Stage.Summary)))
+ assertEquals(StackMove.Pop, moveFrom(RequestStep.People, stackFor(Stage.Summary)))
+ assertEquals(StackMove.Pop, moveFrom(RequestStep.Amount, stackFor(Stage.History)))
+ }
+}
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/TimeToPayTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/TimeToPayTest.kt
new file mode 100644
index 0000000000..28b40d2253
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/TimeToPayTest.kt
@@ -0,0 +1,221 @@
+package com.flipcash.app.messenger.internal.request
+
+import com.flipcash.app.messenger.internal.request.NewRequestDraft.Deadline
+import com.flipcash.app.messenger.internal.request.TimeToPay.Choice
+import java.time.DayOfWeek
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.LocalTime
+import java.time.ZoneOffset
+import kotlin.test.Test
+import androidx.compose.animation.core.SnapSpec
+import com.flipcash.app.messenger.internal.request.TimeToPay.RangeCell
+import com.flipcash.app.messenger.internal.request.TimeToPay.MonthTone
+import com.flipcash.app.messenger.internal.request.TimeToPay.RangeCell.Mark
+import androidx.compose.ui.unit.dp
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.toKotlinInstant
+
+/** Spec "Deadline sheet", tests 34–47. UTC so 28 days never cross a clock change. */
+class TimeToPayTest {
+ private val zone = ZoneOffset.UTC
+
+ private fun openedAt(month: Int, day: Int, hour: Int, minute: Int, deadline: Deadline = Deadline.Week) =
+ TimeToPay.of(
+ deadline,
+ LocalDateTime.of(2026, month, day, hour, minute).toInstant(zone).toKotlinInstant(),
+ zone,
+ )
+
+ private fun date(month: Int, day: Int) = LocalDate.of(2026, month, day)
+
+ @Test
+ fun `test 34 - default sheet on Mon Oct 26 with Sunday weeks`() {
+ val sheet = openedAt(10, 26, 11, 39)
+ val grid = TimeToPay.grid(sheet.today, DayOfWeek.SUNDAY)
+
+ assertEquals(35, grid.size)
+ assertEquals(date(10, 25), grid.first())
+ assertFalse(sheet.enabled(date(10, 25)))
+ assertTrue(sheet.enabled(date(10, 26)))
+ assertTrue(sheet.enabled(date(11, 23)))
+ assertFalse(sheet.enabled(date(11, 24)))
+ assertEquals(Choice.Preset(Deadline.Preset.Week), sheet.choice)
+ assertEquals(date(11, 2), sheet.selectedDay)
+ assertNull(sheet.customAt)
+ // The Time row shows for a preset too: it closes at the time of day the request is sent.
+ assertEquals(LocalTime.of(11, 39), sheet.time)
+ assertEquals(RangeCell(Mark.Start, leading = false, trailing = true), sheet.range(date(10, 26)))
+ // The band wraps from Sat Oct 31 to Sun Nov 1; the grid rounds it at the row edges.
+ assertEquals(RangeCell(Mark.None, leading = true, trailing = true), sheet.range(date(10, 31)))
+ assertEquals(RangeCell(Mark.None, leading = true, trailing = true), sheet.range(date(11, 1)))
+ assertEquals(RangeCell(Mark.End, leading = true, trailing = false), sheet.range(date(11, 2)))
+ assertEquals(RangeCell.Outside, sheet.range(date(10, 25)))
+ assertEquals(RangeCell.Outside, sheet.range(date(11, 3)))
+ }
+
+ @Test
+ fun `test 35 - tapping Nov 5 makes a custom 6 PM choice 10 days out`() {
+ val sheet = openedAt(10, 26, 11, 39).chooseDay(date(11, 5))
+
+ assertEquals(Choice.Custom(date(11, 5), LocalTime.of(18, 0)), sheet.choice)
+ assertEquals(10, sheet.daysFromNow)
+ assertEquals(RangeCell(Mark.None, leading = true, trailing = true), sheet.range(date(11, 4)))
+ assertEquals(RangeCell(Mark.End, leading = true, trailing = false), sheet.range(date(11, 5)))
+ }
+
+ @Test
+ fun `test 36 - today at 5_30 PM moves 6 PM to now plus 1 hour`() {
+ val sheet = openedAt(10, 26, 17, 30).chooseDay(date(10, 26))
+
+ assertEquals(Choice.Custom(date(10, 26), LocalTime.of(18, 30)), sheet.choice)
+ }
+
+ @Test
+ fun `test 37 - the last day moves 6 PM to now plus 28 days less 5 minutes`() {
+ val sheet = openedAt(10, 26, 11, 39).chooseDay(date(11, 23))
+
+ assertEquals(Choice.Custom(date(11, 23), LocalTime.of(11, 34)), sheet.choice)
+ }
+
+ @Test
+ fun `test 38 - a chip after a custom day highlights its date`() {
+ val sheet = openedAt(10, 26, 11, 39).chooseDay(date(11, 5)).choosePreset(Deadline.Preset.TwoWeeks)
+
+ assertEquals(Choice.Preset(Deadline.Preset.TwoWeeks), sheet.choice)
+ assertEquals(date(11, 9), sheet.selectedDay)
+ assertEquals(Deadline.Preset.TwoWeeks, sheet.toDeadline())
+ assertEquals(LocalTime.of(11, 39), sheet.time)
+ assertEquals(RangeCell(Mark.End, leading = true, trailing = false), sheet.range(date(11, 9)))
+ assertEquals(RangeCell.Outside, sheet.range(date(11, 10)))
+ }
+
+ @Test
+ fun `test 40 - today tapped shows only the end circle`() {
+ val sheet = openedAt(10, 26, 11, 39).chooseDay(date(10, 26))
+
+ assertEquals(Choice.Custom(date(10, 26), LocalTime.of(18, 0)), sheet.choice)
+ assertEquals(RangeCell(Mark.End, leading = false, trailing = false), sheet.range(date(10, 26)))
+ assertEquals(RangeCell.Outside, sheet.range(date(10, 27)))
+ }
+
+ @Test
+ fun `test 39 - a custom day the sheet never applies leaves the draft's deadline`() {
+ val draft = Deadline.Week
+ openedAt(10, 26, 11, 39, draft).chooseDay(date(11, 5))
+
+ // Only Done calls toDeadline(); reopening on the untouched draft shows the chip again.
+ assertEquals(Choice.Preset(Deadline.Preset.Week), openedAt(10, 26, 11, 40, draft).choice)
+ }
+
+ @Test
+ fun `a changed day keeps the last custom time`() {
+ val sheet = openedAt(10, 26, 11, 39)
+ .chooseDay(date(11, 5))
+ .chooseTime(LocalTime.of(9, 15))
+ .chooseDay(date(11, 7))
+
+ assertEquals(Choice.Custom(date(11, 7), LocalTime.of(9, 15)), sheet.choice)
+ }
+
+ @Test
+ fun `test 41 - changing a preset's time makes its day custom at that time`() {
+ val sheet = openedAt(10, 26, 11, 39).chooseTime(LocalTime.of(9, 0))
+
+ assertEquals(Choice.Custom(date(11, 2), LocalTime.of(9, 0)), sheet.choice)
+ assertEquals(Deadline.Custom(LocalDateTime.of(2026, 11, 2, 9, 0).toInstant(zone).toKotlinInstant()), sheet.toDeadline())
+ }
+
+ @Test
+ fun `test 42 - a later time on 4 Weeks stops at now plus 28 days less 5 minutes`() {
+ val sheet = openedAt(10, 26, 11, 39)
+ .choosePreset(Deadline.Preset.FourWeeks)
+ .chooseTime(LocalTime.of(18, 0))
+
+ assertEquals(Choice.Custom(date(11, 23), LocalTime.of(11, 34)), sheet.choice)
+ }
+
+ @Test
+ fun `test 43 - the band follows the end across a week`() {
+ // Oct 26 is index 1 of a Sunday grid; Nov 2 is index 8, the second row.
+ assertEquals(
+ listOf(
+ TimeToPay.BandSpan(0, 1.5f, 7f, roundTo = true),
+ TimeToPay.BandSpan(1, 0f, 1.5f, roundFrom = true),
+ ),
+ TimeToPay.bandSpans(startIndex = 1, endIndex = 8f),
+ )
+ // Partway back to Oct 30 the band has left the second row.
+ assertEquals(listOf(TimeToPay.BandSpan(0, 1.5f, 6f)), TimeToPay.bandSpans(startIndex = 1, endIndex = 5.5f))
+ // Ending on today draws no band.
+ assertEquals(emptyList(), TimeToPay.bandSpans(startIndex = 1, endIndex = 1f))
+ }
+
+ @Test
+ fun `test 48 - the band runs square up to the start and end circles`() {
+ // One row, Oct 26 to Oct 30: both ends sit under a circle, so neither is rounded.
+ assertEquals(listOf(TimeToPay.BandSpan(0, 1.5f, 5.5f)), TimeToPay.bandSpans(startIndex = 1, endIndex = 5f))
+ // Wrapping to Nov 2: only the ends at the row edges are rounded.
+ val wrapped = TimeToPay.bandSpans(startIndex = 1, endIndex = 8f)
+ assertEquals(listOf(false to true, true to false), wrapped.map { it.roundFrom to it.roundTo })
+ // Mid-slide, the end under the moving circle stays square.
+ assertEquals(listOf(false to false), TimeToPay.bandSpans(startIndex = 1, endIndex = 3.4f).map { it.roundFrom to it.roundTo })
+ }
+
+ @Test
+ fun `test 44 - reduce motion moves the selection without animating`() {
+ assertTrue(TimeToPayMotion.spec(reducedMotion = true) is SnapSpec)
+ assertFalse(TimeToPayMotion.spec(reducedMotion = false) is SnapSpec)
+ }
+
+ @Test
+ fun `test 45 - a tapped day keeps the time set on a preset`() {
+ val sheet = openedAt(10, 26, 11, 39)
+ .chooseTime(LocalTime.of(9, 15))
+ .choosePreset(Deadline.Preset.TwoWeeks)
+ .chooseDay(date(11, 5))
+
+ assertEquals(Choice.Custom(date(11, 5), LocalTime.of(9, 15)), sheet.choice)
+ }
+
+ @Test
+ fun `test 46 - Nov 1 labels its month inside the band, rows 44 apart`() {
+ val sheet = openedAt(10, 26, 11, 39)
+
+ assertEquals(MonthTone.Band, sheet.monthTone(date(11, 1)))
+ assertEquals(44.dp, TimeToPayGrid.CellHeight + TimeToPayGrid.RowGap)
+ assertEquals(34.dp, TimeToPayGrid.Circle)
+ }
+
+ @Test
+ fun `test 47 - Nov 1 tapped labels its month on the closing circle`() {
+ val sheet = openedAt(10, 26, 11, 39).chooseDay(date(11, 1))
+
+ assertEquals(MonthTone.End, sheet.monthTone(date(11, 1)))
+ }
+
+ @Test
+ fun `a month label outside the range, or on today's circle, takes that tone`() {
+ assertEquals(MonthTone.Plain, openedAt(10, 26, 11, 39).monthTone(date(12, 1)))
+ assertEquals(MonthTone.Start, openedAt(11, 1, 11, 39).monthTone(date(11, 1)))
+ }
+
+ @Test
+ fun `a disabled day is ignored`() {
+ val sheet = openedAt(10, 26, 11, 39)
+
+ assertEquals(sheet, sheet.chooseDay(date(11, 24)))
+ }
+
+ @Test
+ fun `reopening a custom deadline shows its day and time`() {
+ val at = LocalDateTime.of(2026, 11, 5, 18, 0).toInstant(zone).toKotlinInstant()
+ val sheet = openedAt(10, 26, 12, 0, Deadline.Custom(at))
+
+ assertEquals(Choice.Custom(date(11, 5), LocalTime.of(18, 0)), sheet.choice)
+ assertEquals(Deadline.Custom(at), sheet.toDeadline())
+ }
+}
diff --git a/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/MessageSearch.kt b/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/MessageSearch.kt
index 343e7dbe07..ec33f54827 100644
--- a/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/MessageSearch.kt
+++ b/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/MessageSearch.kt
@@ -34,7 +34,8 @@ private fun searchableText(content: MessageContent): String? = when (content) {
is MessageContent.Media -> content.caption?.text
is MessageContent.Widget -> when (val widget = content.widget) {
is WidgetContent.ShareProfile -> widget.username
- WidgetContent.Unsupported -> null
+ // Its title lives in the request, not the message.
+ is WidgetContent.PaymentRequest, WidgetContent.Unsupported -> null
}
else -> null
}
diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt
index 3f1745a391..c32cd5962d 100644
--- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt
+++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt
@@ -15,6 +15,7 @@ import com.getcode.util.resources.ResourceHelper
import com.getcode.view.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.combine
+import com.flipcash.shared.chat.PaymentRequests
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -31,6 +32,7 @@ internal class ArchivedChatsViewModel @Inject constructor(
tokenCoordinator: TokenCoordinator,
resources: ResourceHelper,
dispatchers: DispatcherProvider,
+ paymentRequests: PaymentRequests,
) : BaseViewModel(
initialState = State(),
updateStateForEvent = updateStateForEvent,
@@ -53,8 +55,11 @@ internal class ArchivedChatsViewModel @Inject constructor(
chatCoordinator.archivedChatListFeed(),
tokenCoordinator.tokens,
chatCoordinator.observeSenderProfiles(),
- ) { summaries, tokens, senderProfiles ->
- mapConversations(summaries, tokens, senderProfiles, userManager.accountId, resources, chatCoordinator, exchange)
+ paymentRequests.state,
+ ) { summaries, tokens, senderProfiles, requests ->
+ mapConversations(
+ summaries, tokens, senderProfiles, userManager.accountId, resources, chatCoordinator, exchange, requests,
+ ) { id -> viewModelScope.launch { paymentRequests.fetchIfAbsent(id) } }
}
.flowOn(dispatchers.Default)
.onEach { dispatchEvent(Event.ChatsUpdated(Loadable.Loaded(it))) }
diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt
index c0ff6f553b..dea471f2c9 100644
--- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt
+++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt
@@ -31,6 +31,8 @@ import com.getcode.opencode.model.financial.Token
import com.getcode.util.resources.ResourceHelper
import com.getcode.view.BaseViewModel
import kotlinx.coroutines.flow.combine
+import com.flipcash.shared.chat.PaymentRequests
+import com.flipcash.services.models.paymentrequest.PaymentRequest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -49,6 +51,7 @@ internal class ChatsViewModel @Inject constructor(
private val archiveStore: ChatArchiveStore,
private val lobbyRequests: LobbyRequests,
private val hostLobbies: HostLobbies,
+ private val paymentRequests: PaymentRequests,
) : BaseViewModel(
initialState = State(),
updateStateForEvent = updateStateForEvent,
@@ -129,9 +132,11 @@ internal class ChatsViewModel @Inject constructor(
// Null on the first-frame draw if the table has not been read yet: asking then would
// re-fetch every sender already on disk.
senderProfiles: Map?,
+ requests: Map,
): Event.ChatsUpdated {
- val mainRows = mapConversations(feeds.main, tokens, senderProfiles, selfId(), resources, chatCoordinator, exchange)
- val archivedRows = mapConversations(feeds.archived, tokens, senderProfiles, selfId(), resources, chatCoordinator, exchange)
+ val fetchRequest: (ID) -> Unit = { id -> viewModelScope.launch { paymentRequests.fetchIfAbsent(id) } }
+ val mainRows = mapConversations(feeds.main, tokens, senderProfiles, selfId(), resources, chatCoordinator, exchange, requests, fetchRequest)
+ val archivedRows = mapConversations(feeds.archived, tokens, senderProfiles, selfId(), resources, chatCoordinator, exchange, requests, fetchRequest)
// The projection sees every chat so the chips, the Archived row and the badge number
// come from the one function the fixture tests. Hidden chats never reach the feeds, so
@@ -159,6 +164,7 @@ internal class ChatsViewModel @Inject constructor(
feeds = feeds,
tokens = tokenCoordinator.cachedTokens(),
senderProfiles = chatCoordinator.currentSenderProfiles(),
+ requests = paymentRequests.state.value,
)
)
}
@@ -171,7 +177,8 @@ internal class ChatsViewModel @Inject constructor(
tokenCoordinator.tokens,
chatCoordinator.observeSenderProfiles(),
exchange.observePreferredRate(),
- ) { feeds, tokens, senderProfiles, _ -> content(feeds, tokens, senderProfiles) }
+ paymentRequests.state,
+ ) { feeds, tokens, senderProfiles, _, requests -> content(feeds, tokens, senderProfiles, requests) }
// Off the main thread: on a cold launch the first mapping lands while the main thread
// is drawing the app's first screens, and waiting for it holds the Chats tab blank.
.flowOn(dispatchers.Default)
diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt
index 6746801fbd..16936175c6 100644
--- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt
+++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt
@@ -4,7 +4,9 @@ import com.getcode.opencode.exchange.Exchange
import com.flipcash.services.models.UserProfile
import com.flipcash.shared.chat.ChatCoordinator
import com.flipcash.shared.chat.ChatSummary
+import com.flipcash.services.models.paymentrequest.PaymentRequest
import com.flipcash.shared.chat.ui.ConversationReference
+import com.flipcash.shared.chat.ui.previewedPaymentRequestId
import com.flipcash.shared.chat.ui.toConversationReference
import com.flipcash.shared.chat.ui.unnamedGroupSender
import com.getcode.opencode.model.financial.Token
@@ -25,6 +27,9 @@ internal fun mapConversations(
resources: ResourceHelper,
chatCoordinator: ChatCoordinator,
exchange: Exchange,
+ paymentRequests: Map,
+ // Asked for each previewed request the store doesn't hold yet; the row redraws when it lands.
+ requestPaymentRequest: (ID) -> Unit,
): List {
val tokensByMint = tokens.associateBy { it.address }
return summaries.map { summary ->
@@ -34,6 +39,9 @@ internal fun mapConversations(
if (senderProfiles != null) {
summary.unnamedGroupSender(selfId, senderProfiles)?.let(chatCoordinator::requestSenderProfile)
}
- summary.toConversationReference(selfId, tokensByMint, resources, senderProfiles.orEmpty(), exchange)
+ summary.previewedPaymentRequestId()
+ ?.takeIf { it !in paymentRequests }
+ ?.let(requestPaymentRequest)
+ summary.toConversationReference(selfId, tokensByMint, resources, senderProfiles.orEmpty(), exchange, paymentRequests)
}
}
diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt
index a6f16a51de..d14c551737 100644
--- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt
+++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt
@@ -79,6 +79,7 @@ class ChatsViewModelArchiveTest {
archiveStore = ChatArchiveStore.None,
lobbyRequests = mockk(relaxed = true),
hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) },
+ paymentRequests = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) },
)
val states = mutableListOf()
diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ActionCapsule.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ActionCapsule.kt
new file mode 100644
index 0000000000..b6b098b32b
--- /dev/null
+++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ActionCapsule.kt
@@ -0,0 +1,60 @@
+package com.flipcash.shared.chat.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.testTag
+import com.getcode.theme.CodeTheme
+
+/**
+ * A capsule holding a screen's icon buttons side by side, as iOS combines them: the photo viewer's
+ * pill, on the quick-reaction strip's solid surface so it reads over a photo and over the app's
+ * own background alike.
+ */
+@Composable
+fun ActionCapsule(modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) {
+ Row(
+ modifier = modifier
+ .clip(CircleShape)
+ .background(StripSurface),
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1),
+ verticalAlignment = Alignment.CenterVertically,
+ content = content,
+ )
+}
+
+/** One of [ActionCapsule]'s buttons. */
+@Composable
+fun ActionCapsuleButton(
+ icon: ImageVector,
+ description: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ tag: String? = null,
+) {
+ IconButton(
+ onClick = onClick,
+ enabled = enabled,
+ modifier = modifier
+ .size(CodeTheme.dimens.staticGrid.x10)
+ .then(if (tag != null) Modifier.testTag(tag) else Modifier),
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = description,
+ tint = Color.White.copy(alpha = if (enabled) 1f else 0.4f),
+ )
+ }
+}
diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt
index d2bd145644..710e285526 100644
--- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt
+++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt
@@ -1,11 +1,14 @@
package com.flipcash.shared.chat.ui
+import com.flipcash.app.core.ui.forViewer
import com.flipcash.core.R
import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.WidgetContent
import com.flipcash.services.models.handle
+import com.flipcash.services.models.paymentrequest.PaymentRequest
+import com.flipcash.services.models.paymentrequest.PaymentRequestText
import com.flipcash.shared.chat.models.displayText
import com.flipcash.shared.chat.models.namedUsers
import com.flipcash.shared.chat.ChatSummary
@@ -37,6 +40,9 @@ import com.getcode.utils.hexEncodedString
*
* [exchange] restates a payment's amount in the viewer's currency; without one the amount reads in
* the currency it was sent in.
+ *
+ * [paymentRequests] supplies the per-person share for a payment request widget's preview; one the
+ * device doesn't hold yet previews as nothing until it arrives.
*/
fun ChatSummary.toConversationReference(
selfId: ID?,
@@ -44,6 +50,7 @@ fun ChatSummary.toConversationReference(
resources: ResourceHelper,
senderProfiles: Map = emptyMap(),
exchange: Exchange? = null,
+ paymentRequests: Map = emptyMap(),
): ConversationReference {
val isGroup = metadata.type == ChatType.GROUP
val other = metadata.members.firstOrNull { it.userId != selfId }
@@ -58,7 +65,7 @@ fun ChatSummary.toConversationReference(
title = metadata.title,
isGroup = isGroup,
chatType = metadata.type,
- lastMessagePreview = formatPreview(selfId, tokensByMint, resources, senderProfiles, exchange),
+ lastMessagePreview = formatPreview(selfId, tokensByMint, resources, senderProfiles, exchange, paymentRequests),
hasMessages = metadata.lastMessage != null,
lastActivity = metadata.lastActivity,
unreadCount = unreadCount,
@@ -92,12 +99,17 @@ private fun ChatSummary.groupSenderName(senderId: ID, senderProfiles: Map,
resources: ResourceHelper,
senderProfiles: Map,
exchange: Exchange?,
+ paymentRequests: Map,
): String? {
val lastMsg = metadata.lastMessage ?: return null
val isGroup = metadata.type == ChatType.GROUP
@@ -112,7 +124,7 @@ private fun ChatSummary.formatPreview(
null
}
return lastMsg.content.firstOrNull()
- ?.previewText(sentBySelf, senderName, isGroup, tokensByMint, resources, exchange) { content ->
+ ?.previewText(sentBySelf, senderName, isGroup, tokensByMint, resources, exchange, paymentRequests) { content ->
content.displayText(resources, selfId) { groupSenderName(it, senderProfiles) }
}
}
@@ -130,6 +142,7 @@ private fun MessageContent.previewText(
tokensByMint: Map,
resources: ResourceHelper,
exchange: Exchange?,
+ paymentRequests: Map,
depth: Int = 0,
systemText: (MessageContent.System) -> String?,
): String? = when (this) {
@@ -189,7 +202,7 @@ private fun MessageContent.previewText(
null
} else {
content.firstOrNull()
- ?.previewText(sentBySelf, senderName, isGroup, tokensByMint, resources, exchange, depth + 1, systemText)
+ ?.previewText(sentBySelf, senderName, isGroup, tokensByMint, resources, exchange, paymentRequests, depth + 1, systemText)
}
// The feed carries the newest message that still has content, so a tombstone only
@@ -212,8 +225,21 @@ private fun MessageContent.previewText(
is MessageContent.System -> systemText(this)
// A widget this client can't draw previews as nothing, as an undecryptable message does.
- is MessageContent.Widget -> when (widget) {
+ is MessageContent.Widget -> when (val widget = widget) {
is WidgetContent.ShareProfile -> resources.getString(R.string.label_chat_preview_sharedProfile)
+ // Who sent it and its title; a title that can't be opened drops the "for" part.
+ is WidgetContent.PaymentRequest -> paymentRequests[widget.id]?.let { request ->
+ val title = (request.split?.text as? PaymentRequestText.Plain)?.title?.takeIf { it.isNotBlank() }
+ when {
+ sentBySelf && title != null -> resources.getString(R.string.label_chat_preview_sentRequest, title)
+ sentBySelf -> resources.getString(R.string.label_chat_preview_sentRequest_untitled)
+ senderName != null && title != null ->
+ resources.getString(R.string.label_chat_preview_sentRequest_bySender, senderName, title)
+ senderName != null ->
+ resources.getString(R.string.label_chat_preview_sentRequest_untitled_bySender, senderName)
+ else -> null
+ }
+ }
WidgetContent.Unsupported -> null
}
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 c5c364eccc..d3d197b563 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
@@ -69,6 +69,12 @@ import com.flipcash.app.core.ui.TokenIconWithName
import com.flipcash.app.theme.FlipcashThemeWrapper
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.WidgetContent
+import com.flipcash.shared.chat.RequestCardState
+import com.flipcash.shared.chat.ui.request.FetchWhenAbsent
+import com.flipcash.shared.chat.ui.request.LocalPaymentRequestCards
+import com.flipcash.shared.chat.ui.request.PaymentRequestBubbleDefaults
+import com.flipcash.shared.chat.ui.request.PaymentRequestCardBody
+import com.flipcash.shared.chat.ui.request.RefreshOnArrival
import com.flipcash.shared.chat.models.ChatAction
import com.flipcash.shared.chat.models.ChatQuote
import com.flipcash.shared.chat.models.LinkCard
@@ -211,6 +217,9 @@ fun ContentBubble(
return@Row
}
+ // A share payment landing changes its request, and nothing else says so.
+ (item.content as? MessageContent.Cash)?.paymentRequestId?.let { RefreshOnArrival(it, item.timestamp) }
+
when (val content = item.content) {
is MessageContent.Text -> TextBubble(
modifier = modifier,
@@ -378,6 +387,39 @@ fun ContentBubble(
attention = attention,
)
+ is WidgetContent.PaymentRequest -> {
+ val cards = LocalPaymentRequestCards.current
+ val state = cards?.stateOf(widget.id, item.isFromSelf, item.senderId)
+ ?: RequestCardState.Unsupported
+ if (cards != null) FetchWhenAbsent(widget.id, cards)
+ if (cards == null || state == RequestCardState.Unsupported) {
+ UndecryptableBubble(
+ modifier = modifier,
+ hint = item.undecryptableHint,
+ maxWidth = bubbleMaxWidth,
+ actionLabel = updateAction?.label,
+ onAction = { updateAction?.onClick?.invoke() },
+ )
+ } else {
+ val width = minOf(bubbleMaxWidth, PaymentRequestBubbleDefaults.WIDTH)
+ Bubble(
+ isFromSelf = item.isFromSelf,
+ position = position,
+ minWidth = width,
+ maxWidth = width,
+ horizontalPadding = CodeTheme.dimens.grid.x3,
+ verticalPadding = CodeTheme.dimens.grid.x3,
+ onClick = { cards.onOpen(widget.id) }.takeIf { interactive && state is RequestCardState.Ready },
+ onLongClick = onLongClick?.takeIf { interactive },
+ onDoubleClick = onDoubleClick?.takeIf { interactive },
+ modifier = modifier,
+ attention = attention,
+ ) {
+ PaymentRequestCardBody(state, cards.now)
+ }
+ }
+ }
+
WidgetContent.Unsupported -> UndecryptableBubble(
modifier = modifier,
hint = item.undecryptableHint,
diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt
index f3b3c3bea9..e09f05f03f 100644
--- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt
+++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt
@@ -102,7 +102,7 @@ private val GrowSpring = spring(dampingRatio = 0.9f, stiffness = 900f)
private val PopSpring = spring(dampingRatio = 0.65f, stiffness = 1200f)
/** iOS's pre-glass strip surface. */
-private val StripSurface = Color(0xFF303030)
+internal val StripSurface = Color(0xFF303030)
/** The "+"'s frost over the emoji passing under it: a blur tinted back toward the strip. */
private val PlusGlass = HazeBlurStyle {
diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UndecryptableBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UndecryptableBubble.kt
index 72ecfcb9bc..4838a26027 100644
--- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UndecryptableBubble.kt
+++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UndecryptableBubble.kt
@@ -92,7 +92,7 @@ fun unsupportedContentHint(content: MessageContent): UndecryptableHint? = when (
MessageContent.Unsupported -> UndecryptableHint.UpdateApp
is MessageContent.Widget -> when (content.widget) {
WidgetContent.Unsupported -> UndecryptableHint.UpdateApp
- is WidgetContent.ShareProfile -> null
+ is WidgetContent.ShareProfile, is WidgetContent.PaymentRequest -> null
}
else -> null
}
diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ReducedMotion.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ReducedMotion.kt
index 89036bfbbf..602d5f4e5e 100644
--- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ReducedMotion.kt
+++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ReducedMotion.kt
@@ -13,7 +13,7 @@ import androidx.compose.ui.platform.LocalContext
* unreadable setting means animations are on.
*/
@Composable
-internal fun rememberReducedMotion(): Boolean {
+fun rememberReducedMotion(): Boolean {
val resolver = LocalContext.current.contentResolver
return remember(resolver) {
runCatching {
diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestBubble.kt
new file mode 100644
index 0000000000..73f67ec4e4
--- /dev/null
+++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestBubble.kt
@@ -0,0 +1,72 @@
+package com.flipcash.shared.chat.ui.request
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.paymentrequest.PaymentRequest
+import com.flipcash.shared.chat.RequestCardState
+import com.flipcash.shared.chat.requestCardState
+import com.getcode.opencode.model.core.ID
+import kotlin.time.Instant
+
+/**
+ * What a transcript needs to draw payment request cards: the chat they sit in, the held requests,
+ * and the one-minute clock. Provided by the conversation screen; null elsewhere, where a request
+ * widget draws as unsupported.
+ */
+class PaymentRequestCards(
+ val chatId: ChatId,
+ val viewerId: ID?,
+ val requests: Map,
+ val missing: Set,
+ val now: Instant,
+ /** When the chat screen opened: messages after it arrived while the reader watched. */
+ val openedAt: Instant,
+ /** Fetches a request the store doesn't hold; safe to call on every composition. */
+ val onNeeded: (ID) -> Unit,
+ /** Refetches a request whether or not the store holds it. */
+ val onRefresh: (ID) -> Unit,
+ val onOpen: (ID) -> Unit,
+) {
+ fun stateOf(id: ID, isFromSelf: Boolean, senderId: ID?): RequestCardState = requestCardState(
+ request = requests[id],
+ missing = id in missing,
+ chatId = chatId,
+ senderId = if (isFromSelf) viewerId else senderId,
+ )
+}
+
+val LocalPaymentRequestCards = compositionLocalOf { null }
+
+/** Node 12254:1929: the card at its transcript width. */
+internal object PaymentRequestBubbleDefaults {
+ val WIDTH: Dp = 264.dp
+}
+
+/** Fetches [id] when the card comes on screen without it, which covers a widget scrolled into view. */
+@Composable
+internal fun FetchWhenAbsent(id: ID, cards: PaymentRequestCards) {
+ val absent = id !in cards.requests && id !in cards.missing
+ LaunchedEffect(id, absent) { if (absent) cards.onNeeded(id) }
+}
+
+/** Refetches [id] once when a message about it, sent at [sentAt], arrives while the chat is open. */
+@Composable
+internal fun RefreshOnArrival(id: ID, sentAt: Instant) {
+ val cards = LocalPaymentRequestCards.current ?: return
+ val arrived = sentAt > cards.openedAt
+ LaunchedEffect(id, arrived) { if (arrived) cards.onRefresh(id) }
+}
+
+@Composable
+internal fun PaymentRequestCardBody(state: RequestCardState, now: Instant) {
+ when (state) {
+ is RequestCardState.Ready -> PaymentRequestCardContent(state.request, state.title, now, Modifier.fillMaxWidth())
+ else -> PaymentRequestCardContent(null, null, now, Modifier.fillMaxWidth())
+ }
+}
diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestUi.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestUi.kt
new file mode 100644
index 0000000000..5462c69d26
--- /dev/null
+++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestUi.kt
@@ -0,0 +1,195 @@
+package com.flipcash.shared.chat.ui.request
+
+import androidx.compose.ui.platform.LocalContext
+import android.content.res.Resources
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+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.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.flipcash.core.R
+import com.flipcash.app.core.ui.inDisplayCurrency
+import com.flipcash.app.core.ui.shimmer
+import com.flipcash.services.models.paymentrequest.PaymentRequest
+import com.flipcash.services.models.paymentrequest.StatusLine
+import com.flipcash.services.models.paymentrequest.ViewerStanding
+import com.flipcash.services.models.paymentrequest.statusLine
+import com.getcode.opencode.model.financial.Fiat
+import com.getcode.theme.CodeTheme
+import kotlinx.coroutines.delay
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Instant
+
+/**
+ * The time, ticking once a minute, for status lines and the ended state. One ticker per screen:
+ * pass its value down rather than calling this per card.
+ */
+@Composable
+fun rememberMinuteClock(): Instant {
+ var now by remember { mutableStateOf(Clock.System.now()) }
+ LaunchedEffect(Unit) {
+ while (true) {
+ delay(1.minutes)
+ now = Clock.System.now()
+ }
+ }
+ return now
+}
+
+/** A request amount as the viewer reads it: in their display currency, whole amounts without ".00". */
+@Composable
+fun Fiat.requestText(): String = inDisplayCurrency().formatted(Fiat.FormattingRule.Truncated)
+
+@Composable
+fun StatusLine.text(): String = text(LocalContext.current.resources)
+
+fun StatusLine.text(resources: Resources): String = when (this) {
+ is StatusLine.DaysLeft -> resources.getQuantityString(R.plurals.label_requestDaysLeft, days, days)
+ is StatusLine.HoursLeft -> resources.getQuantityString(R.plurals.label_requestHoursLeft, hours, hours)
+ is StatusLine.MinutesLeft -> resources.getQuantityString(R.plurals.label_requestMinutesLeft, minutes, minutes)
+ StatusLine.PaidInFull -> resources.getString(R.string.label_requestPaidInFull)
+ StatusLine.Closed -> resources.getString(R.string.label_requestClosed)
+ StatusLine.Expired -> resources.getString(R.string.label_requestExpired)
+}
+
+/** "$20 paid, $520 requested". */
+@Composable
+fun PaymentRequest.progressText(): String {
+ val split = split ?: return ""
+ return stringResource(R.string.label_requestProgress, split.paid.requestText(), split.requested.requestText())
+}
+
+/** The paid fraction as a thin bar; grey once the request has ended. */
+@Composable
+fun RequestProgressBar(fraction: Float, ended: Boolean, modifier: Modifier = Modifier) {
+ val shape = RoundedCornerShape(1.5.dp)
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(3.dp)
+ .background(CodeTheme.colors.divider, shape),
+ ) {
+ if (fraction > 0f) {
+ Box(
+ modifier = Modifier
+ .fillMaxHeight()
+ .fillMaxWidth(fraction.coerceIn(0f, 1f))
+ .background(if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success, shape),
+ )
+ }
+ }
+}
+
+/**
+ * The request card's contents (frames 07 and 08): label, title, progress line, bar and status
+ * line. [title] null draws the placeholders while the request loads. The caller supplies the
+ * surface: a chat bubble in the transcript, a full-width card in the expanded deck.
+ * [detailModifier] goes on the pieces the deck pill has no counterpart for (label, bar, status
+ * line), so the deck can fade them apart from the card. [standing], given in the deck, puts the
+ * viewer's part at the footer's trailing end and the paid badge on the glyph.
+ */
+@Composable
+fun PaymentRequestCardContent(
+ request: PaymentRequest?,
+ title: String?,
+ now: Instant,
+ modifier: Modifier = Modifier,
+ glyphModifier: Modifier = Modifier,
+ titleModifier: Modifier = Modifier,
+ amountsModifier: Modifier = Modifier,
+ detailModifier: Modifier = Modifier,
+ standing: ViewerStanding? = null,
+) {
+ val loading = request == null || title == null
+ val ended = request != null && !request.isOpen(now)
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
+ ) {
+ Box(
+ modifier = glyphModifier
+ .size(34.dp)
+ .background(if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success, CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(text = "$", style = CodeTheme.typography.textLarge, color = Color.Black)
+ if (standing is ViewerStanding.Paid) PaidBadge(glyph = 34.dp, ended = ended)
+ }
+ Column {
+ Text(
+ text = stringResource(R.string.label_paymentRequest_card),
+ style = CodeTheme.typography.caption,
+ color = CodeTheme.colors.textSecondary,
+ modifier = detailModifier,
+ )
+ Text(
+ text = title.orEmpty(),
+ style = CodeTheme.typography.textLarge,
+ color = CodeTheme.colors.textMain,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = if (loading) Modifier.width(120.dp).shimmer(RoundedCornerShape(4.dp)) else titleModifier,
+ )
+ }
+ }
+ Text(
+ text = request?.takeUnless { loading }?.progressText().orEmpty(),
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textMain.copy(alpha = 0.75f),
+ modifier = if (loading) Modifier.width(160.dp).shimmer(RoundedCornerShape(4.dp)) else amountsModifier,
+ )
+ RequestProgressBar(fraction = request?.split?.paidFraction ?: 0f, ended = ended, modifier = detailModifier)
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
+ ) {
+ Text(
+ text = request?.takeUnless { loading }?.statusLine(now)?.text().orEmpty(),
+ style = CodeTheme.typography.caption,
+ color = CodeTheme.colors.textSecondary,
+ modifier = if (loading) Modifier.width(100.dp).shimmer(RoundedCornerShape(4.dp)) else detailModifier,
+ )
+ val viewer = standing?.takeUnless { loading }?.text()
+ if (viewer != null) {
+ Spacer(Modifier.weight(1f))
+ Text(
+ text = viewer,
+ style = CodeTheme.typography.caption,
+ color = when (standing) {
+ is ViewerStanding.Paid, is ViewerStanding.Owes -> CodeTheme.colors.success
+ else -> CodeTheme.colors.textSecondary
+ },
+ maxLines = 1,
+ modifier = detailModifier,
+ )
+ }
+ }
+ }
+}
diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/ViewerStatus.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/ViewerStatus.kt
new file mode 100644
index 0000000000..ee39adcdcf
--- /dev/null
+++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/ViewerStatus.kt
@@ -0,0 +1,136 @@
+package com.flipcash.shared.chat.ui.request
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.compositeOver
+import androidx.compose.ui.layout.SubcomposeLayout
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.sp
+import com.flipcash.core.R
+import com.flipcash.services.models.paymentrequest.PaymentRequest
+import com.flipcash.services.models.paymentrequest.ViewerStanding
+import com.getcode.theme.CodeTheme
+
+/**
+ * The viewer's part of a deck card's status line: "✓ You paid $5", "You owe $5", "Your request",
+ * or null with no share.
+ */
+@Composable
+fun ViewerStanding.text(): String? = when (this) {
+ is ViewerStanding.Paid -> stringResource(R.string.label_requestYouPaid, share.requestText())
+ is ViewerStanding.Owes -> stringResource(R.string.label_requestYouOwe, share.requestText())
+ ViewerStanding.Creator -> stringResource(R.string.label_requestYourRequest)
+ ViewerStanding.NoShare -> null
+}
+
+/** For the screen reader: "you paid $5", "you owe $5", "your request", or null with no share. */
+@Composable
+fun ViewerStanding.description(): String? = when (this) {
+ is ViewerStanding.Paid -> stringResource(R.string.cd_requestYouPaid, share.requestText())
+ is ViewerStanding.Owes -> stringResource(R.string.cd_requestYouOwe, share.requestText())
+ ViewerStanding.Creator -> stringResource(R.string.cd_requestYourRequest)
+ ViewerStanding.NoShare -> null
+}
+
+/**
+ * A deck card's status line: the viewer's standing, then " · of ". A paid or
+ * owed share reads in the success color. When the whole line doesn't fit, the totals go first and
+ * then the standing ellipsizes.
+ */
+@Composable
+fun ViewerStatusLine(
+ request: PaymentRequest,
+ standing: ViewerStanding,
+ style: TextStyle,
+ color: Color,
+ modifier: Modifier = Modifier,
+) {
+ val split = request.split ?: return
+ val totals = stringResource(R.string.label_requestDeckProgress, split.paid.requestText(), split.requested.requestText())
+ val viewer = standing.text()
+ val accent = when (standing) {
+ is ViewerStanding.Paid, is ViewerStanding.Owes -> CodeTheme.colors.success
+ else -> color
+ }
+ if (viewer == null) {
+ Text(text = totals, style = style, color = color, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = modifier)
+ return
+ }
+ val full = buildAnnotatedString {
+ withStyle(SpanStyle(color = accent)) { append(viewer) }
+ append(" · ")
+ append(totals)
+ }
+ val short = buildAnnotatedString { withStyle(SpanStyle(color = accent)) { append(viewer) } }
+ FirstThatFits(full, short, style, color, modifier)
+}
+
+/** [full] when it fits on one line, otherwise [short], ellipsized. */
+@Composable
+private fun FirstThatFits(
+ full: AnnotatedString,
+ short: AnnotatedString,
+ style: TextStyle,
+ color: Color,
+ modifier: Modifier,
+) {
+ SubcomposeLayout(modifier) { constraints ->
+ val whole = subcompose("full") {
+ Text(text = full, style = style, color = color, maxLines = 1, softWrap = false)
+ }.single().measure(constraints.copy(minWidth = 0, maxWidth = Int.MAX_VALUE))
+ val placeable = if (whole.width <= constraints.maxWidth) {
+ whole
+ } else {
+ subcompose("short") {
+ Text(text = short, style = style, color = color, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }.single().measure(constraints.copy(minWidth = 0))
+ }
+ layout(placeable.width, placeable.height) { placeable.place(0, 0) }
+ }
+}
+
+/**
+ * The paid badge at a glyph's bottom-right: a success circle with a black "✓", ringed in the card
+ * surface so it reads apart from the glyph. 10dp on a 26dp glyph, scaled with [glyph]. Greys with
+ * the glyph once the request has ended.
+ */
+@Composable
+fun BoxScope.PaidBadge(glyph: Dp, ended: Boolean) {
+ val badge = glyph * (10f / 26f)
+ val ring = glyph * (1.5f / 26f)
+ val surface = CodeTheme.colors.surfaceVariant.compositeOver(CodeTheme.colors.background)
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .size(badge + ring * 2)
+ .background(surface, CircleShape)
+ .padding(ring)
+ .background(if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success, CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "✓",
+ style = CodeTheme.typography.caption.copy(fontSize = badgeFontSize(badge), lineHeight = badgeFontSize(badge)),
+ color = Color.Black,
+ )
+ }
+}
+
+private fun badgeFontSize(badge: Dp): TextUnit = (badge.value * 0.7f).sp
diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt
index e4892b7795..e4a6c418e2 100644
--- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt
+++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt
@@ -10,6 +10,11 @@ import com.flipcash.services.models.chat.ChatMetadata
import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.WidgetContent
+import com.flipcash.services.models.paymentrequest.PaymentRequest
+import com.flipcash.services.models.paymentrequest.PaymentRequestStatus
+import com.flipcash.services.models.paymentrequest.PaymentRequestText
+import com.flipcash.services.models.paymentrequest.Share
+import com.flipcash.services.models.paymentrequest.Split
import com.flipcash.shared.chat.ChatSummary
import com.getcode.opencode.exchange.Exchange
import com.getcode.opencode.model.core.ID
@@ -406,6 +411,70 @@ class ChatSummaryPreviewTest {
assertEquals("Shared a profile", preview(listOf(widget), senderId = self))
}
+ private val requestId: ID = List(32) { 7 }
+
+ private fun request(share: Fiat) = PaymentRequest(
+ id = requestId,
+ creator = other,
+ createdAt = sentAt,
+ status = PaymentRequestStatus.Open,
+ closedAt = null,
+ version = 1,
+ kind = PaymentRequest.Kind.GroupSplit(
+ chatId = ChatId(byteArrayOf(9)),
+ split = Split(
+ text = PaymentRequestText.Plain("Dinner", ""),
+ currency = CurrencyCode.USD,
+ endsAt = Instant.fromEpochSeconds(100_000),
+ shares = listOf(self, other).map { Share(userId = it, amount = share, paid = null) },
+ ),
+ ),
+ )
+
+ @Test
+ fun `a payment request previews as who sent it and its title`() {
+ every {
+ resources.getString(R.string.label_chat_preview_sentRequest_bySender, "Alice", "Dinner")
+ } returns "Alice sent request for Dinner"
+ every { resources.getString(R.string.label_chat_preview_sentRequest, "Dinner") } returns "You sent request for Dinner"
+ val widget = listOf(MessageContent.Widget(WidgetContent.PaymentRequest(requestId)))
+ val requests = mapOf(requestId to request(Fiat(20.0, CurrencyCode.USD)))
+
+ fun groupPreview(senderId: ID) = groupSummary(widget, senderId, "Alice")
+ .toConversationReference(self, emptyMap(), resources, paymentRequests = requests)
+ .lastMessagePreview
+
+ assertEquals("Alice sent request for Dinner", groupPreview(other))
+ assertEquals("You sent request for Dinner", groupPreview(self))
+ }
+
+ @Test
+ fun `a payment request whose title can't be opened previews without it`() {
+ every {
+ resources.getString(R.string.label_chat_preview_sentRequest_untitled_bySender, "Alice")
+ } returns "Alice sent request"
+ every { resources.getString(R.string.label_chat_preview_sentRequest_untitled) } returns "You sent request"
+ val widget = listOf(MessageContent.Widget(WidgetContent.PaymentRequest(requestId)))
+ val held = request(Fiat(20.0, CurrencyCode.USD))
+ val requests = mapOf(
+ requestId to held.copy(kind = (held.kind as PaymentRequest.Kind.GroupSplit).let { kind ->
+ kind.copy(split = kind.split.copy(text = PaymentRequestText.Undecryptable))
+ }),
+ )
+
+ fun groupPreview(senderId: ID) = groupSummary(widget, senderId, "Alice")
+ .toConversationReference(self, emptyMap(), resources, paymentRequests = requests)
+ .lastMessagePreview
+
+ assertEquals("Alice sent request", groupPreview(other))
+ assertEquals("You sent request", groupPreview(self))
+ }
+
+ @Test
+ fun `a payment request the device doesn't hold yet has nothing to preview`() {
+ assertNull(preview(listOf(MessageContent.Widget(WidgetContent.PaymentRequest(requestId)))))
+ }
+
@Test
fun `a widget this client can't draw has nothing to preview`() {
assertNull(preview(listOf(MessageContent.Widget(WidgetContent.Unsupported))))
diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/request/RequestStatusTextTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/request/RequestStatusTextTest.kt
new file mode 100644
index 0000000000..c162934fdd
--- /dev/null
+++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/request/RequestStatusTextTest.kt
@@ -0,0 +1,28 @@
+package com.flipcash.shared.chat.ui.request
+
+import com.flipcash.services.models.paymentrequest.StatusLine
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE)
+class RequestStatusTextTest {
+
+ private val resources = RuntimeEnvironment.getApplication().resources
+
+ // 22: the state side is pinned in PaymentRequestTest.
+ @Test
+ fun `exactly 24 hours left reads in hours`() {
+ assertEquals("24 hours left to pay", StatusLine.HoursLeft(24).text(resources))
+ }
+
+ @Test
+ fun `one hour and one minute read in the singular`() {
+ assertEquals("1 hour left to pay", StatusLine.HoursLeft(1).text(resources))
+ assertEquals("1 minute left to pay", StatusLine.MinutesLeft(1).text(resources))
+ }
+}
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt
index da8b75f88c..bf3477c422 100644
--- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt
@@ -515,6 +515,14 @@ interface MessagingOperations {
linkPreview: OutgoingLinkPreview? = null,
): Result
+ /**
+ * Posts the widget for payment request [requestId] to [chatId], once the request exists. A
+ * failed send is retried on the same pending row; after the last retry the failure is a
+ * [PaymentRequestNotSent] naming that row, so it can be retried again with [retryMessage].
+ * The request is never created twice for one post.
+ */
+ suspend fun sendPaymentRequest(chatId: ChatId, requestId: ID): Result
+
/** Retries a failed pending message: resets to SENDING and re-sends to the server. */
suspend fun retryMessage(chatId: ChatId, pendingClientIdHex: String, content: List): Result
@@ -680,3 +688,10 @@ interface ChatCoordinator :
class NoDmChatInitializedException(e164: String) : Exception("No DM chat for $e164")
class FailedToGenerateChatIdException(identifier: String?) : Exception("Failed to generate chat ID for $identifier")
+
+/**
+ * A payment request's widget message that still failed after its retries. The request itself
+ * exists; retry the pending row [pendingClientIdHex] with [ChatCoordinator.retryMessage].
+ */
+class PaymentRequestNotSent(val pendingClientIdHex: String, cause: Throwable) :
+ Exception("payment request message not sent", cause)
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequestCard.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequestCard.kt
new file mode 100644
index 0000000000..5ae0ca0277
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequestCard.kt
@@ -0,0 +1,44 @@
+package com.flipcash.shared.chat
+
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.paymentrequest.PaymentRequest
+import com.flipcash.services.models.paymentrequest.PaymentRequestText
+import com.getcode.opencode.model.core.ID
+
+/** What a payment request widget message draws as. */
+sealed interface RequestCardState {
+ /** The request isn't held yet, or its text hasn't opened yet: the card's frame with placeholders. */
+ data object Loading : RequestCardState
+
+ /** Drawn as the chat's unsupported-message bubble. */
+ data object Unsupported : RequestCardState
+
+ data class Ready(val request: PaymentRequest, val title: String) : RequestCardState
+}
+
+/**
+ * The card for a widget naming [request], sent by [senderId] in [chatId]. [missing] is true once a
+ * fetch for it came back DENIED or NOT_FOUND.
+ *
+ * A request from another chat, or one the sender didn't create, is unsupported: anyone can paste a
+ * request id into a widget, and the card must not lend that request the chat's or sender's name.
+ */
+fun requestCardState(
+ request: PaymentRequest?,
+ missing: Boolean,
+ chatId: ChatId,
+ senderId: ID?,
+): RequestCardState {
+ if (missing) return RequestCardState.Unsupported
+ request ?: return RequestCardState.Loading
+ val kind = request.kind as? PaymentRequest.Kind.GroupSplit ?: return RequestCardState.Unsupported
+ if (kind.chatId != chatId) return RequestCardState.Unsupported
+ if (senderId == null || request.creator != senderId) return RequestCardState.Unsupported
+ return when (val text = kind.split.text) {
+ is PaymentRequestText.Plain -> RequestCardState.Ready(request, text.title)
+ is PaymentRequestText.Encrypted ->
+ if (text.scheme == PaymentRequestText.SCHEME_CHAT_KEY_XCHACHA20POLY1305) RequestCardState.Loading
+ else RequestCardState.Unsupported
+ PaymentRequestText.Undecryptable -> RequestCardState.Unsupported
+ }
+}
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequests.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequests.kt
new file mode 100644
index 0000000000..8767aff282
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequests.kt
@@ -0,0 +1,154 @@
+package com.flipcash.shared.chat
+
+import com.flipcash.services.models.chat.IdempotencyKey
+import com.flipcash.services.chat.GroupContentCrypto
+import com.flipcash.services.controllers.PaymentRequestController
+import com.flipcash.services.models.GetPaymentRequestError
+import com.flipcash.services.models.PagingToken
+import com.flipcash.services.models.QueryOptions
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.paymentrequest.NewSplitRequest
+import com.flipcash.services.models.paymentrequest.PaymentRequest
+import com.flipcash.services.models.paymentrequest.PaymentRequestDetails
+import com.flipcash.services.models.paymentrequest.PaymentRequestPage
+import com.flipcash.services.models.paymentrequest.PaymentRequestSection
+import com.flipcash.services.models.paymentrequest.PaymentRequestText
+import com.flipcash.services.models.paymentrequest.newerOf
+import com.getcode.opencode.model.core.ID
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * The payment requests the viewer has seen, by id, each the newest copy fetched.
+ *
+ * No event carries a request change, so screens refetch (see the spec's refetch table) and every
+ * fetched request goes through [ingest]: its text is opened once, then [merge] keeps the newer copy.
+ * A request's `payment_intent_id` never enters the store; [fetch] hands it to the caller.
+ */
+@Singleton
+class PaymentRequests @Inject constructor(
+ private val controller: PaymentRequestController,
+ private val crypto: GroupContentCrypto,
+) {
+ private val requests = MutableStateFlow>(emptyMap())
+
+ val state: StateFlow> = requests.asStateFlow()
+
+ private val _missing = MutableStateFlow>(emptySet())
+
+ /** Requests a fetch found DENIED or NOT_FOUND. Their cards draw as unsupported and aren't refetched. */
+ val missing: StateFlow> = _missing.asStateFlow()
+
+ private val inFlight = MutableStateFlow>(emptySet())
+
+ /** The held requests made in [chatId], in no particular order. */
+ fun inChat(chatId: ChatId): Flow> =
+ requests.map { all -> all.values.filter { it.chatId == chatId } }.distinctUntilChanged()
+
+ fun request(id: ID): Flow = requests.map { it[id] }.distinctUntilChanged()
+
+ /** Refetches [chatId]'s open requests (first 100) and recently resolved ones (first 20). */
+ suspend fun loadChat(chatId: ChatId): Result = runCatching {
+ val open = controller.getChatPaymentRequests(chatId, PaymentRequestSection.OPEN, QueryOptions(limit = OPEN_PAGE))
+ .getOrThrow()
+ val resolved = controller.getChatPaymentRequests(chatId, PaymentRequestSection.RESOLVED, QueryOptions(limit = RESOLVED_PAGE))
+ .getOrThrow()
+ ingest(open.paymentRequests + resolved.paymentRequests)
+ }
+
+ /**
+ * One page of [chatId]'s ended requests, most recently ended first, for Request Money history.
+ * [token] is the previous page's; null starts over.
+ */
+ suspend fun resolvedPage(chatId: ChatId, token: PagingToken?): Result =
+ controller.getChatPaymentRequests(
+ chatId,
+ PaymentRequestSection.RESOLVED,
+ QueryOptions(limit = RESOLVED_PAGE, token = token),
+ ).onSuccess { ingest(it.paymentRequests) }
+
+ /**
+ * Refetches one request. The returned details carry `payment_intent_id` for a viewer with an
+ * unpaid share; keep it for the one payment and do not store or log it.
+ */
+ suspend fun fetch(id: ID): Result =
+ controller.getPaymentRequest(id)
+ .onSuccess { ingest(listOf(it.paymentRequest)) }
+ .onFailure { if (it is GetPaymentRequestError.Denied || it is GetPaymentRequestError.NotFound) _missing.update { set -> set.plusElement(id) } }
+
+ /**
+ * Fetches [id] for a card that has nothing to show, once: not while a fetch for it is running,
+ * and not after one found it missing.
+ */
+ suspend fun fetchIfAbsent(id: ID) {
+ if (id in requests.value || id in _missing.value) return
+ var claimed = false
+ inFlight.update { held -> claimed = id !in held; held.plusElement(id) }
+ if (!claimed) return
+ try {
+ fetch(id)
+ } finally {
+ inFlight.update { it.minusElement(id) }
+ }
+ }
+
+ suspend fun create(request: NewSplitRequest, idempotencyKey: IdempotencyKey): Result =
+ controller.createPaymentRequest(request, idempotencyKey).map { ingestOne(it) }
+
+ suspend fun close(id: ID): Result =
+ controller.closePaymentRequest(id).map { ingestOne(it) }
+
+ suspend fun markPaid(id: ID, userId: ID): Result =
+ controller.markPaid(id, userId).map { ingestOne(it) }
+
+ /** Opens each request's text, then merges it into what is held. */
+ suspend fun ingest(incoming: List) {
+ val opened = incoming.map { open(it) }
+ requests.update { held -> opened.fold(held, ::merge) }
+ }
+
+ fun clear() {
+ requests.value = emptyMap()
+ _missing.value = emptySet()
+ }
+
+ private suspend fun ingestOne(request: PaymentRequest): PaymentRequest {
+ ingest(listOf(request))
+ return requests.value[request.id] ?: request
+ }
+
+ private suspend fun open(request: PaymentRequest): PaymentRequest {
+ val kind = request.kind as? PaymentRequest.Kind.GroupSplit ?: return request
+ if (kind.split.text !is PaymentRequestText.Encrypted) return request
+ // A key that can't be fetched yet leaves the text sealed; a later fetch opens it.
+ val text = crypto.openPaymentRequestText(kind.chatId, request.creator, kind.split.text)
+ .getOrElse { return request }
+ return request.copy(kind = kind.copy(split = kind.split.copy(text = text)))
+ }
+
+ companion object {
+ const val OPEN_PAGE = 100
+ const val RESOLVED_PAGE = 20
+
+ /**
+ * [held] with [incoming] merged in by [newerOf]. A copy still sealed never replaces an opened
+ * one of the same version.
+ */
+ fun merge(held: Map, incoming: PaymentRequest): Map {
+ val current = held[incoming.id]
+ val sealedOverOpened = current != null &&
+ current.version == incoming.version &&
+ incoming.split?.text is PaymentRequestText.Encrypted &&
+ current.split?.text !is PaymentRequestText.Encrypted
+ if (sealedOverOpened) return held
+ return held + (incoming.id to newerOf(current, incoming))
+ }
+ }
+}
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt
index 30724c484e..d27251c60b 100644
--- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt
@@ -123,8 +123,9 @@ internal class DmOutgoingEncryption @Inject constructor(
val selfId = userManager.accountId
?: return Result.failure(IllegalStateException("No account to encrypt from"))
- // A sealed message is one Text, one photo, or one Reply around either. Anything else can't
- // be sealed, and sending it in plaintext would break the chat's promise.
+ // A sealed message is one Text, one photo, one Reply around either, or one payment request
+ // widget. Anything else can't be sealed, and sending it in plaintext would break the
+ // chat's promise.
val single = content.singleOrNull()
?: return Result.failure(IllegalArgumentException("Can't encrypt ${content.size} content items"))
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt
index 50d0edce1c..118d41ab3e 100644
--- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt
@@ -12,6 +12,7 @@ import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.user.UserManager
import com.flipcash.shared.chat.ChatArchiveStore
import com.flipcash.shared.chat.LobbyUpdateSink
+import com.flipcash.shared.chat.PaymentRequests
import com.flipcash.shared.chat.RosterUpdateSink
import com.flipcash.shared.chat.ChatCoordinator
import com.flipcash.shared.chat.ChatDraftStore
@@ -105,6 +106,7 @@ class RealChatCoordinator @Inject constructor(
private val mediaSender: ChatMediaSending = ChatMediaSending.None,
private val lobbyUpdates: LobbyUpdateSink = LobbyUpdateSink.None,
private val rosterUpdates: RosterUpdateSink = RosterUpdateSink.None,
+ private val paymentRequests: PaymentRequests? = null,
) : ChatCoordinator,
SessionListener,
DefaultLifecycleObserver,
@@ -428,6 +430,7 @@ class RealChatCoordinator @Inject constructor(
reactionsDelegate.clearAll()
lobbyUpdates.clear()
rosterUpdates.clear()
+ paymentRequests?.clear()
cluster.value = null
supervisorJob.cancel()
trace(tag = TAG, message = "teardown complete", type = TraceType.Process)
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt
index cef5c5c30b..8c331f263e 100644
--- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt
@@ -2,6 +2,7 @@
package com.flipcash.shared.chat.internal.delegates
+import com.flipcash.shared.chat.PaymentRequestNotSent
import com.flipcash.shared.chat.link.OutgoingLinkPreview
import com.flipcash.shared.chat.link.LinkPreviewImages
import com.flipcash.shared.chat.link.LinkPreviewChecks
@@ -37,6 +38,7 @@ import com.flipcash.services.models.chat.MuteState
import com.flipcash.services.models.chat.PointerType
import com.flipcash.services.models.chat.TypingState
import com.flipcash.services.models.chat.ViewerState
+import com.flipcash.services.models.chat.WidgetContent
import com.flipcash.services.models.DeleteMessageError
import com.flipcash.services.models.EditMessageError
import com.flipcash.services.models.GetChatError
@@ -58,7 +60,9 @@ import com.getcode.opencode.model.core.ID
import com.getcode.utils.TraceType
import java.nio.ByteBuffer
import com.getcode.utils.trace
+import com.getcode.utils.hexEncodedString
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -73,6 +77,7 @@ import kotlinx.coroutines.flow.update
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
+import kotlin.time.Duration.Companion.seconds
/**
* Handles all per-chat messaging operations: sending and receiving messages,
@@ -478,6 +483,24 @@ class MessagingDelegate @Inject constructor(
return send(chatId, withImage, clientMessageId)
}
+ override suspend fun sendPaymentRequest(chatId: ChatId, requestId: ID): Result {
+ val senderId = userManager.accountId
+ ?: return Result.failure(IllegalStateException("Cannot send message without an account"))
+ val payload = listOf(MessageContent.Widget(WidgetContent.PaymentRequest(requestId)))
+ val (_, clientMessageId) = messageDataSource.insertPending(chatId = chatId, content = payload, senderId = senderId)
+ val pendingClientIdHex = clientMessageId.bytes.toList().hexEncodedString()
+ // The request already exists, so only the message is retried, on the same pending row.
+ var result = send(chatId, payload, clientMessageId)
+ for (attempt in 1..PAYMENT_REQUEST_SEND_RETRIES) {
+ val cause = result.exceptionOrNull() ?: break
+ if (cause is SendMessageError.Denied) break
+ delay(PAYMENT_REQUEST_RETRY_BACKOFF * attempt)
+ messageDataSource.retryPending(chatId, pendingClientIdHex)
+ result = send(chatId, payload, clientMessageId)
+ }
+ return result.recoverCatching { cause -> throw PaymentRequestNotSent(pendingClientIdHex, cause) }
+ }
+
override suspend fun retryMessage(chatId: ChatId, pendingClientIdHex: String, content: List): Result {
val clientMessageId = messageDataSource.retryPending(chatId, pendingClientIdHex)
@@ -802,6 +825,9 @@ class MessagingDelegate @Inject constructor(
private companion object {
const val TAG = "MessagingDelegate"
+ /** Retries after the first failed send of a payment request's widget. */
+ const val PAYMENT_REQUEST_SEND_RETRIES = 2
+ val PAYMENT_REQUEST_RETRY_BACKOFF = 1.seconds
/** The transcript's page size. */
const val HISTORY_PAGE_SIZE = 50
diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt
index b7a46fd77c..7ccd84ba69 100644
--- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt
+++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt
@@ -518,4 +518,32 @@ class MessagingSendEncryptionTest {
assertEquals(true, delegate.sendMessage(chatId, "hello group", replyToMessageId = null).isFailure)
coVerify(exactly = 0) { controller.sendMessage(chatId, any(), any()) }
}
+
+ @Test
+ fun `a payment request message that keeps failing is retried on its row, then names it`() = runTest {
+ storedChat(privateGroup())
+ answerSends()
+ coEvery { controller.sendMessage(chatId, any(), any()) } returns Result.failure(IOException("offline"))
+
+ val result = delegate.sendPaymentRequest(chatId, requestId = List(16) { 4 })
+
+ // The first send and two retries, all on the same pending row; Create is not this layer's.
+ coVerify(exactly = 3) { controller.sendMessage(chatId, any(), any()) }
+ coVerify(exactly = 2) { messages.retryPending(chatId, any()) }
+ coVerify(exactly = 1) { messages.insertPending(chatId, any(), selfId) }
+ assertIs(result.exceptionOrNull())
+ }
+
+ @Test
+ fun `a payment request message that goes through on a retry succeeds`() = runTest {
+ storedChat(privateGroup())
+ answerSends()
+ var calls = 0
+ coEvery { controller.sendMessage(chatId, any(), any()) } answers {
+ if (++calls == 1) Result.failure(IOException("offline")) else Result.success(serverCopy(secondArg()))
+ }
+
+ assertEquals(true, delegate.sendPaymentRequest(chatId, requestId = List(16) { 4 }).isSuccess)
+ assertEquals(2, calls)
+ }
}
diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PaymentRequestsMergeTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PaymentRequestsMergeTest.kt
new file mode 100644
index 0000000000..6c1eaa49e6
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PaymentRequestsMergeTest.kt
@@ -0,0 +1,75 @@
+package com.flipcash.shared.chat
+
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.paymentrequest.PaymentRequest
+import com.flipcash.services.models.paymentrequest.PaymentRequestStatus
+import com.flipcash.services.models.paymentrequest.PaymentRequestText
+import com.flipcash.services.models.paymentrequest.Split
+import com.getcode.opencode.model.financial.CurrencyCode
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.time.Instant
+
+/** The store keeps one copy per request id: the newer, and an opened text over a sealed one. */
+class PaymentRequestsMergeTest {
+
+ private val id = List(16) { 9 }
+
+ private fun request(version: Long, text: PaymentRequestText, status: PaymentRequestStatus = PaymentRequestStatus.Open) =
+ PaymentRequest(
+ id = id,
+ creator = List(16) { 1 },
+ createdAt = Instant.fromEpochSeconds(0),
+ status = status,
+ closedAt = null,
+ version = version,
+ kind = PaymentRequest.Kind.GroupSplit(
+ chatId = ChatId(ByteArray(32) { 3 }),
+ split = Split(text, CurrencyCode.USD, Instant.fromEpochSeconds(1_000), emptyList()),
+ ),
+ )
+
+ private val plain = PaymentRequestText.Plain("Dinner", "")
+ private val sealed = PaymentRequestText.Encrypted(1, ByteArray(24), ByteArray(17))
+
+ @Test
+ fun `a newer version replaces the held copy`() {
+ val held = mapOf(id to request(4, plain))
+ val incoming = request(5, plain, PaymentRequestStatus.Closed)
+ assertEquals(incoming, PaymentRequests.merge(held, incoming)[id])
+ }
+
+ @Test
+ fun `an older version is dropped`() {
+ val held = mapOf(id to request(5, plain))
+ assertEquals(held, PaymentRequests.merge(held, request(4, plain)))
+ }
+
+ @Test
+ fun `a still-sealed copy does not replace an opened one of the same version`() {
+ val held = mapOf(id to request(5, plain))
+ assertEquals(held, PaymentRequests.merge(held, request(5, sealed)))
+ }
+
+ @Test
+ fun `a still-sealed newer version replaces an opened older one`() {
+ val held = mapOf(id to request(4, plain))
+ val incoming = request(5, sealed)
+ assertEquals(incoming, PaymentRequests.merge(held, incoming)[id])
+ }
+
+ // 11
+ @Test
+ fun `an expired copy of the same version replaces an open one`() {
+ val held = mapOf(id to request(5, plain))
+ val incoming = request(5, plain, PaymentRequestStatus.Expired)
+ assertEquals(PaymentRequestStatus.Expired, PaymentRequests.merge(held, incoming)[id]?.status)
+ }
+
+ // 12
+ @Test
+ fun `an open copy of the same version does not replace an expired one`() {
+ val held = mapOf(id to request(5, plain, PaymentRequestStatus.Expired))
+ assertEquals(PaymentRequestStatus.Expired, PaymentRequests.merge(held, request(5, plain))[id]?.status)
+ }
+}
diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/RequestCardStateTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/RequestCardStateTest.kt
new file mode 100644
index 0000000000..7996fcdb90
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/RequestCardStateTest.kt
@@ -0,0 +1,78 @@
+package com.flipcash.shared.chat
+
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.paymentrequest.PaymentRequest
+import com.flipcash.services.models.paymentrequest.PaymentRequestStatus
+import com.flipcash.services.models.paymentrequest.PaymentRequestText
+import com.flipcash.services.models.paymentrequest.Split
+import com.getcode.opencode.model.financial.CurrencyCode
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.time.Instant
+
+/** Spec case 19, and the other rules for when a request widget draws as unsupported. */
+class RequestCardStateTest {
+
+ private val chat = ChatId(ByteArray(32) { 3 })
+ private val creator = List(16) { 1 }
+
+ private fun request(
+ chatId: ChatId = chat,
+ text: PaymentRequestText = PaymentRequestText.Plain("Dinner", ""),
+ kind: PaymentRequest.Kind? = null,
+ ) = PaymentRequest(
+ id = List(16) { 9 },
+ creator = creator,
+ createdAt = Instant.fromEpochSeconds(0),
+ status = PaymentRequestStatus.Open,
+ closedAt = null,
+ version = 1,
+ kind = kind ?: PaymentRequest.Kind.GroupSplit(
+ chatId = chatId,
+ split = Split(text, CurrencyCode.USD, Instant.fromEpochSeconds(1_000), emptyList()),
+ ),
+ )
+
+ @Test
+ fun `a request from the sender in this chat is a card`() {
+ val state = requestCardState(request(), missing = false, chatId = chat, senderId = creator)
+ assertEquals("Dinner", assertIs(state).title)
+ }
+
+ @Test
+ fun `a request made in another chat is unsupported`() {
+ val other = ChatId(ByteArray(32) { 4 })
+ assertEquals(RequestCardState.Unsupported, requestCardState(request(chatId = other), false, chat, creator))
+ }
+
+ @Test
+ fun `a request the sender did not create is unsupported`() {
+ assertEquals(RequestCardState.Unsupported, requestCardState(request(), false, chat, List(16) { 2 }))
+ }
+
+ @Test
+ fun `an unknown kind is unsupported`() {
+ val state = requestCardState(request(kind = PaymentRequest.Kind.Unsupported), false, chat, creator)
+ assertEquals(RequestCardState.Unsupported, state)
+ }
+
+ @Test
+ fun `text that failed to open or uses an unknown scheme is unsupported`() {
+ assertEquals(RequestCardState.Unsupported, requestCardState(request(text = PaymentRequestText.Undecryptable), false, chat, creator))
+ val unknownScheme = PaymentRequestText.Encrypted(7, ByteArray(24), ByteArray(17))
+ assertEquals(RequestCardState.Unsupported, requestCardState(request(text = unknownScheme), false, chat, creator))
+ }
+
+ @Test
+ fun `text still sealed under the known scheme is loading`() {
+ val sealed = PaymentRequestText.Encrypted(1, ByteArray(24), ByteArray(17))
+ assertEquals(RequestCardState.Loading, requestCardState(request(text = sealed), false, chat, creator))
+ }
+
+ @Test
+ fun `a request not held yet is loading, and one found missing is unsupported`() {
+ assertEquals(RequestCardState.Loading, requestCardState(null, false, chat, creator))
+ assertEquals(RequestCardState.Unsupported, requestCardState(null, true, chat, creator))
+ }
+}
diff --git a/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/BottomBarCardAction.kt b/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/BottomBarCardAction.kt
new file mode 100644
index 0000000000..8d75d5bdf7
--- /dev/null
+++ b/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/BottomBarCardAction.kt
@@ -0,0 +1,69 @@
+package com.flipcash.app.funding
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.unit.dp
+import com.getcode.manager.BottomBarAction
+import com.getcode.theme.CodeTheme
+
+/**
+ * A card row for a [com.getcode.manager.BottomBarManager] sheet: title, subtitle and a trailing
+ * icon. The Add Money method picker is built from these, and so is any sheet that offers a choice
+ * of money actions the same way.
+ */
+fun bottomBarCardAction(
+ title: String,
+ subtitle: String,
+ @DrawableRes iconRes: Int,
+ tintIcon: Boolean = true,
+ testTag: String? = null,
+ onClick: () -> Unit,
+): BottomBarAction = BottomBarAction(
+ text = AnnotatedString(title),
+ inlineContentMap = emptyMap(),
+ testTag = testTag,
+ onClick = onClick,
+ content = {
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Text(
+ text = title,
+ style = CodeTheme.typography.textMedium,
+ color = Color.White,
+ )
+ Text(
+ text = subtitle,
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textSecondary,
+ )
+ }
+ if (tintIcon) {
+ // Monochrome glyph (phantom, QR, reserves) — tint white and size square.
+ Icon(
+ modifier = Modifier.size(32.dp),
+ painter = painterResource(iconRes),
+ tint = Color.White,
+ contentDescription = null,
+ )
+ } else {
+ // Brand mark (Google Pay) — keep its own colours; constrain height, let width wrap.
+ Image(
+ modifier = Modifier.height(28.dp),
+ painter = painterResource(iconRes),
+ contentDescription = null,
+ )
+ }
+ },
+)
diff --git a/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/Buttons.kt b/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/Buttons.kt
index ac522c8b67..3086a23ce9 100644
--- a/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/Buttons.kt
+++ b/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/Buttons.kt
@@ -1,26 +1,14 @@
package com.flipcash.app.funding.internal
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.size
-import androidx.compose.material3.Icon
-import androidx.compose.material3.Text
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.unit.dp
import com.flipcash.app.core.money.formatted
import com.flipcash.app.funding.PurchaseMethod
import com.flipcash.app.funding.PurchaseMethodMetadata
import com.flipcash.app.funding.PurchaseMethodState
+import com.flipcash.app.funding.bottomBarCardAction
import com.flipcash.shared.funding.R
import com.getcode.manager.BottomBarAction
import com.getcode.manager.BottomBarManager
import com.getcode.opencode.model.financial.Fiat
-import com.getcode.theme.CodeTheme
import com.getcode.util.resources.ResourceHelper
/**
@@ -35,7 +23,7 @@ internal fun purchaseOptions(
): List = buildList {
if (state.coinbaseOnRampAvailable) {
add(
- cardAction(
+ bottomBarCardAction(
title = resources.getString(R.string.label_debitCard),
subtitle = resources.getString(R.string.subtitle_depositDebitCard),
iconRes = R.drawable.ic_google_pay_brand_mark,
@@ -49,7 +37,7 @@ internal fun purchaseOptions(
val minimumAmountNeeded = metadata.purchaseAmount ?: Fiat.MIN_VALUE
if (state.reservesBalance.nativeAmount >= minimumAmountNeeded) {
add(
- cardAction(
+ bottomBarCardAction(
title = resources.getString(R.string.label_cashReserves),
subtitle = state.reservesBalance.formatted(),
iconRes = R.drawable.ic_wallet,
@@ -60,7 +48,7 @@ internal fun purchaseOptions(
}
}
add(
- cardAction(
+ bottomBarCardAction(
title = resources.getString(R.string.label_phantom),
subtitle = resources.getString(R.string.subtitle_depositPhantom),
iconRes = R.drawable.ic_phantom_wallet,
@@ -70,7 +58,7 @@ internal fun purchaseOptions(
)
if (state.canUseOtherWallets) {
add(
- cardAction(
+ bottomBarCardAction(
title = resources.getString(R.string.title_onrampProviderOtherWallet),
subtitle = resources.getString(R.string.subtitle_depositOtherWallet),
iconRes = R.drawable.ic_qr_code,
@@ -86,50 +74,3 @@ internal fun purchaseOptions(
)
)
}
-
-private fun cardAction(
- title: String,
- subtitle: String,
- iconRes: Int,
- tintIcon: Boolean = true,
- testTag: String? = null,
- onClick: () -> Unit,
-): BottomBarAction = BottomBarAction(
- text = AnnotatedString(title),
- inlineContentMap = emptyMap(),
- testTag = testTag,
- onClick = onClick,
- content = {
- Column(
- modifier = Modifier.weight(1f),
- verticalArrangement = Arrangement.spacedBy(2.dp),
- ) {
- Text(
- text = title,
- style = CodeTheme.typography.textMedium,
- color = Color.White,
- )
- Text(
- text = subtitle,
- style = CodeTheme.typography.textSmall,
- color = CodeTheme.colors.textSecondary,
- )
- }
- if (tintIcon) {
- // Monochrome glyph (phantom, QR, reserves) — tint white and size square.
- Icon(
- modifier = Modifier.size(32.dp),
- painter = painterResource(iconRes),
- tint = Color.White,
- contentDescription = null,
- )
- } else {
- // Brand mark (Google Pay) — keep its own colours; constrain height, let width wrap.
- Image(
- modifier = Modifier.height(28.dp),
- painter = painterResource(iconRes),
- contentDescription = null,
- )
- }
- },
-)
diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/SharedStatePayment.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/SharedStatePayment.kt
index a22f2569fb..31baeb42e1 100644
--- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/SharedStatePayment.kt
+++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/SharedStatePayment.kt
@@ -11,6 +11,7 @@ import com.getcode.opencode.model.core.errors.ComputeVerifiedFiatError
import com.getcode.opencode.model.financial.Fiat
import com.getcode.opencode.model.financial.Rate
import com.getcode.opencode.model.financial.Token
+import com.getcode.solana.keys.PublicKey
/**
* Pays several people in one intent, each their own fiat amount. Shared by reaction boosts and
@@ -50,6 +51,8 @@ internal class SharedStatePayment(
/**
* Pays each of [payees] its [priced] amount from [source]: one payee is a single transfer, more
* are one multi-transfer in the order given. Debits the local balance by every part.
+ *
+ * [intentId] applies to a single transfer only: the id the server issued for the payment.
*/
suspend fun pay(
payees: List,
@@ -57,6 +60,7 @@ internal class SharedStatePayment(
token: Token,
source: AccountCluster,
appMetadata: ByteArray?,
+ intentId: PublicKey? = null,
): Result = runCatching {
require(payees.size == priced.size) { "${payees.size} payees for ${priced.size} amounts" }
val destinations = payees.map { resolverController.resolve(it).getOrThrow() }
@@ -68,8 +72,10 @@ internal class SharedStatePayment(
source = source,
destinationOwner = destinations.single(),
appMetadata = appMetadata,
+ intentId = intentId,
).getOrThrow()
} else {
+ require(intentId == null) { "An issued intent id pays one payee" }
transactionController.directMultiTransfer(
payments = destinations.zip(priced.map { it.localFiat }),
verifiedState = requireNotNull(priced.first().verifiedState),
diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/request/PaymentRequestPaymentDelegate.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/request/PaymentRequestPaymentDelegate.kt
new file mode 100644
index 0000000000..f8c4a91718
--- /dev/null
+++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/request/PaymentRequestPaymentDelegate.kt
@@ -0,0 +1,122 @@
+package com.flipcash.shared.payments.request
+
+import com.flipcash.app.tokens.TokenCoordinator
+import com.flipcash.services.controllers.ResolverController
+import com.flipcash.services.models.buildRequestedPaymentMetadata
+import com.flipcash.services.models.paymentrequest.PaymentIntentId
+import com.flipcash.services.models.paymentrequest.PaymentRequest
+import com.flipcash.services.models.paymentrequest.Share
+import com.flipcash.shared.chat.PaymentRequests
+import com.flipcash.shared.payments.SharedStatePayment
+import com.getcode.opencode.controllers.TransactionController
+import com.getcode.opencode.exchange.Exchange
+import com.getcode.opencode.exchange.VerifiedFiatCalculator
+import com.getcode.opencode.model.accounts.AccountCluster
+import com.getcode.opencode.model.core.ID
+import com.getcode.opencode.model.core.errors.ComputeVerifiedFiatError
+import com.getcode.opencode.model.financial.Token
+import com.getcode.solana.keys.PublicKey
+import com.getcode.solana.keys.base58
+import com.getcode.utils.LiveSecrets
+import com.getcode.utils.TraceType
+import com.getcode.utils.maskedSuffix
+import com.getcode.utils.trace
+import kotlin.time.Clock
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Pays the viewer's share of a payment request: one public payment to the creator, carrying
+ * requested-payment app metadata, under the intent id the server issued for that share.
+ *
+ * Call [prepare] immediately before showing the pay sheet, and again before any retry after an
+ * unknown outcome: it refetches the request, so a share that landed meanwhile comes back as
+ * [ShareToPay.NothingToPay] instead of paying twice. Paying twice under one id lands once anyway.
+ *
+ * No UI. Balance and send-limit checks on the share are the caller's.
+ */
+@Singleton
+class PaymentRequestPaymentDelegate @Inject constructor(
+ resolverController: ResolverController,
+ transactionController: TransactionController,
+ tokenCoordinator: TokenCoordinator,
+ verifiedFiatCalculator: VerifiedFiatCalculator,
+ private val exchange: Exchange,
+ private val paymentRequests: PaymentRequests,
+) {
+
+ private val payment = SharedStatePayment(
+ resolverController, transactionController, tokenCoordinator, verifiedFiatCalculator,
+ )
+
+ /** Where the pay outcome is logged; tests swap it to read the line. */
+ @androidx.annotation.VisibleForTesting
+ internal var log: (message: String, type: TraceType) -> Unit = { message, type ->
+ trace(tag = "PaymentRequest", message = message, type = type)
+ }
+
+ /** Refetches [requestId] and says whether [viewer] has a share to pay now. */
+ suspend fun prepare(requestId: ID, viewer: ID): Result =
+ paymentRequests.fetch(requestId).map { details ->
+ val request = details.paymentRequest
+ val intentId = details.paymentIntentId
+ val share = request.split?.shareOf(viewer)
+ when {
+ !request.isOpen(Clock.System.now()) -> ShareToPay.Closed(request)
+ intentId == null || share == null || share.isPaid -> ShareToPay.NothingToPay(request)
+ else -> ShareToPay.Ready(request, share, intentId)
+ }
+ }
+
+ /**
+ * Pays [ready]'s share in the request's currency, priced into [token] from [source]. Refetches
+ * the request after the payment lands so the row reads as paid.
+ */
+ suspend fun pay(ready: ShareToPay.Ready, token: Token, source: AccountCluster): Result {
+ val request = ready.request
+ val split = requireNotNull(request.split)
+ val rate = exchange.rateFor(split.currency)
+ ?: return Result.failure(ComputeVerifiedFiatError.StaleRate())
+ val priced = payment.price(listOf(ready.share.amount), token, rate)
+ .getOrElse { return Result.failure(it) }
+ val intentId = PublicKey(ready.intentId.bytes.toList())
+ // Until the share is paid, anyone who reads the id can use it up, so logs see only its
+ // last characters. Once paid it is harmless and logs in full.
+ val idBase58 = intentId.base58()
+ LiveSecrets.hold(idBase58)
+ return payment.pay(
+ payees = listOf(request.creator),
+ priced = priced,
+ token = token,
+ source = source,
+ appMetadata = buildRequestedPaymentMetadata(request.chatId, request.id.toByteArray()),
+ intentId = intentId,
+ ).onSuccess {
+ LiveSecrets.release(idBase58)
+ log("paid share under intent $idBase58", TraceType.Log)
+ paymentRequests.fetch(request.id)
+ }.onFailure {
+ log("share payment failed under intent ${idBase58.maskedSuffix()}", TraceType.Error)
+ }
+ }
+}
+
+/** What [PaymentRequestPaymentDelegate.prepare] found. */
+sealed interface ShareToPay {
+ val request: PaymentRequest
+
+ /** The viewer owes [share]; pay it under [intentId], which never prints. */
+ class Ready(
+ override val request: PaymentRequest,
+ val share: Share,
+ val intentId: PaymentIntentId,
+ ) : ShareToPay {
+ override fun toString(): String = "Ready(request=${request.id.size} bytes, share=$share)"
+ }
+
+ /** The viewer has no unpaid share: never asked, or already paid. */
+ data class NothingToPay(override val request: PaymentRequest) : ShareToPay
+
+ /** The request is no longer open: "Request Closed". */
+ data class Closed(override val request: PaymentRequest) : ShareToPay
+}
diff --git a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/request/PaymentRequestPaymentDelegateTest.kt b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/request/PaymentRequestPaymentDelegateTest.kt
new file mode 100644
index 0000000000..3d77db7594
--- /dev/null
+++ b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/request/PaymentRequestPaymentDelegateTest.kt
@@ -0,0 +1,199 @@
+package com.flipcash.shared.payments.request
+
+import com.flipcash.app.tokens.TokenCoordinator
+import com.flipcash.services.controllers.ResolverController
+import com.flipcash.services.models.buildRequestedPaymentMetadata
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.paymentrequest.PaidShare
+import com.flipcash.services.models.paymentrequest.PaymentIntentId
+import com.flipcash.services.models.paymentrequest.PaymentRequest
+import com.flipcash.services.models.paymentrequest.PaymentRequestDetails
+import com.flipcash.services.models.paymentrequest.PaymentRequestStatus
+import com.flipcash.services.models.paymentrequest.PaymentRequestText
+import com.flipcash.services.models.paymentrequest.Share
+import com.flipcash.services.models.paymentrequest.Split
+import com.flipcash.shared.chat.PaymentRequests
+import com.getcode.opencode.controllers.TransactionController
+import com.getcode.opencode.exchange.Exchange
+import com.getcode.opencode.exchange.VerifiedFiat
+import com.getcode.opencode.exchange.VerifiedFiatCalculator
+import com.getcode.opencode.internal.manager.VerifiedState
+import com.getcode.opencode.model.accounts.AccountCluster
+import com.getcode.opencode.model.core.ID
+import com.getcode.opencode.model.financial.CurrencyCode
+import com.getcode.opencode.model.financial.Fiat
+import com.getcode.opencode.model.financial.LocalFiat
+import com.getcode.opencode.model.financial.Rate
+import com.getcode.opencode.model.financial.Token
+import com.getcode.solana.keys.PublicKey
+import com.getcode.utils.LiveSecrets
+import com.getcode.vendor.Base58
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertFalse
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.days
+
+class PaymentRequestPaymentDelegateTest {
+
+ private val resolverController = mockk()
+ private val transactionController = mockk(relaxed = true)
+ private val tokenCoordinator = mockk(relaxed = true)
+ private val calculator = mockk()
+ private val exchange = mockk(relaxed = true)
+ private val paymentRequests = mockk()
+
+ private val delegate = PaymentRequestPaymentDelegate(
+ resolverController, transactionController, tokenCoordinator, calculator, exchange, paymentRequests,
+ )
+
+ private val chatId = ChatId(ByteArray(32) { 9 })
+ private val requestId: ID = List(32) { 4 }
+ private val creator: ID = List(16) { 1 }
+ private val viewer: ID = List(16) { 2 }
+ private val creatorKey = mockk()
+ private val intentBytes = ByteArray(32) { 7 }
+ private val token = mockk(relaxed = true)
+ private val source = mockk(relaxed = true)
+ private val state = mockk()
+ private val eur = mockk()
+
+ private fun request(
+ status: PaymentRequestStatus = PaymentRequestStatus.Open,
+ viewerPaid: Boolean = false,
+ ) = PaymentRequest(
+ id = requestId,
+ creator = creator,
+ createdAt = Clock.System.now(),
+ status = status,
+ closedAt = null,
+ version = 3,
+ kind = PaymentRequest.Kind.GroupSplit(
+ chatId = chatId,
+ split = Split(
+ text = PaymentRequestText.Plain("Dinner", ""),
+ currency = CurrencyCode.EUR,
+ endsAt = Clock.System.now() + 7.days,
+ shares = listOf(
+ Share(creator, Fiat("12.50", CurrencyCode.EUR), PaidShare(Clock.System.now(), PaidShare.How.Creator)),
+ Share(
+ viewer,
+ Fiat("12.50", CurrencyCode.EUR),
+ if (viewerPaid) PaidShare(Clock.System.now(), PaidShare.How.Payment) else null,
+ ),
+ ),
+ ),
+ ),
+ )
+
+ private fun serverReturns(request: PaymentRequest, intentId: PaymentIntentId?) {
+ coEvery { paymentRequests.fetch(requestId) } returns Result.success(PaymentRequestDetails(request, intentId))
+ }
+
+ init {
+ every { exchange.rateFor(CurrencyCode.EUR) } returns eur
+ coEvery { resolverController.resolve(userId = creator) } returns Result.success(creatorKey)
+ coEvery { calculator.compute(any(), any(), any(), any(), any()) } returns
+ Result.success(VerifiedFiat(mockk(relaxed = true), state))
+ }
+
+ @Test
+ fun `an unpaid share with an issued intent id is ready to pay`() = runTest {
+ serverReturns(request(), PaymentIntentId(intentBytes))
+
+ val ready = assertIs(delegate.prepare(requestId, viewer).getOrThrow())
+
+ assertEquals(viewer, ready.share.userId)
+ assertFalse(ready.toString().contains(intentBytes.toList().toString()))
+ }
+
+ @Test
+ fun `no intent id for the viewer means nothing to pay (case 20)`() = runTest {
+ serverReturns(request(), null)
+
+ assertIs(delegate.prepare(requestId, viewer).getOrThrow())
+ }
+
+ @Test
+ fun `a share paid meanwhile is nothing to pay, so a retry stops`() = runTest {
+ serverReturns(request(viewerPaid = true), PaymentIntentId(intentBytes))
+
+ assertIs(delegate.prepare(requestId, viewer).getOrThrow())
+ }
+
+ @Test
+ fun `a request no longer open is closed`() = runTest {
+ serverReturns(request(status = PaymentRequestStatus.Closed), PaymentIntentId(intentBytes))
+
+ assertIs(delegate.prepare(requestId, viewer).getOrThrow())
+ }
+
+ @Test
+ fun `paying sends the share to the creator under the issued id, priced from the request currency`() = runTest {
+ val r = request()
+ serverReturns(r, PaymentIntentId(intentBytes))
+ val ready = delegate.prepare(requestId, viewer).getOrThrow() as ShareToPay.Ready
+ val amount = slot()
+ val rate = slot()
+ coEvery { calculator.compute(capture(amount), any(), any(), capture(rate), any()) } returns
+ Result.success(VerifiedFiat(mockk(relaxed = true), state))
+ val metadata = slot()
+ val intentId = slot()
+ coEvery {
+ transactionController.directTransfer(any(), token, source, creatorKey, capture(metadata), any(), capture(intentId))
+ } returns Result.success(mockk())
+
+ assertTrue(delegate.pay(ready, token, source).isSuccess)
+
+ assertEquals(Fiat("12.50", CurrencyCode.EUR), amount.captured)
+ assertSame(eur, rate.captured)
+ assertContentEquals(intentBytes, intentId.captured.byteArray)
+ assertContentEquals(buildRequestedPaymentMetadata(chatId, requestId.toByteArray()), metadata.captured)
+ coVerify(exactly = 2) { paymentRequests.fetch(requestId) }
+ }
+
+ private suspend fun payWith(outcome: Result): List {
+ serverReturns(request(), PaymentIntentId(intentBytes))
+ val ready = delegate.prepare(requestId, viewer).getOrThrow() as ShareToPay.Ready
+ coEvery { calculator.compute(any(), any(), any(), any(), any()) } returns
+ Result.success(VerifiedFiat(mockk(relaxed = true), state))
+ coEvery {
+ transactionController.directTransfer(any(), token, source, creatorKey, any(), any(), any())
+ } returns outcome.map { mockk() }
+ val lines = mutableListOf()
+ delegate.log = { message, _ -> lines += message }
+ delegate.pay(ready, token, source)
+ return lines
+ }
+
+ private val intentBase58 = Base58.encode(intentBytes)
+
+ @Test
+ fun `a paid share logs its intent id in full and releases it`() = runTest {
+ val lines = payWith(Result.success(Unit))
+
+ assertTrue(lines.single().contains(intentBase58), lines.single())
+ assertFalse(LiveSecrets.isLive(intentBase58))
+ }
+
+ @Test
+ fun `a failed payment logs only the last six characters and keeps the id masked`() = runTest {
+ val lines = payWith(Result.failure(IllegalStateException("boom")))
+
+ assertTrue(lines.single().endsWith("…${intentBase58.takeLast(6)}"), lines.single())
+ assertFalse(intentBase58 in lines.single())
+ assertTrue(LiveSecrets.isLive(intentBase58))
+ LiveSecrets.release(intentBase58)
+ }
+}
diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt
index 03e38458b0..55555fd3f1 100644
--- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt
+++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt
@@ -254,6 +254,11 @@ sealed interface MessageContentSerialized {
@SerialName("widget_share_profile")
data class WidgetShareProfile(val username: String?) : MessageContentSerialized
+ // Mirrors WidgetContent.PaymentRequest; [paymentRequestId] is base58.
+ @Serializable
+ @SerialName("widget_payment_request")
+ data class WidgetPaymentRequest(val paymentRequestId: String) : MessageContentSerialized
+
// Mirrors MessageContent.System. [joinedUser] is set for a UserJoined event, and
// [admittedBy] when someone let them in; both hex-encoded. Rows stored before the event was
// kept have neither and decode as fallback-only.
diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt
index e1cf744305..4538e42a42 100644
--- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt
+++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt
@@ -418,9 +418,13 @@ private fun MessageContent.toSerialized(): MessageContentSerialized = when (this
items = items,
caption = caption?.let { MessageContentSerialized.Text(it.text) },
)
- is MessageContent.Widget -> MessageContentSerialized.WidgetShareProfile(
- username = (widget as? WidgetContent.ShareProfile)?.username,
- )
+ is MessageContent.Widget -> when (val widget = widget) {
+ is WidgetContent.PaymentRequest ->
+ MessageContentSerialized.WidgetPaymentRequest(paymentRequestId = widget.id.base58)
+ else -> MessageContentSerialized.WidgetShareProfile(
+ username = (widget as? WidgetContent.ShareProfile)?.username,
+ )
+ }
is MessageContent.System -> when (val event = event) {
is MessageContent.SystemEvent.UserJoined -> MessageContentSerialized.System(
fallbackText = fallbackText,
@@ -445,6 +449,9 @@ private fun MessageContentSerialized.toDomain(): MessageContent = when (this) {
is MessageContentSerialized.WidgetShareProfile -> MessageContent.Widget(
username?.let { WidgetContent.ShareProfile(it) } ?: WidgetContent.Unsupported,
)
+ is MessageContentSerialized.WidgetPaymentRequest -> MessageContent.Widget(
+ WidgetContent.PaymentRequest(paymentRequestId.decodeBase58().toList()),
+ )
is MessageContentSerialized.Text -> MessageContent.Text(text, linkPreview)
is MessageContentSerialized.Cash -> MessageContent.Cash(
intentId = intentId.decodeBase58().toList(),
diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt
index b839e8b4e4..a9db3a7393 100644
--- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt
+++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt
@@ -276,6 +276,22 @@ class ChatEntityMapperTest {
)
}
+ @Test
+ fun `a payment request widget survives the round trip through the message row`() {
+ val widget = MessageContent.Widget(WidgetContent.PaymentRequest(List(32) { it.toByte() }))
+ val message = ChatMessage(
+ messageId = 1,
+ senderId = listOf(0xAB.toByte()),
+ content = listOf(widget),
+ timestamp = Instant.fromEpochSeconds(1_000),
+ unreadSeq = 1,
+ )
+
+ val entity = mapper.toEntity(CHAT_HEX, message)
+
+ assertEquals(listOf(widget), mapper.toMessage(entity).content)
+ }
+
private fun groupMetadata() = ChatMetadata(
chatId = ChatId(CHAT_HEX),
type = ChatType.GROUP,
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatContentCrypto.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatContentCrypto.kt
index 7df50daf5a..4828640447 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatContentCrypto.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatContentCrypto.kt
@@ -6,6 +6,7 @@ import com.flipcash.services.internal.network.extensions.asMediaItem
import com.flipcash.services.internal.network.extensions.toMessageContent
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.MessageContent
+import com.flipcash.services.models.chat.WidgetContent
import com.getcode.chatcipher.ChatCipher
import com.getcode.chatcipher.ChatCipherException
import com.getcode.chatcipher.EncryptedPayload
@@ -215,11 +216,13 @@ internal fun MessageContent.isSealable(): Boolean = when (this) {
(content.all { it is MessageContent.Text && it.isSealable() } || content.singleOrNull()?.let {
it is MessageContent.Media && it.isSealable()
} == true)
+ // A private group sends its payment request widget sealed, like any other message.
+ is MessageContent.Widget -> widget is WidgetContent.PaymentRequest
else -> false
}
/**
- * The spec allows Text, Media, and a Reply of either. Media must meet [SealedMediaContract];
+ * The spec allows Text, Media, a Reply of either, and a payment request widget. Media must meet [SealedMediaContract];
* anything else takes the same "update" path as a type this client has never heard of.
*/
internal fun MessagingModel.Content.isRenderable(): Boolean = when (typeCase) {
@@ -233,5 +236,7 @@ internal fun MessagingModel.Content.isRenderable(): Boolean = when (typeCase) {
(reply.contentCount == 1 &&
reply.getContent(0).typeCase == MessagingModel.Content.TypeCase.MEDIA &&
reply.getContent(0).isRenderable()))
+ MessagingModel.Content.TypeCase.WIDGET ->
+ widget.typeCase == MessagingModel.WidgetContent.TypeCase.PAYMENT_REQUEST
else -> false
}
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupContentCrypto.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupContentCrypto.kt
index d4b40f982c..cabd2b8eca 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupContentCrypto.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupContentCrypto.kt
@@ -1,10 +1,12 @@
package com.flipcash.services.chat
import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel
+import com.codeinc.flipcash.gen.paymentrequest.v1.Model as PaymentRequestModel
import com.flipcash.services.internal.network.extensions.asContent
import com.flipcash.services.internal.network.extensions.toMessageContent
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.MessageContent
+import com.flipcash.services.models.paymentrequest.PaymentRequestText
import com.getcode.chatcipher.ChatCipher
import com.getcode.chatcipher.ChatCipherException
import com.getcode.chatcipher.EncryptedPayload
@@ -134,6 +136,64 @@ class GroupContentCrypto @Inject constructor(
return OpenedBlob.Plaintext(plaintext)
}
+ /** Seals a request's [title] and [description] that [creatorId], the viewer, asks in [chatId]. */
+ suspend fun sealPaymentRequestText(
+ chatId: ChatId,
+ creatorId: ID,
+ title: String,
+ description: String,
+ ): Result {
+ val groupKey = keys.key(chatId).getOrElse { return Result.failure(it) }
+ return runCatching {
+ val text = PaymentRequestModel.PaymentRequestText.newBuilder()
+ .setTitle(title)
+ .setDescription(description)
+ .build()
+ val payload = cipher.encryptPaymentRequestText(
+ text = text.toByteArray(),
+ groupKey = groupKey,
+ chatId = chatId.bytes,
+ creatorId = creatorId.toByteArray(),
+ )
+ PaymentRequestText.Encrypted(
+ scheme = PaymentRequestText.SCHEME_CHAT_KEY_XCHACHA20POLY1305,
+ nonce = payload.nonce,
+ ciphertext = payload.ciphertext,
+ )
+ }
+ }
+
+ /**
+ * Opens [text], a request [creatorId] made in [chatId]. Text that is not encrypted comes back as
+ * it is. A key that could not be fetched fails, so the caller can retry; any other failure is
+ * [PaymentRequestText.Undecryptable].
+ */
+ suspend fun openPaymentRequestText(chatId: ChatId, creatorId: ID, text: PaymentRequestText): Result {
+ if (text !is PaymentRequestText.Encrypted) return Result.success(text)
+ if (text.scheme != PaymentRequestText.SCHEME_CHAT_KEY_XCHACHA20POLY1305) {
+ return Result.success(PaymentRequestText.Undecryptable)
+ }
+ val groupKey = keys.key(chatId).getOrElse { cause ->
+ return if (cause is UntrustedEnvelope) Result.success(PaymentRequestText.Undecryptable) else Result.failure(cause)
+ }
+ val plaintext = try {
+ cipher.decryptPaymentRequestText(
+ payload = EncryptedPayload(nonce = text.nonce, ciphertext = text.ciphertext),
+ groupKey = groupKey,
+ chatId = chatId.bytes,
+ creatorId = creatorId.toByteArray(),
+ )
+ } catch (_: ChatCipherException) {
+ return Result.success(PaymentRequestText.Undecryptable)
+ }
+ val opened = try {
+ PaymentRequestModel.PaymentRequestText.parseFrom(plaintext)
+ } catch (_: InvalidProtocolBufferException) {
+ return Result.success(PaymentRequestText.Undecryptable)
+ }
+ return Result.success(PaymentRequestText.Plain(opened.title, opened.description))
+ }
+
companion object {
/** `EncryptedContent.Scheme.CHAT_KEY_XCHACHA20POLY1305`. */
const val SCHEME_CHAT_KEY_XCHACHA20POLY1305 = 2
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/PaymentRequestController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/PaymentRequestController.kt
index d0a5937d3a..ccfd7e8c47 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/PaymentRequestController.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/PaymentRequestController.kt
@@ -1,8 +1,10 @@
package com.flipcash.services.controllers
-import com.codeinc.flipcash.gen.paymentrequest.v1.Model
+import com.flipcash.services.models.chat.IdempotencyKey
import com.flipcash.services.models.QueryOptions
import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.paymentrequest.NewSplitRequest
+import com.flipcash.services.models.paymentrequest.PaymentRequest
import com.flipcash.services.models.paymentrequest.PaymentRequestDetails
import com.flipcash.services.models.paymentrequest.PaymentRequestPage
import com.flipcash.services.models.paymentrequest.PaymentRequestSection
@@ -15,13 +17,13 @@ class PaymentRequestController @Inject constructor(
private val repository: PaymentRequestRepository,
private val userManager: UserManager,
) {
- suspend fun createPaymentRequest(groupChat: Model.GroupChatPaymentRequest, idempotencyKey: Model.IdempotencyKey): Result {
+ suspend fun createPaymentRequest(request: NewSplitRequest, idempotencyKey: IdempotencyKey): Result {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))
- return repository.createPaymentRequest(owner, groupChat, idempotencyKey)
+ return repository.createPaymentRequest(owner, request, idempotencyKey)
}
- suspend fun getPaymentRequest(paymentRequestId: Model.PaymentRequestId): Result {
+ suspend fun getPaymentRequest(paymentRequestId: ID): Result {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))
return repository.getPaymentRequest(owner, paymentRequestId)
@@ -33,13 +35,13 @@ class PaymentRequestController @Inject constructor(
return repository.getChatPaymentRequests(owner, chatId, section, queryOptions)
}
- suspend fun closePaymentRequest(paymentRequestId: Model.PaymentRequestId): Result {
+ suspend fun closePaymentRequest(paymentRequestId: ID): Result {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))
return repository.closePaymentRequest(owner, paymentRequestId)
}
- suspend fun markPaid(paymentRequestId: Model.PaymentRequestId, userId: ID): Result {
+ suspend fun markPaid(paymentRequestId: ID, userId: ID): Result {
val owner = userManager.accountCluster?.authority?.keyPair
?: return Result.failure(Throwable("No account cluster in UserManager"))
return repository.markPaid(owner, paymentRequestId, userId)
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt
index b735b93535..bfc91daa44 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt
@@ -19,6 +19,7 @@ import com.flipcash.services.models.chat.SilenceState
import com.flipcash.services.models.chat.PointerType
import com.flipcash.services.models.chat.TypingState
import com.flipcash.services.models.chat.ViewMode
+import com.flipcash.services.models.chat.WidgetContent
import com.getcode.ed25519.Ed25519.KeyPair
import com.getcode.network.jwt.ApiProvider
import com.getcode.opencode.model.core.ID
@@ -26,6 +27,7 @@ import com.getcode.opencode.model.financial.Fiat
import com.getcode.solana.keys.Checksum
import com.getcode.solana.keys.PublicKey
import com.getcode.utils.toByteString
+import com.flipcash.services.models.paymentrequest.NewSplitRequest
import com.google.protobuf.Timestamp
import kotlin.time.Instant
import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel
@@ -194,8 +196,19 @@ internal fun MessageContent.asContent(): MessagingModel.Content {
}
)
.build()
- // Server-authored and receive-only: no client path builds one, so reaching this is a bug.
- is MessageContent.Widget -> error("Widget messages are receive-only and are never sent by a client")
+ // A client sends only the payment request widget; share-profile is server-authored.
+ is MessageContent.Widget -> when (val widget = widget) {
+ is WidgetContent.PaymentRequest -> MessagingModel.Content.newBuilder()
+ .setWidget(
+ MessagingModel.WidgetContent.newBuilder().setPaymentRequest(
+ MessagingModel.PaymentRequestWidget.newBuilder()
+ .setPaymentRequestId(widget.id.asPaymentRequestId())
+ )
+ )
+ .build()
+ is WidgetContent.ShareProfile, WidgetContent.Unsupported ->
+ error("Only a payment request widget is ever sent by a client")
+ }
// Only ever decoded from a type this build doesn't know, so it has no wire form to send.
is MessageContent.Unsupported -> error("Unsupported content is decode-only; it has no wire form")
is MessageContent.System -> {
@@ -414,3 +427,35 @@ internal fun com.flipcash.services.models.chat.KeyEnvelope.asProtoKeyEnvelope():
.setCiphertext(ciphertext.toByteString())
.build()
}
+
+internal fun ID.asPaymentRequestId(): PaymentRequestModel.PaymentRequestId {
+ return PaymentRequestModel.PaymentRequestId.newBuilder().setValue(toByteString()).build()
+}
+
+internal fun IdempotencyKey.asPaymentRequestIdempotencyKey(): PaymentRequestModel.IdempotencyKey {
+ return PaymentRequestModel.IdempotencyKey.newBuilder().setValue(bytes.toByteString()).build()
+}
+
+internal fun NewSplitRequest.asGroupChatPaymentRequest(): PaymentRequestModel.GroupChatPaymentRequest {
+ val split = PaymentRequestModel.Split.newBuilder()
+ .setEncrypted(
+ PaymentRequestModel.EncryptedPaymentRequestText.newBuilder()
+ .setSchemeValue(text.scheme)
+ .setNonce(text.nonce.toByteString())
+ .setCiphertext(text.ciphertext.toByteString())
+ )
+ .setCurrency(currency.name.lowercase())
+ .setEndsAt(endsAt.asTimestamp())
+ .addAllShares(
+ shares.map { userId ->
+ PaymentRequestModel.Share.newBuilder()
+ .setUserId(userId.asUserId())
+ .setNativeAmount(perPerson.decimalValue)
+ .build()
+ }
+ )
+ return PaymentRequestModel.GroupChatPaymentRequest.newBuilder()
+ .setChatId(chatId.asChatId())
+ .setSplit(split)
+ .build()
+}
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt
index 1c73d9a278..e37b7f7e09 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt
@@ -81,6 +81,14 @@ import com.codeinc.flipcash.gen.activity.v1.Model as ActivityModels
import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel
import com.codeinc.flipcash.gen.events.v1.Model as EventModel
import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel
+import com.codeinc.flipcash.gen.paymentrequest.v1.Model as PaymentRequestModel
+import com.flipcash.services.models.paymentrequest.PaidShare
+import com.flipcash.services.models.paymentrequest.PaymentRequest
+import com.flipcash.services.models.paymentrequest.PaymentRequestStatus
+import com.flipcash.services.models.paymentrequest.PaymentRequestText
+import com.flipcash.services.models.paymentrequest.Share
+import com.flipcash.services.models.paymentrequest.Split
+import com.flipcash.services.models.paymentrequest.shareAmount
internal fun ActivityModels.NotificationId.toId(): ID = value.toByteArray().toList()
internal fun Common.UserId.toId(): ID = value.toByteArray().toList()
@@ -244,8 +252,11 @@ internal fun MessagingModel.Content.toMessageContent(): MessageContent {
when (widget.typeCase) {
MessagingModel.WidgetContent.TypeCase.SHARE_PROFILE ->
WidgetContent.ShareProfile(widget.shareProfile.username.value)
- // PAYMENT_REQUEST carries only the request id. It renders as unsupported until a
- // UI consumes payment requests, which also owns the chat/creator mismatch check.
+ // Only the id travels; the card owns the chat/creator mismatch check.
+ MessagingModel.WidgetContent.TypeCase.PAYMENT_REQUEST ->
+ WidgetContent.PaymentRequest(
+ widget.paymentRequest.paymentRequestId.value.toByteArray().toList()
+ )
else -> WidgetContent.Unsupported
}
)
@@ -843,3 +854,60 @@ private fun MessagingModel.SystemContent.toSystemEvent(): MessageContent.SystemE
}
else -> null
}
+
+internal fun PaymentRequestModel.PaymentRequestMetadata.toPaymentRequest(): PaymentRequest = PaymentRequest(
+ id = paymentRequestId.value.toByteArray().toList(),
+ creator = creator.toId(),
+ createdAt = Instant.fromEpochSeconds(createdAt.seconds, createdAt.nanos),
+ status = when (status) {
+ PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_OPEN -> PaymentRequestStatus.Open
+ PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_COMPLETED -> PaymentRequestStatus.Completed
+ PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_CLOSED -> PaymentRequestStatus.Closed
+ PaymentRequestModel.PaymentRequestStatus.PAYMENT_REQUEST_STATUS_EXPIRED -> PaymentRequestStatus.Expired
+ else -> PaymentRequestStatus.Unknown
+ },
+ closedAt = if (hasClosedAt()) Instant.fromEpochSeconds(closedAt.seconds, closedAt.nanos) else null,
+ version = version,
+ kind = groupChat.takeIf { hasGroupChat() }?.toKind() ?: PaymentRequest.Kind.Unsupported,
+)
+
+private fun PaymentRequestModel.GroupChatPaymentRequest.toKind(): PaymentRequest.Kind {
+ if (!hasSplit()) return PaymentRequest.Kind.Unsupported
+ // An unknown currency can't be priced or shown; the request renders as unsupported.
+ val currency = CurrencyCode.tryValueOf(split.currency) ?: return PaymentRequest.Kind.Unsupported
+ val text = when (split.textCase) {
+ PaymentRequestModel.Split.TextCase.PLAINTEXT ->
+ PaymentRequestText.Plain(split.plaintext.title, split.plaintext.description)
+ PaymentRequestModel.Split.TextCase.ENCRYPTED -> PaymentRequestText.Encrypted(
+ scheme = split.encrypted.schemeValue,
+ nonce = split.encrypted.nonce.toByteArray(),
+ ciphertext = split.encrypted.ciphertext.toByteArray(),
+ )
+ else -> PaymentRequestText.Undecryptable
+ }
+ return PaymentRequest.Kind.GroupSplit(
+ chatId = chatId.toChatId(),
+ split = Split(
+ text = text,
+ currency = currency,
+ endsAt = Instant.fromEpochSeconds(split.endsAt.seconds, split.endsAt.nanos),
+ shares = split.sharesList.map { share ->
+ Share(
+ userId = share.userId.toId(),
+ amount = shareAmount(share.nativeAmount, currency),
+ paid = if (share.hasPaid()) share.paid.toPaidShare() else null,
+ )
+ },
+ ),
+ )
+}
+
+private fun PaymentRequestModel.PaidShare.toPaidShare(): PaidShare = PaidShare(
+ paidAt = Instant.fromEpochSeconds(paidAt.seconds, paidAt.nanos),
+ how = when (howCase) {
+ PaymentRequestModel.PaidShare.HowCase.INTENT -> PaidShare.How.Payment
+ PaymentRequestModel.PaidShare.HowCase.MANUAL -> PaidShare.How.Manual
+ PaymentRequestModel.PaidShare.HowCase.CREATOR -> PaidShare.How.Creator
+ else -> PaidShare.How.Unknown
+ },
+)
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/PaymentRequestService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/PaymentRequestService.kt
index e9c9e3cab1..f37c292347 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/PaymentRequestService.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/PaymentRequestService.kt
@@ -1,9 +1,13 @@
package com.flipcash.services.internal.network.services
-import com.codeinc.flipcash.gen.paymentrequest.v1.Model
+import com.flipcash.services.models.chat.IdempotencyKey
import com.codeinc.flipcash.gen.paymentrequest.v1.PaymentrequestService
import com.flipcash.services.internal.network.api.PaymentRequestApi
+import com.flipcash.services.internal.network.extensions.asGroupChatPaymentRequest
+import com.flipcash.services.internal.network.extensions.asPaymentRequestId
+import com.flipcash.services.internal.network.extensions.asPaymentRequestIdempotencyKey
import com.flipcash.services.internal.network.extensions.toFlaggedCategory
+import com.flipcash.services.internal.network.extensions.toPaymentRequest
import com.flipcash.services.internal.network.extensions.toPagingToken
import com.flipcash.services.models.ClosePaymentRequestError
import com.flipcash.services.models.CreatePaymentRequestError
@@ -12,6 +16,9 @@ import com.flipcash.services.models.GetPaymentRequestError
import com.flipcash.services.models.MarkPaymentRequestPaidError
import com.flipcash.services.models.QueryOptions
import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.paymentrequest.NewSplitRequest
+import com.flipcash.services.models.paymentrequest.PaymentIntentId
+import com.flipcash.services.models.paymentrequest.PaymentRequest
import com.flipcash.services.models.paymentrequest.PaymentRequestDetails
import com.flipcash.services.models.paymentrequest.PaymentRequestPage
import com.flipcash.services.models.paymentrequest.PaymentRequestSection
@@ -26,15 +33,15 @@ internal class PaymentRequestService @Inject constructor(
) {
suspend fun createPaymentRequest(
owner: Ed25519.KeyPair,
- groupChat: Model.GroupChatPaymentRequest,
- idempotencyKey: Model.IdempotencyKey,
- ): Result {
+ request: NewSplitRequest,
+ idempotencyKey: IdempotencyKey,
+ ): Result {
return runCatching {
- api.createPaymentRequest(owner, groupChat, idempotencyKey)
+ api.createPaymentRequest(owner, request.asGroupChatPaymentRequest(), idempotencyKey.asPaymentRequestIdempotencyKey())
}.foldWithSuppression(
onSuccess = { response ->
when (response.result) {
- PaymentrequestService.CreatePaymentRequestResponse.Result.OK -> Result.success(response.paymentRequest)
+ PaymentrequestService.CreatePaymentRequestResponse.Result.OK -> Result.success(response.paymentRequest.toPaymentRequest())
PaymentrequestService.CreatePaymentRequestResponse.Result.DENIED -> Result.failure(CreatePaymentRequestError.Denied())
PaymentrequestService.CreatePaymentRequestResponse.Result.CHAT_NOT_SUPPORTED -> Result.failure(CreatePaymentRequestError.ChatNotSupported())
PaymentrequestService.CreatePaymentRequestResponse.Result.ENCRYPTION_NOT_ALLOWED -> Result.failure(CreatePaymentRequestError.EncryptionNotAllowed())
@@ -55,16 +62,16 @@ internal class PaymentRequestService @Inject constructor(
suspend fun getPaymentRequest(
owner: Ed25519.KeyPair,
- paymentRequestId: Model.PaymentRequestId,
+ paymentRequestId: ID,
): Result {
return runCatching {
- api.getPaymentRequest(owner, paymentRequestId)
+ api.getPaymentRequest(owner, paymentRequestId.asPaymentRequestId())
}.foldWithSuppression(
onSuccess = { response ->
when (response.result) {
PaymentrequestService.GetPaymentRequestResponse.Result.OK -> Result.success(PaymentRequestDetails(
- paymentRequest = response.paymentRequest,
- paymentIntentId = if (response.hasPaymentIntentId()) response.paymentIntentId else null,
+ paymentRequest = response.paymentRequest.toPaymentRequest(),
+ paymentIntentId = if (response.hasPaymentIntentId()) PaymentIntentId(response.paymentIntentId.value.toByteArray()) else null,
))
PaymentrequestService.GetPaymentRequestResponse.Result.DENIED -> Result.failure(GetPaymentRequestError.Denied())
PaymentrequestService.GetPaymentRequestResponse.Result.NOT_FOUND -> Result.failure(GetPaymentRequestError.NotFound())
@@ -90,7 +97,7 @@ internal class PaymentRequestService @Inject constructor(
onSuccess = { response ->
when (response.result) {
PaymentrequestService.GetChatPaymentRequestsResponse.Result.OK -> Result.success(PaymentRequestPage(
- paymentRequests = response.paymentRequestsList,
+ paymentRequests = response.paymentRequestsList.map { it.toPaymentRequest() },
pagingToken = if (response.hasPagingToken()) response.pagingToken.toPagingToken() else null,
hasMore = response.hasMore,
))
@@ -108,14 +115,14 @@ internal class PaymentRequestService @Inject constructor(
suspend fun closePaymentRequest(
owner: Ed25519.KeyPair,
- paymentRequestId: Model.PaymentRequestId,
- ): Result {
+ paymentRequestId: ID,
+ ): Result {
return runCatching {
- api.closePaymentRequest(owner, paymentRequestId)
+ api.closePaymentRequest(owner, paymentRequestId.asPaymentRequestId())
}.foldWithSuppression(
onSuccess = { response ->
when (response.result) {
- PaymentrequestService.ClosePaymentRequestResponse.Result.OK -> Result.success(response.paymentRequest)
+ PaymentrequestService.ClosePaymentRequestResponse.Result.OK -> Result.success(response.paymentRequest.toPaymentRequest())
PaymentrequestService.ClosePaymentRequestResponse.Result.DENIED -> Result.failure(ClosePaymentRequestError.Denied())
PaymentrequestService.ClosePaymentRequestResponse.Result.NOT_FOUND -> Result.failure(ClosePaymentRequestError.NotFound())
PaymentrequestService.ClosePaymentRequestResponse.Result.UNRECOGNIZED -> Result.failure(ClosePaymentRequestError.Unrecognized())
@@ -130,15 +137,15 @@ internal class PaymentRequestService @Inject constructor(
suspend fun markPaid(
owner: Ed25519.KeyPair,
- paymentRequestId: Model.PaymentRequestId,
+ paymentRequestId: ID,
userId: ID,
- ): Result {
+ ): Result {
return runCatching {
- api.markPaid(owner, paymentRequestId, userId)
+ api.markPaid(owner, paymentRequestId.asPaymentRequestId(), userId)
}.foldWithSuppression(
onSuccess = { response ->
when (response.result) {
- PaymentrequestService.MarkPaidResponse.Result.OK -> Result.success(response.paymentRequest)
+ PaymentrequestService.MarkPaidResponse.Result.OK -> Result.success(response.paymentRequest.toPaymentRequest())
PaymentrequestService.MarkPaidResponse.Result.DENIED -> Result.failure(MarkPaymentRequestPaidError.Denied())
PaymentrequestService.MarkPaidResponse.Result.NOT_FOUND -> Result.failure(MarkPaymentRequestPaidError.NotFound())
PaymentrequestService.MarkPaidResponse.Result.NOT_OPEN -> Result.failure(MarkPaymentRequestPaidError.NotOpen())
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalPaymentRequestRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalPaymentRequestRepository.kt
index 528f8f99bf..854f9b04ee 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalPaymentRequestRepository.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalPaymentRequestRepository.kt
@@ -1,12 +1,14 @@
package com.flipcash.services.internal.repositories
-import com.codeinc.flipcash.gen.paymentrequest.v1.Model
+import com.flipcash.services.models.chat.IdempotencyKey
import com.flipcash.services.internal.network.services.PaymentRequestService
import com.flipcash.services.models.CreatePaymentRequestError
import com.flipcash.services.models.GetChatPaymentRequestsError
import com.flipcash.services.models.GetPaymentRequestError
import com.flipcash.services.models.QueryOptions
import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.paymentrequest.NewSplitRequest
+import com.flipcash.services.models.paymentrequest.PaymentRequest
import com.flipcash.services.models.paymentrequest.PaymentRequestDetails
import com.flipcash.services.models.paymentrequest.PaymentRequestPage
import com.flipcash.services.models.paymentrequest.PaymentRequestSection
@@ -20,10 +22,10 @@ internal class InternalPaymentRequestRepository(
) : PaymentRequestRepository {
override suspend fun createPaymentRequest(
owner: KeyPair,
- groupChat: Model.GroupChatPaymentRequest,
- idempotencyKey: Model.IdempotencyKey,
- ): Result {
- return service.createPaymentRequest(owner, groupChat, idempotencyKey)
+ request: NewSplitRequest,
+ idempotencyKey: IdempotencyKey,
+ ): Result {
+ return service.createPaymentRequest(owner, request, idempotencyKey)
.onFailure {
if (it !is CreatePaymentRequestError.Denied && it !is CreatePaymentRequestError.TextModerated) {
ErrorUtils.handleError(it)
@@ -33,7 +35,7 @@ internal class InternalPaymentRequestRepository(
override suspend fun getPaymentRequest(
owner: KeyPair,
- paymentRequestId: Model.PaymentRequestId,
+ paymentRequestId: ID,
): Result {
return service.getPaymentRequest(owner, paymentRequestId)
.onFailure { if (it !is GetPaymentRequestError.NotFound) ErrorUtils.handleError(it) }
@@ -51,17 +53,17 @@ internal class InternalPaymentRequestRepository(
override suspend fun closePaymentRequest(
owner: KeyPair,
- paymentRequestId: Model.PaymentRequestId,
- ): Result {
+ paymentRequestId: ID,
+ ): Result {
return service.closePaymentRequest(owner, paymentRequestId)
.onFailure { ErrorUtils.handleError(it) }
}
override suspend fun markPaid(
owner: KeyPair,
- paymentRequestId: Model.PaymentRequestId,
+ paymentRequestId: ID,
userId: ID,
- ): Result {
+ ): Result {
return service.markPaid(owner, paymentRequestId, userId)
.onFailure { ErrorUtils.handleError(it) }
}
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/WidgetContent.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/WidgetContent.kt
index bafd31e9e2..66ce1decef 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/WidgetContent.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/WidgetContent.kt
@@ -1,10 +1,18 @@
package com.flipcash.services.models.chat
+import com.getcode.opencode.model.core.ID
+
/** The variant carried by a [MessageContent.Widget]. */
sealed interface WidgetContent {
/** Invites the viewer to open [username]'s profile. */
data class ShareProfile(val username: String) : WidgetContent
+ /**
+ * Points at a payment request by id. The request itself lives in the payment request store;
+ * the card checks it belongs to this chat and was created by the sender before rendering.
+ */
+ data class PaymentRequest(val id: ID) : WidgetContent
+
/** A `WidgetContent.type` this client does not recognise. */
data object Unsupported : WidgetContent
}
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequest.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequest.kt
new file mode 100644
index 0000000000..50d2ef3caf
--- /dev/null
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequest.kt
@@ -0,0 +1,236 @@
+package com.flipcash.services.models.paymentrequest
+
+import com.flipcash.services.models.chat.ChatId
+import com.getcode.opencode.model.core.ID
+import com.getcode.opencode.model.financial.CurrencyCode
+import com.getcode.opencode.model.financial.Fiat
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Instant
+
+/**
+ * A payment request: `paymentrequest.v1.PaymentRequestMetadata`, of the one kind the app knows, a
+ * split in a group chat.
+ *
+ * [status] is as the server last sent it. Read [resolvedStatus] and [resolvedClosedAt] instead: an
+ * OPEN request past its [endsAt] has expired by the clock, which the server never announces.
+ *
+ * @param version advances by one on every change; the store keeps the greater (see [newerOf]).
+ */
+data class PaymentRequest(
+ val id: ID,
+ val creator: ID,
+ val createdAt: Instant,
+ val status: PaymentRequestStatus,
+ val closedAt: Instant?,
+ val version: Long,
+ val kind: Kind,
+) {
+ /** Where the request was made and what it asks. An arm the app does not know is [Unsupported]. */
+ sealed interface Kind {
+ data class GroupSplit(val chatId: ChatId, val split: Split) : Kind
+ data object Unsupported : Kind
+ }
+
+ val split: Split? get() = (kind as? Kind.GroupSplit)?.split
+ val chatId: ChatId? get() = (kind as? Kind.GroupSplit)?.chatId
+
+ fun resolvedStatus(now: Instant): PaymentRequestStatus {
+ val endsAt = split?.endsAt
+ return if (status == PaymentRequestStatus.Open && endsAt != null && endsAt <= now) {
+ PaymentRequestStatus.Expired
+ } else {
+ status
+ }
+ }
+
+ fun resolvedClosedAt(now: Instant): Instant? =
+ if (status == PaymentRequestStatus.Open && resolvedStatus(now) == PaymentRequestStatus.Expired) split?.endsAt
+ else closedAt
+
+ fun isOpen(now: Instant): Boolean = resolvedStatus(now) == PaymentRequestStatus.Open
+
+ /** Whether the deck still shows it: a split, open or ended less than 24 hours ago. */
+ fun isInDeck(now: Instant): Boolean {
+ if (split == null) return false
+ if (isOpen(now)) return true
+ val closed = resolvedClosedAt(now) ?: return false
+ return now - closed < ENDED_VISIBLE_FOR
+ }
+
+ companion object {
+ val ENDED_VISIBLE_FOR = 24.hours
+ }
+}
+
+enum class PaymentRequestStatus { Open, Completed, Closed, Expired, Unknown }
+
+/**
+ * Named members each owe the creator [Share.amount], all in [currency].
+ *
+ * Sums are in [Fiat.quarks] so "$20 paid, $520 requested" is exact rather than a sum of doubles.
+ */
+data class Split(
+ val text: PaymentRequestText,
+ val currency: CurrencyCode,
+ val endsAt: Instant,
+ val shares: List,
+) {
+ val requested: Fiat get() = Fiat(quarks = shares.sumOf { it.amount.quarks }, currencyCode = currency)
+ val paid: Fiat get() = Fiat(quarks = shares.filter { it.isPaid }.sumOf { it.amount.quarks }, currencyCode = currency)
+
+ /** Paid over requested, the creator's share on both sides; 0 for a split with nothing asked. */
+ val paidFraction: Float
+ get() {
+ val total = requested.quarks
+ return if (total <= 0L) 0f else paid.quarks.toFloat() / total
+ }
+
+ fun shareOf(userId: ID): Share? = shares.firstOrNull { it.userId == userId }
+}
+
+data class Share(
+ val userId: ID,
+ val amount: Fiat,
+ val paid: PaidShare?,
+) {
+ val isPaid: Boolean get() = paid != null
+}
+
+/** How a share was paid. Once paid it stays paid. */
+data class PaidShare(val paidAt: Instant, val how: How) {
+ enum class How { Payment, Manual, Creator, Unknown }
+}
+
+/**
+ * The request's title and description. A private group's arrives [Encrypted] and is opened once,
+ * on its way into the store, into [Plain] or [Undecryptable].
+ */
+sealed interface PaymentRequestText {
+ data class Plain(val title: String, val description: String) : PaymentRequestText
+
+ class Encrypted(val scheme: Int, val nonce: ByteArray, val ciphertext: ByteArray) : PaymentRequestText
+
+ /** The text failed to open, or its scheme is unknown. The request renders as unsupported. */
+ data object Undecryptable : PaymentRequestText
+
+ companion object {
+ /** `EncryptedPaymentRequestText.Scheme.CHAT_KEY_XCHACHA20POLY1305`. */
+ const val SCHEME_CHAT_KEY_XCHACHA20POLY1305 = 1
+ }
+}
+
+/** Builds a split's share amount from the wire's `native_amount`, rounded to the nearest quark. */
+fun shareAmount(nativeAmount: Double, currency: CurrencyCode): Fiat =
+ Fiat(quarks = Math.round(nativeAmount * Fiat.MULTIPLIER), currencyCode = currency)
+
+/**
+ * The copy to keep when [incoming] arrives for a request [held] already: the greater version, and
+ * at an equal version EXPIRED over OPEN, since expiry moves no version.
+ */
+fun newerOf(held: PaymentRequest?, incoming: PaymentRequest): PaymentRequest = when {
+ held == null -> incoming
+ incoming.version > held.version -> incoming
+ incoming.version < held.version -> held
+ held.status == PaymentRequestStatus.Expired && incoming.status == PaymentRequestStatus.Open -> held
+ // A held copy that failed to decrypt yields to one that opened, at the same version.
+ else -> incoming
+}
+
+/**
+ * What a creator sends to open a split in a private group. [text] is already sealed under the
+ * group's chat key; [shares] lists who is asked, in roster order, the creator first when included.
+ */
+data class NewSplitRequest(
+ val chatId: ChatId,
+ val text: PaymentRequestText.Encrypted,
+ val currency: CurrencyCode,
+ val endsAt: Instant,
+ val shares: List,
+ val perPerson: Fiat,
+)
+
+/**
+ * The intent id a viewer's share payment must use. Anyone who learns it before the share is paid
+ * can use it up, so it never prints: keep it in memory for the one payment and never log it.
+ */
+class PaymentIntentId(val bytes: ByteArray) {
+ override fun toString(): String = "PaymentIntentId(redacted)"
+}
+
+/**
+ * What a request's status line says: the time left to pay, rounded up, or how it ended.
+ *
+ * The bands are uneven on purpose: [HoursLeft] runs from 1 hour to 24 hours inclusive, so
+ * [DaysLeft] is never 1 and a 1-week request reads 7 days.
+ */
+sealed interface StatusLine {
+ data class DaysLeft(val days: Int) : StatusLine
+ data class HoursLeft(val hours: Int) : StatusLine
+ data class MinutesLeft(val minutes: Int) : StatusLine
+ data object PaidInFull : StatusLine
+ data object Closed : StatusLine
+ data object Expired : StatusLine
+}
+
+fun PaymentRequest.statusLine(now: Instant): StatusLine = when (resolvedStatus(now)) {
+ PaymentRequestStatus.Completed -> StatusLine.PaidInFull
+ PaymentRequestStatus.Closed -> StatusLine.Closed
+ PaymentRequestStatus.Expired -> StatusLine.Expired
+ PaymentRequestStatus.Open, PaymentRequestStatus.Unknown -> {
+ val left = (split?.endsAt ?: now) - now
+ val minute = 60_000L
+ val hour = 60 * minute
+ val ms = left.inWholeMilliseconds
+ fun ceilDiv(a: Long, b: Long) = ((a + b - 1) / b).toInt()
+ when {
+ ms > 24 * hour -> StatusLine.DaysLeft(ceilDiv(ms, 24 * hour))
+ ms >= hour -> StatusLine.HoursLeft(ceilDiv(ms, hour))
+ else -> StatusLine.MinutesLeft(maxOf(1, ceilDiv(ms, minute)))
+ }
+ }
+}
+
+/**
+ * The viewer's share to pay on this request now: unpaid, on an open request, or null. The creator
+ * owes nothing, even on a share of their own that reads unpaid.
+ */
+fun PaymentRequest.unpaidShareOf(viewerId: ID?, now: Instant): Share? =
+ if (viewerId == null || viewerId == creator || !isOpen(now)) null
+ else split?.shareOf(viewerId)?.takeIf { !it.isPaid }
+
+/** Where the viewer stands on a request, shown on every deck card. */
+sealed interface ViewerStanding {
+ data class Paid(val share: Fiat) : ViewerStanding
+ data class Owes(val share: Fiat) : ViewerStanding
+ data object Creator : ViewerStanding
+ data object NoShare : ViewerStanding
+}
+
+/**
+ * [viewerId]'s standing: the creator first, then a paid share (however it was paid), then a share
+ * still owed on an open request. An unpaid share on an ended request owes nothing.
+ */
+fun PaymentRequest.viewerStanding(viewerId: ID?, now: Instant): ViewerStanding {
+ if (viewerId == null) return ViewerStanding.NoShare
+ if (viewerId == creator) return ViewerStanding.Creator
+ val share = split?.shareOf(viewerId) ?: return ViewerStanding.NoShare
+ return when {
+ share.isPaid -> ViewerStanding.Paid(share.amount)
+ isOpen(now) -> ViewerStanding.Owes(share.amount)
+ else -> ViewerStanding.NoShare
+ }
+}
+
+/**
+ * The app-bar deck's default order: open requests [viewerId] owes on, then other open requests,
+ * each by deadline, nearest first, then requests that ended within the last 24 hours, most
+ * recently ended first. Anything else is left out.
+ */
+fun deckOrder(requests: Collection, now: Instant, viewerId: ID? = null): List {
+ val inDeck = requests.filter { it.isInDeck(now) }
+ val (open, ended) = inDeck.partition { it.isOpen(now) }
+ val (owed, other) = open.partition { it.unpaidShareOf(viewerId, now) != null }
+ return owed.sortedBy { it.split?.endsAt } +
+ other.sortedBy { it.split?.endsAt } +
+ ended.sortedByDescending { it.resolvedClosedAt(now) }
+}
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestModels.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestModels.kt
index 7491bff578..a647ad0bfc 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestModels.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestModels.kt
@@ -1,7 +1,5 @@
package com.flipcash.services.models.paymentrequest
-import com.codeinc.flipcash.gen.common.v1.Common
-import com.codeinc.flipcash.gen.paymentrequest.v1.Model
import com.flipcash.services.models.PagingToken
/** Which half of a chat's payment requests to list. Mirrors `GetChatPaymentRequestsRequest.Section`. */
@@ -15,17 +13,16 @@ enum class PaymentRequestSection {
/**
* A payment request, with the intent id the caller's share payment must use. [paymentIntentId] is
- * set only for a caller who holds an unpaid share. The request is the generated
- * `paymentrequest.v1` type until a UI consumes it.
+ * set only for a caller who holds an unpaid share, and is never stored with the request.
*/
data class PaymentRequestDetails(
- val paymentRequest: Model.PaymentRequestMetadata,
- val paymentIntentId: Common.IntentId?,
+ val paymentRequest: PaymentRequest,
+ val paymentIntentId: PaymentIntentId?,
)
/** One page of a chat's payment requests. Page with [pagingToken] while [hasMore]. */
data class PaymentRequestPage(
- val paymentRequests: List,
+ val paymentRequests: List,
val pagingToken: PagingToken?,
val hasMore: Boolean,
)
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/PaymentRequestRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/PaymentRequestRepository.kt
index da08dbfd5a..9de5589934 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/PaymentRequestRepository.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/PaymentRequestRepository.kt
@@ -1,8 +1,10 @@
package com.flipcash.services.repository
-import com.codeinc.flipcash.gen.paymentrequest.v1.Model
+import com.flipcash.services.models.chat.IdempotencyKey
import com.flipcash.services.models.QueryOptions
import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.paymentrequest.NewSplitRequest
+import com.flipcash.services.models.paymentrequest.PaymentRequest
import com.flipcash.services.models.paymentrequest.PaymentRequestDetails
import com.flipcash.services.models.paymentrequest.PaymentRequestPage
import com.flipcash.services.models.paymentrequest.PaymentRequestSection
@@ -10,20 +12,19 @@ import com.getcode.ed25519.Ed25519.KeyPair
import com.getcode.opencode.model.core.ID
/**
- * Payment requests in group chats. Request values are the generated `paymentrequest.v1` types
- * for now; a domain model is deferred until a UI consumes them. The server sends no events for
+ * Payment requests in group chats. The server sends no events for
* payment requests, so callers refetch.
*/
interface PaymentRequestRepository {
suspend fun createPaymentRequest(
owner: KeyPair,
- groupChat: Model.GroupChatPaymentRequest,
- idempotencyKey: Model.IdempotencyKey,
- ): Result
+ request: NewSplitRequest,
+ idempotencyKey: IdempotencyKey,
+ ): Result
suspend fun getPaymentRequest(
owner: KeyPair,
- paymentRequestId: Model.PaymentRequestId,
+ paymentRequestId: ID,
): Result
suspend fun getChatPaymentRequests(
@@ -36,13 +37,13 @@ interface PaymentRequestRepository {
/** Closes the request. Creator only. */
suspend fun closePaymentRequest(
owner: KeyPair,
- paymentRequestId: Model.PaymentRequestId,
- ): Result
+ paymentRequestId: ID,
+ ): Result
/** Marks [userId]'s share paid outside the app. Creator only. */
suspend fun markPaid(
owner: KeyPair,
- paymentRequestId: Model.PaymentRequestId,
+ paymentRequestId: ID,
userId: ID,
- ): Result
+ ): Result
}
diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupContentCryptoTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupContentCryptoTest.kt
index 6b69ee92a8..1c2e049fcc 100644
--- a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupContentCryptoTest.kt
+++ b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupContentCryptoTest.kt
@@ -2,6 +2,8 @@ package com.flipcash.services.chat
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.MessageContent
+import com.flipcash.services.models.chat.WidgetContent
+import com.flipcash.services.models.paymentrequest.PaymentRequestText
import com.getcode.opencode.model.core.ID
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -34,6 +36,22 @@ class GroupContentCryptoTest {
assertEquals(OpenedContent.Plaintext(MessageContent.Text("hi")), crypto.open(chatId, sender, sealed))
}
+ @Test
+ fun `a payment request widget seals and opens to itself`() = runTest {
+ val widget = MessageContent.Widget(WidgetContent.PaymentRequest(List(32) { 9 }))
+
+ val sealed = crypto.seal(chatId, sender, widget).getOrThrow()
+
+ assertEquals(OpenedContent.Plaintext(widget), crypto.open(chatId, sender, sealed))
+ }
+
+ @Test
+ fun `a share profile widget can't be sealed`() = runTest {
+ val widget = MessageContent.Widget(WidgetContent.ShareProfile("alice"))
+
+ assertTrue(crypto.seal(chatId, sender, widget).isFailure)
+ }
+
@Test
fun `a message claimed by another sender or chat doesn't open`() = runTest {
val sealed = sealText("hi")
@@ -89,4 +107,34 @@ class GroupContentCryptoTest {
assertEquals(OpenedBlob.Failed(BlobOpenFailure.Length), crypto.openBlob(chatId, sender, blobId, 4, sealed))
}
+
+ @Test
+ fun `request text sealed by its creator opens to itself`() = runTest {
+ val sealed = crypto.sealPaymentRequestText(chatId, sender, "Dinner", "Tacos").getOrThrow()
+ assertEquals(
+ PaymentRequestText.Plain("Dinner", "Tacos"),
+ crypto.openPaymentRequestText(chatId, sender, sealed).getOrThrow(),
+ )
+ }
+
+ @Test
+ fun `request text opened under another creator or chat is undecryptable`() = runTest {
+ val sealed = crypto.sealPaymentRequestText(chatId, sender, "Dinner", "").getOrThrow()
+ assertEquals(PaymentRequestText.Undecryptable, crypto.openPaymentRequestText(chatId, someoneElse, sealed).getOrThrow())
+ assertEquals(PaymentRequestText.Undecryptable, crypto.openPaymentRequestText(otherChat, sender, sealed).getOrThrow())
+ }
+
+ @Test
+ fun `request text with an unknown scheme is undecryptable`() = runTest {
+ val sealed = crypto.sealPaymentRequestText(chatId, sender, "Dinner", "").getOrThrow()
+ val unknown = PaymentRequestText.Encrypted(scheme = 7, nonce = sealed.nonce, ciphertext = sealed.ciphertext)
+ assertEquals(PaymentRequestText.Undecryptable, crypto.openPaymentRequestText(chatId, sender, unknown).getOrThrow())
+ }
+
+ @Test
+ fun `request text waits when the key can't be fetched`() = runTest {
+ val sealed = crypto.sealPaymentRequestText(chatId, sender, "Dinner", "").getOrThrow()
+ key = Result.failure(IOException("offline"))
+ assertTrue(crypto.openPaymentRequestText(chatId, sender, sealed).isFailure)
+ }
}
diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/WidgetAndNeverMappingTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/WidgetAndNeverMappingTest.kt
index beb80c8df5..26e98d0492 100644
--- a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/WidgetAndNeverMappingTest.kt
+++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/WidgetAndNeverMappingTest.kt
@@ -52,12 +52,22 @@ class WidgetAndNeverMappingTest {
}
@Test
- fun `a widget can't be built for sending`() {
+ fun `a share profile widget can't be built for sending`() {
assertFailsWith {
MessageContent.Widget(WidgetContent.ShareProfile("alice")).asContent()
}
}
+ @Test
+ fun `a payment request widget round-trips through the wire form`() {
+ val widget = MessageContent.Widget(WidgetContent.PaymentRequest(List(32) { it.toByte() }))
+
+ val wire = widget.asContent()
+
+ assertEquals(MessagingModel.WidgetContent.TypeCase.PAYMENT_REQUEST, wire.widget.typeCase)
+ assertEquals(widget, wire.toMessageContent())
+ }
+
@Test
fun `speaker never maps to Never and back`() {
val proto = ChatRuleRequirement.Never.asProtoSpeakerRules()
diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestTest.kt
new file mode 100644
index 0000000000..eaa8a9c4a0
--- /dev/null
+++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestTest.kt
@@ -0,0 +1,225 @@
+package com.flipcash.services.models.paymentrequest
+
+import com.flipcash.services.models.chat.ChatId
+import com.getcode.opencode.model.core.ID
+import com.getcode.opencode.model.financial.CurrencyCode
+import com.getcode.opencode.model.financial.Fiat
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import java.util.Locale
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.days
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.Instant
+
+/** The spec's shared test cases 1–13: amounts, the status line, the version merge and deck order. */
+class PaymentRequestTest {
+
+ private val now = Instant.fromEpochSeconds(1_800_000_000)
+ private val chatId = ChatId(ByteArray(32) { 3 })
+ private val creator: ID = List(16) { 1 }
+ private lateinit var defaultLocale: Locale
+
+ @Before
+ fun pinLocale() {
+ defaultLocale = Locale.getDefault()
+ Locale.setDefault(Locale.US)
+ }
+
+ @After
+ fun restoreLocale() = Locale.setDefault(defaultLocale)
+
+ private fun member(n: Int): ID = List(16) { n.toByte() }
+
+ private fun request(
+ shares: List = listOf(share(1, paid = true), share(2)),
+ status: PaymentRequestStatus = PaymentRequestStatus.Open,
+ endsIn: Duration = 7.days,
+ closedAt: Instant? = null,
+ version: Long = 1,
+ id: Int = 9,
+ ) = PaymentRequest(
+ id = List(16) { id.toByte() },
+ creator = creator,
+ createdAt = now - 1.hours,
+ status = status,
+ closedAt = closedAt,
+ version = version,
+ kind = PaymentRequest.Kind.GroupSplit(
+ chatId = chatId,
+ split = Split(
+ text = PaymentRequestText.Plain("Dinner", ""),
+ currency = CurrencyCode.USD,
+ endsAt = now + endsIn,
+ shares = shares,
+ ),
+ ),
+ )
+
+ private fun share(n: Int, amount: Int = 20, paid: Boolean = false) = Share(
+ userId = member(n),
+ amount = Fiat(amount, CurrencyCode.USD),
+ paid = if (paid) PaidShare(now, PaidShare.How.Creator) else null,
+ )
+
+ private fun Fiat.text() = formatted(Fiat.FormattingRule.Truncated)
+
+ @Test
+ fun `1 - 26 shares of 20 with only the creator's paid`() {
+ val split = request(shares = (1..26).map { share(it, paid = it == 1) }).split!!
+ assertEquals("$20", split.paid.text())
+ assertEquals("$520", split.requested.text())
+ assertEquals(1f / 26, split.paidFraction, 1e-6f)
+ }
+
+ @Test
+ fun `2 - 4 shares of 10, three paid, closed`() {
+ val r = request(
+ shares = (1..4).map { share(it, amount = 10, paid = it <= 3) },
+ status = PaymentRequestStatus.Closed,
+ closedAt = now - 1.hours,
+ )
+ assertEquals("$30", r.split!!.paid.text())
+ assertEquals("$40", r.split!!.requested.text())
+ assertEquals(StatusLine.Closed, r.statusLine(now))
+ assertFalse(r.isOpen(now))
+ assertFalse(r.split!!.shareOf(member(4))!!.isPaid)
+ }
+
+ @Test
+ fun `3 - all shares paid`() {
+ val r = request(
+ shares = (1..3).map { share(it, paid = true) },
+ status = PaymentRequestStatus.Completed,
+ closedAt = now - 1.minutes,
+ )
+ assertEquals(StatusLine.PaidInFull, r.statusLine(now))
+ assertEquals(1f, r.split!!.paidFraction)
+ assertTrue(r.isInDeck(now))
+ assertFalse(r.isOpen(now))
+ }
+
+ @Test
+ fun `4 - 7 days left`() = assertEquals(StatusLine.DaysLeft(7), request(endsIn = 7.days).statusLine(now))
+
+ @Test
+ fun `5 - 6 days 1 hour left reads 7 days`() =
+ assertEquals(StatusLine.DaysLeft(7), request(endsIn = 6.days + 1.hours).statusLine(now))
+
+ @Test
+ fun `6 - 23 hours 10 minutes left reads 24 hours`() =
+ assertEquals(StatusLine.HoursLeft(24), request(endsIn = 23.hours + 10.minutes).statusLine(now))
+
+ @Test
+ fun `22 - exactly 24 hours left reads 24 hours, not 1 day`() =
+ assertEquals(StatusLine.HoursLeft(24), request(endsIn = 24.hours).statusLine(now))
+
+ @Test
+ fun `7 - 1 hour left`() = assertEquals(StatusLine.HoursLeft(1), request(endsIn = 1.hours).statusLine(now))
+
+ @Test
+ fun `8 - 30 seconds left reads 1 minute`() =
+ assertEquals(StatusLine.MinutesLeft(1), request(endsIn = 30.seconds).statusLine(now))
+
+ @Test
+ fun `9 - open past its deadline reads expired, closed at the deadline`() {
+ val r = request(endsIn = (-1).seconds)
+ assertEquals(PaymentRequestStatus.Expired, r.resolvedStatus(now))
+ assertEquals(r.split!!.endsAt, r.resolvedClosedAt(now))
+ assertEquals(StatusLine.Expired, r.statusLine(now))
+ assertFalse(r.isOpen(now))
+ }
+
+ @Test
+ fun `10 - an older version does not replace a newer one`() {
+ val held = request(version = 5)
+ assertEquals(held, newerOf(held, request(version = 4)))
+ }
+
+ @Test
+ fun `11 - expired at the same version replaces open`() {
+ val expired = request(version = 5, status = PaymentRequestStatus.Expired, closedAt = now)
+ assertEquals(expired, newerOf(request(version = 5), expired))
+ }
+
+ @Test
+ fun `12 - open at the same version does not replace expired`() {
+ val expired = request(version = 5, status = PaymentRequestStatus.Expired, closedAt = now)
+ assertEquals(expired, newerOf(expired, request(version = 5)))
+ }
+
+ @Test
+ fun `13 - deck order`() {
+ val in5d = request(id = 1, endsIn = 5.days)
+ val in2d = request(id = 2, endsIn = 2.days)
+ val closed3hAgo = request(id = 3, status = PaymentRequestStatus.Closed, closedAt = now - 3.hours)
+ val expired30hAgo = request(id = 4, status = PaymentRequestStatus.Expired, closedAt = now - 30.hours)
+ assertEquals(
+ listOf(in2d, in5d, closed3hAgo),
+ deckOrder(listOf(in5d, closed3hAgo, expired30hAgo, in2d), now),
+ )
+ }
+
+ @Test
+ fun `23 - a request the viewer owes on sorts before an earlier one they paid`() {
+ val viewer = 2
+ val owed5d = request(id = 1, endsIn = 5.days, shares = listOf(share(1, paid = true), share(viewer)))
+ val paid2d = request(id = 2, endsIn = 2.days, shares = listOf(share(1), share(viewer, paid = true)))
+ assertEquals(listOf(owed5d, paid2d), deckOrder(listOf(paid2d, owed5d), now, member(viewer)))
+ assertEquals(Fiat(20, CurrencyCode.USD), owed5d.unpaidShareOf(member(viewer), now)?.amount)
+ }
+
+ @Test
+ fun `24 - the creator, a payer, or an ended request owes nothing`() {
+ val r = request(shares = listOf(share(1), share(2, paid = true)))
+ assertNull(r.unpaidShareOf(creator, now))
+ assertNull(r.unpaidShareOf(member(2), now))
+ assertNull(r.unpaidShareOf(member(5), now))
+ assertNull(r.unpaidShareOf(null, now))
+ assertNull(request(status = PaymentRequestStatus.Expired).unpaidShareOf(member(2), now))
+ assertNull(request(endsIn = (-1).minutes).unpaidShareOf(member(2), now))
+ }
+
+ @Test
+ fun `a request with no split has no share for anyone`() {
+ val r = request().copy(kind = PaymentRequest.Kind.Unsupported)
+ assertNull(r.split)
+ assertFalse(r.isInDeck(now))
+ }
+
+ @Test
+ fun `29 - the viewer paid their share`() =
+ assertEquals(
+ ViewerStanding.Paid(Fiat(5, CurrencyCode.USD)),
+ request(listOf(share(2, 5, paid = true), share(3, 40))).viewerStanding(member(2), now),
+ )
+
+ @Test
+ fun `30 - the viewer owes their share`() =
+ assertEquals(
+ ViewerStanding.Owes(Fiat(5, CurrencyCode.USD)),
+ request(listOf(share(2, 5), share(3, 40))).viewerStanding(member(2), now),
+ )
+
+ @Test
+ fun `31 - the viewer created the request, even with a share of their own`() =
+ assertEquals(ViewerStanding.Creator, request(listOf(share(1, paid = true), share(2))).viewerStanding(creator, now))
+
+ @Test
+ fun `32 - the viewer has no share`() =
+ assertEquals(ViewerStanding.NoShare, request().viewerStanding(member(7), now))
+
+ @Test
+ fun `an unpaid share on an ended request owes nothing`() =
+ assertEquals(
+ ViewerStanding.NoShare,
+ request(listOf(share(2)), status = PaymentRequestStatus.Expired, endsIn = (-1).hours).viewerStanding(member(2), now),
+ )
+}
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt
index ce500e641e..b78023e0cd 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt
@@ -130,6 +130,9 @@ class TransactionController @Inject constructor(
/**
* Sends funds directly to another user's vault by resolving their
* [destinationOwner] into a timelock-derived vault address.
+ *
+ * [intentId] is the id the server issued for this payment, if any. Submitting twice under the
+ * same id lands once, which makes a retry safe. Never log it.
*/
suspend fun directTransfer(
amount: VerifiedFiat,
@@ -138,6 +141,7 @@ class TransactionController @Inject constructor(
destinationOwner: PublicKey,
appMetadata: ByteArray? = null,
scope: CoroutineScope = this.scope,
+ intentId: PublicKey? = null,
): Result {
val verifiedState = amount.verifiedState
?: return Result.failure(IllegalStateException("No verified state"))
@@ -153,6 +157,7 @@ class TransactionController @Inject constructor(
destinationOwner = destinationOwner,
verifiedState = verifiedState,
appMetadata = appMetadata,
+ intentId = intentId,
)
return submitIntent(scope, intent, source.authority.keyPair)
diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentTransfer.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentTransfer.kt
index 933155b23b..dc939f62d3 100644
--- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentTransfer.kt
+++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentTransfer.kt
@@ -68,7 +68,10 @@ internal class IntentTransfer(
)
}
- /** Creates a direct transfer to a known recipient's vault. */
+ /**
+ * Creates a direct transfer to a known recipient's vault, under [intentId] when the server
+ * issued one (a payment request's share) and a fresh id otherwise.
+ */
fun create(
amount: LocalFiat,
mint: Mint,
@@ -77,6 +80,7 @@ internal class IntentTransfer(
destinationOwner: PublicKey,
verifiedState: VerifiedState,
appMetadata: ByteArray? = null,
+ intentId: PublicKey? = null,
): IntentTransfer {
val transfer = ActionPublicTransfer.newInstance(
owner = sourceCluster.authority.keyPair,
@@ -87,7 +91,7 @@ internal class IntentTransfer(
)
return IntentTransfer(
- id = PublicKey.generate(),
+ id = intentId ?: PublicKey.generate(),
metadata = TransactionMetadata.SendPublicPayment(
source = sourceCluster.vaultPublicKey,
destination = destination,