Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -412,11 +412,17 @@ sealed interface AppRoute : NavKey, Parcelable {
// inside the app. The conversation offers a chip back to it; a link from outside the
// app has nowhere to go back to and sets none.
val returnToTitle: String? = null,
// Open with this request's details (its id in base58) over the transcript: where a
// payment of a share leads back to its request. Needs a [ChatIdentifier.ByChatId], since
// the details are keyed by chat.
val openRequestId: String? = null,
) : Messaging, FlowRoute {
override val initialStack: List<NavKey>
get() = when {
openOnJoinRequests -> listOf(ChatStep.JoinRequests)
openOnProfile -> listOf(ChatStep.GroupProfile)
openRequestId != null && identifier is ChatIdentifier.ByChatId ->
listOf(ChatStep.Conversation, ChatStep.RequestDetails(identifier.chatId, openRequestId))
else -> listOf(ChatStep.Conversation)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.flipcash.app.core.feed

import com.flipcash.services.models.BoostedReaction
import com.flipcash.services.models.RequestedPayment
import com.getcode.opencode.model.core.ID
import com.getcode.opencode.model.financial.Fiat
import com.getcode.opencode.model.financial.LocalFiat
Expand Down Expand Up @@ -112,6 +113,8 @@ sealed interface MessageMetadata {
val userId: ID? = null,
// Defaulted so a row persisted before this field existed still decodes (as null).
val boostedReaction: BoostedReaction? = null,
// Defaulted like [boostedReaction]. A oneof with it on the wire, so at most one is set.
val requestedPayment: RequestedPayment? = null,
) : MessageMetadata

@Serializable
Expand All @@ -126,6 +129,8 @@ sealed interface MessageMetadata {
val userId: ID? = null,
// Defaulted so a row persisted before this field existed still decodes (as null).
val boostedReaction: BoostedReaction? = null,
// Defaulted like [boostedReaction]. A oneof with it on the wire, so at most one is set.
val requestedPayment: RequestedPayment? = null,
) : MessageMetadata

/**
Expand Down
1 change: 1 addition & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1149,6 +1149,7 @@
<string name="error_description_requestCurrency">Requests aren\'t available in this currency yet.</string>
<!-- Request details, group requests spec frames 09–11, 24, 28, 29 and 33–35. -->
<string name="title_requestDetails">Request</string>
<string name="description_requestUnavailable">This request is no longer available.</string>
<string name="label_requestedBy">Requested by %1$s</string>
<string name="label_requestedByYou">Requested by you</string>
<string name="label_sharePaid">✓ Paid</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ import com.flipcash.shared.chat.canReact
import com.flipcash.shared.chat.chatDraftOf
import com.flipcash.shared.chat.groupAccess
import com.flipcash.shared.chat.models.ChatListItem
import com.flipcash.shared.chat.models.paysRequestShare
import com.flipcash.shared.chat.models.ChatQuote
import com.flipcash.shared.chat.models.ChatQuoteSnippet
import com.flipcash.shared.chat.models.PhotoMessageContext
Expand Down Expand Up @@ -1541,6 +1542,10 @@ internal class ChatViewModel @AssistedInject constructor(
)
}

// A request share's payment is shown by the request card, not by a bubble. The
// message is still in the page: its key, and so the next page's, are unchanged.
if (content.paysRequestShare) return@flatMapIndexed emptyList()

val enriched = if (content is MessageContent.Cash && content.tokenName.isBlank()) {
val token = tokenCoordinator.getTokenMetadata(content.mint).getOrNull()?.token
if (token != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,7 @@ internal fun rememberPaymentRequestCards(
requests = held,
missing = missing,
now = now,
openedAt = viewModel.openedAt,
onNeeded = viewModel::fetchIfAbsent,
onRefresh = viewModel::refresh,
onOpen = { open(chatId, it) },
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,21 @@ 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.ChatCoordinator
import com.flipcash.shared.chat.PaymentRequests
import com.getcode.opencode.model.core.ID
import dagger.hilt.android.lifecycle.HiltViewModel
import com.flipcash.shared.chat.ui.request.RequestMotion
import kotlinx.coroutines.Job
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.filterNotNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
Expand All @@ -31,6 +36,7 @@ import kotlin.time.Instant
@HiltViewModel
internal class ChatRequestsViewModel @Inject constructor(
private val requests: PaymentRequests,
private val chatCoordinator: ChatCoordinator,
private val userManager: UserManager,
) : ViewModel() {

Expand Down Expand Up @@ -60,17 +66,20 @@ internal class ChatRequestsViewModel @Inject constructor(
/** The deck, front card first. Empty hides it. */
val deck: StateFlow<List<PaymentRequest>> = _deck.asStateFlow()

/** Requests refetched because a message about them arrived, so a re-shown row doesn't refetch. */
private val refreshed = mutableSetOf<ID>()

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 }
) { inChat, now, cycled -> deckOrder(inChat, now, viewerId).rotated(cycled) to now }
.collect { (next, now) -> publish(next, now) }
}
viewModelScope.launch {
chatId.filterNotNull().flatMapLatest { id ->
val arrivals = PaymentArrivals(openedAt)
chatCoordinator.observeMessages(id).map(arrivals::newlyPaid)
}.collect { paid -> paid.forEach(::refetch) }
}
viewModelScope.launch {
while (true) {
Expand All @@ -80,6 +89,35 @@ internal class ChatRequestsViewModel @Inject constructor(
}
}

private var shownNow = _now.value
private var settle: Job? = null
private var settledDeck: List<PaymentRequest> = emptyList()

/**
* Puts [next] on the deck. A request ending moves it in the sort order; the deck keeps its
* order for the ended look's fade first, and only then lets the list slide it into place.
* Changes arriving meanwhile update the cards in place and join the pending settle.
*/
private fun publish(next: List<PaymentRequest>, now: Instant) {
val shown = _deck.value
val holding = settle?.isActive == true
val held = if (holding) inShownOrder(shown, next) else holdForEndedFade(shown, shownNow, next, now)
shownNow = now
settledDeck = next
if (held == null) {
settle?.cancel()
_deck.value = next
return
}
_deck.value = held
if (!holding) {
settle = viewModelScope.launch {
delay(RequestMotion.CHANGE_MILLIS.toLong())
_deck.value = settledDeck
}
}
}

/**
* 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.
Expand Down Expand Up @@ -108,9 +146,9 @@ internal class ChatRequestsViewModel @Inject constructor(
viewModelScope.launch { requests.fetchIfAbsent(id) }
}

fun refresh(id: ID) {
if (!refreshed.add(id)) return
viewModelScope.launch { requests.fetch(id) }
/** A failed refetch leaves the held copy as it was; the 45 s poll reconciles it. */
private fun refetch(id: ID) {
viewModelScope.launch { runCatching { requests.refetch(id) } }
}

/** Swipe down on the deck: the front card goes to the back. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.flipcash.app.messenger.internal.request

import com.flipcash.services.models.paymentrequest.PaymentRequest
import kotlin.time.Instant

/**
* [next]'s contents in [shown]'s order, when ending a request is all that moves the deck: the same
* requests, a request that was open at [shownNow] and is ended at [now], and a different order.
* The deck keeps [shown]'s order while the ended look fades, then settles to [next]. Null when
* nothing needs holding.
*/
internal fun holdForEndedFade(
shown: List<PaymentRequest>,
shownNow: Instant,
next: List<PaymentRequest>,
now: Instant,
): List<PaymentRequest>? {
if (shown.map { it.id } == next.map { it.id }) return null
val slotted = inShownOrder(shown, next) ?: return null
val ended = shown.indices.any { shown[it].isOpen(shownNow) && !slotted[it].isOpen(now) }
return slotted.takeIf { ended }
}

/** [next]'s contents in [shown]'s order, or null unless both hold the same requests. */
internal fun inShownOrder(shown: List<PaymentRequest>, next: List<PaymentRequest>): List<PaymentRequest>? {
if (shown.size != next.size) return null
val byId = next.associateBy { it.id }
if (byId.size != next.size || shown.any { it.id !in byId }) return null
return shown.map { byId.getValue(it.id) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,9 @@ internal class DeckHostState(owed: Set<ID>) {
}
}

private val SETTLE_SPRING = spring<Float>(stiffness = Spring.StiffnessMediumLow)
/** The deck's settle motion; a reorder after a request ends slides on it too. */
internal const val SETTLE_STIFFNESS = Spring.StiffnessMediumLow
private val SETTLE_SPRING = spring<Float>(stiffness = SETTLE_STIFFNESS)

/** A [DeckHostState] for one visit to a chat; reopening the chat starts it over. */
@Composable
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.flipcash.app.messenger.internal.request

import com.flipcash.services.models.chat.ChatMessage
import com.flipcash.services.models.chat.MessageContent
import com.getcode.opencode.model.core.ID
import kotlin.time.Instant

/**
* Picks out the requests an open chat's cash messages pay. A payment landing changes its request
* and nothing else says so, so each such message sent after [openedAt] earns one refetch. Older
* messages are history loading in, and a message seen again (the list re-emits on every change)
* is not counted twice. Two messages for one request are two refetches; the in-flight dedupe in
* `PaymentRequests.refetch` folds those that overlap.
*/
internal class PaymentArrivals(private val openedAt: Instant) {
private val seen = mutableSetOf<Long>()

/** The request ids to refetch for [messages], one per message not seen before. */
fun newlyPaid(messages: List<ChatMessage>): List<ID> =
messages.mapNotNull { message ->
val id = message.content.firstNotNullOfOrNull { (it as? MessageContent.Cash)?.paymentRequestId }
id?.takeIf { message.timestamp > openedAt && seen.add(message.messageId) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ package com.flipcash.app.messenger.internal.request

import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import com.flipcash.shared.chat.ui.request.RequestMotion
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
Expand Down Expand Up @@ -546,11 +550,12 @@ private fun StripFront(
val share = request.unpaidShareOf(viewerId, now)
val standing = request.viewerStanding(viewerId, now)
val ended = !request.isOpen(now)
val alpha by animateFloatAsState(if (ended) ENDED_ALPHA else 1f, RequestMotion.change(), label = "ended fade")
Row(
modifier = modifier
.height(DeckStyle.Strip.height)
.background(CodeTheme.colors.background, shape)
.alpha(if (ended) ENDED_ALPHA else 1f)
.alpha(alpha)
.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),
Expand Down Expand Up @@ -649,10 +654,11 @@ private fun DeckFront(
val ended = !request.isOpen(now)
val split = request.split
val standing = request.viewerStanding(viewerId, now)
val alpha by animateFloatAsState(if (ended) ENDED_ALPHA else 1f, RequestMotion.change(), label = "ended fade")
Row(
modifier = modifier
.background(CodeTheme.colors.background, PILL_SHAPE)
.alpha(if (ended) ENDED_ALPHA else 1f)
.alpha(alpha)
.background(CodeTheme.colors.surfaceVariant, PILL_SHAPE)
.border(1.dp, CodeTheme.colors.border, PILL_SHAPE)
.padding(
Expand Down Expand Up @@ -752,7 +758,12 @@ internal fun RequestRing(
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 accent by animateColorAsState(
if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success,
RequestMotion.change(),
label = "ring accent",
)
val sweep by animateFloatAsState(fraction, RequestMotion.change(), label = "ring sweep")
val track = CodeTheme.colors.divider
val inset = size * (4f / 26f)
Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) {
Expand All @@ -761,17 +772,19 @@ internal fun RequestRing(
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))
if (sweep > 0f) {
drawArc(accent, -90f, 360f * sweep.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)
Crossfade(targetState = status == PaymentRequestStatus.Closed, animationSpec = RequestMotion.change(), label = "ring glyph") { closed ->
Text(
text = if (closed) "✓" else "$",
style = if (size > 30.dp) CodeTheme.typography.textMedium else CodeTheme.typography.caption,
color = Color.Black,
)
}
PaidBadge(glyph = size, ended = ended, paid = paid)
}
}

Expand Down Expand Up @@ -865,6 +878,11 @@ internal fun ExpandedRequests(
) {
itemsIndexed(deck, key = { _, request -> request.id.hashCode() }) { index, request ->
val title = request.deckTitle
val endedAlpha by animateFloatAsState(
if (request.isOpen(now)) 1f else ENDED_ALPHA,
RequestMotion.change(),
label = "ended fade",
)
val card = Modifier
.fillMaxWidth()
.then(
Expand All @@ -880,12 +898,15 @@ internal fun ExpandedRequests(
enter = slideInVertically { -it * (index + 1) } + fadeIn(),
exit = slideOutVertically { -it * (index + 1) } + fadeOut(),
)
// A reorder after a request ends slides into place on the deck's settle spring.
.animateItem(fadeInSpec = null, fadeOutSpec = null, placementSpec = SETTLE_SPRING_OFFSET)

}
)
// 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)
.alpha(endedAlpha)
.background(CodeTheme.colors.surfaceVariant, CARD_SHAPE)
.clickable { onOpen(request) }
.padding(CodeTheme.dimens.grid.x3)
Expand Down Expand Up @@ -979,6 +1000,7 @@ 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 SETTLE_SPRING_OFFSET = spring<androidx.compose.ui.unit.IntOffset>(stiffness = SETTLE_STIFFNESS)
private val DECK_SPRING = spring<Float>(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium)


Expand Down
Loading
Loading