From 8b5ee89678313889af0f75f8282705d640801b7b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 25 Oct 2026 21:34:55 -0400 Subject: [PATCH 01/47] feat(messenger): send cash to several members of a private group (#1846) * feat(payments): split a group send's total between its recipients Splits in the currency's minor unit, rounding each part down and giving the leftover one unit each to the first recipients in selection order. GroupSendSplitVectorsTest runs the shared test-vectors/group-send-split.json. * feat(payments): pay a group send's parts in one intent GroupSendPaymentDelegate prices each part from its own fiat amount at one verified state and pays them with GroupPayment metadata: one recipient as a single transfer, more as one multi-transfer in selection order. The pricing and paying move out of ReactionBoostPaymentDelegate into SharedStatePayment so both use the same path. * docs(test-vectors): list group-send-split.json * feat(messenger): pay checked members from a private group's amount sheet A private group's amount sheet now leads to Select People and a confirm step instead of posting a cash link. Checked members are paid in the order they were checked, an 11th check shows the 10 People Max card, and a total under one minor unit per person shows an alert and stays on Select People. Public groups still post a cash link. * feat(messenger): add Select People and the group send confirm sheet * feat(chat): draw group sends as one bubble with a recipients sheet A cash message with multi_receiver shows who sent the total, the per-person amount ("~" on the smallest part when the split is uneven), how many people it went to, and the viewer's own part when they were paid. Tapping it opens a sheet listing the sender and recipients by name. * fix(chat): credit and label only the viewer's own part of a group send The eager balance update credited every non-sender with the full amount of any cash message, so a group send would have credited each member the total. It now credits the viewer's multi_receiver part, or the amount when they are the single receiver, and nothing when the message names others. The cash bubble said "You received" to every non-sender. A one-person group send names its recipient, so other members now read " sent". * fix(messenger): let the group send confirm sheet change the token The token pill on the confirm sheet drew a chevron but ignored taps. It now opens the token picker, and the send re-checks the balance, since the token can change after the amount step checked it. * fix(chat): loosen the group send bubble and details sheet The details sheet stacked its rows 16dp apart with 24dp avatars, against the design's 20dp rows and 32dp avatars, and had no top gap above Sent. The group bubble now ends on a "to Alex Morgan and 2 Others" line, as on iOS, and a group payment to one other member uses the same layout. * fix(chat): show the group send token name at full opacity * fix(messenger): match the confirm sheet token pill to other cash sheets Use the White10 pill background and padding from the reaction boost sheet, and drop the Each line when only one person is paid. * fix(chat): size the group send token row like the single send bubble Use the single send bubble's token icon size and lowercase "each" in the per-person line. * fix(chat): drop the group send "You received" line, name the viewer first The per-person line already gives the viewer's share. When the viewer was paid, the footer reads "to You and N Others", matching iOS. * fix(chat): trim the group send bubble's vertical padding * fix(chat): show the viewer's avatar on the group send details sheet * feat(messenger): open a recipient's profile from the group send details Each To row now shows a chevron and opens that member's profile. The push waits for the sheet's hide animation, since the sheet pops whatever is on top once it settles. * fix(messenger): show a group payment to you alone as the group card * fix(messenger): list the payee of a one-recipient group payment in its details * feat(messenger): pick the send currency from a half sheet in the chat send flow Both the amount step's token pill and the group send confirm pill open ChatStep.SendCurrency, which reuses the paid-reaction currency list, so choosing a currency returns to the same step instead of leaving the flow. * fix(messenger): open the sender and token from group send details The From row and the Sent token row had no tap target, so an incoming payment gave no way to reach the sender's profile. Both rows now show a chevron: From opens the sender's profile, Sent opens the token info. * fix(messenger): slide Select People in on the amount sheet's own nav host The amount sheet swapped its content for Select People in place, so the screen appeared without the forward slide. The sheet now hosts its own FlowHost with Amount and People steps, driven by the view model's group send stage, so Next and back animate like any other push and pop. * fix(messenger): present group send like the share sheet Group send opens as GroupSendAmountEntry, a FullscreenSheet like ShareMessage, and its steps are NonDraggableRoute, so the amount and Select People screens close by their X or back arrow only. The confirm sheet gets an X, no drag handle, and turns off drag and tap-outside dismissal. * fix(messenger): drop the confirm sheet's X to match iOS The group send confirm closes by system back, which returns to Select People, as iOS's back gesture does. Drag and tap-outside dismissal stay off for the whole flow, and the amount step keeps its X. The X row gave the avatar its top spacing, so the content now pads its own top. From dc9590fde9c915775388c915f9f56733a12497a1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 22:40:03 -0400 Subject: [PATCH 02/47] feat(payment-requests): model requests in the domain and hold them in a version-merged store PaymentRequestController and its repository now return a PaymentRequest domain model instead of the generated paymentrequest.v1 types, and create takes a NewSplitRequest plus a 16-byte idempotency key. payment_intent_id travels only in PaymentRequestDetails, as a PaymentIntentId whose toString is redacted. PaymentRequests (shared/chat) opens a private group's request text once through GroupContentCrypto and keeps the greater version per request, EXPIRED over OPEN at an equal version. StatusLine and deckOrder carry the spec's time-left bands and deck order; unit tests cover the spec's cases 1-13. --- .../flipcash/shared/chat/PaymentRequests.kt | 113 ++++++++++ .../chat/internal/RealChatCoordinator.kt | 3 + .../shared/chat/PaymentRequestsMergeTest.kt | 60 ++++++ .../services/chat/GroupContentCrypto.kt | 60 ++++++ .../controllers/PaymentRequestController.kt | 13 +- .../network/extensions/LocalToProtobuf.kt | 33 +++ .../network/extensions/ProtobufToLocal.kt | 65 ++++++ .../network/services/PaymentRequestService.kt | 44 ++-- .../InternalPaymentRequestRepository.kt | 21 +- .../models/paymentrequest/PaymentRequest.kt | 202 ++++++++++++++++++ .../paymentrequest/PaymentRequestModels.kt | 11 +- .../repository/PaymentRequestRepository.kt | 22 +- .../services/chat/GroupContentCryptoTest.kt | 31 +++ .../paymentrequest/PaymentRequestTest.kt | 176 +++++++++++++++ 14 files changed, 801 insertions(+), 53 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequests.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PaymentRequestsMergeTest.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequest.kt create mode 100644 services/flipcash/src/test/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestTest.kt 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..2c2ed4c43c --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequests.kt @@ -0,0 +1,113 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.chat.GroupContentCrypto +import com.flipcash.services.controllers.PaymentRequestController +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.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() + + /** 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) + } + + /** + * 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)) } + + suspend fun create(request: NewSplitRequest, idempotencyKey: ID): 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() + } + + 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/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/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..773921ea30 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PaymentRequestsMergeTest.kt @@ -0,0 +1,60 @@ +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]) + } +} 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..dfa281d92c 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,9 @@ package com.flipcash.services.controllers -import com.codeinc.flipcash.gen.paymentrequest.v1.Model 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 +16,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: ID): 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 +34,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..05eade4390 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 @@ -26,6 +26,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 @@ -414,3 +415,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 ID.asPaymentRequestIdempotencyKey(): PaymentRequestModel.IdempotencyKey { + return PaymentRequestModel.IdempotencyKey.newBuilder().setValue(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..4bebb05a6b 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() @@ -843,3 +851,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..35163f399c 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,12 @@ package com.flipcash.services.internal.network.services -import com.codeinc.flipcash.gen.paymentrequest.v1.Model 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 +15,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 +32,15 @@ internal class PaymentRequestService @Inject constructor( ) { suspend fun createPaymentRequest( owner: Ed25519.KeyPair, - groupChat: Model.GroupChatPaymentRequest, - idempotencyKey: Model.IdempotencyKey, - ): Result { + request: NewSplitRequest, + idempotencyKey: ID, + ): 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 +61,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 +96,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 +114,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 +136,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..7707da7c45 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,13 @@ package com.flipcash.services.internal.repositories -import com.codeinc.flipcash.gen.paymentrequest.v1.Model 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 +21,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: ID, + ): Result { + return service.createPaymentRequest(owner, request, idempotencyKey) .onFailure { if (it !is CreatePaymentRequestError.Denied && it !is CreatePaymentRequestError.TextModerated) { ErrorUtils.handleError(it) @@ -33,7 +34,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 +52,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/paymentrequest/PaymentRequest.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequest.kt new file mode 100644 index 0000000000..033aaf4254 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequest.kt @@ -0,0 +1,202 @@ +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 app-bar deck's default order: open requests 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): List { + val inDeck = requests.filter { it.isInDeck(now) } + val (open, ended) = inDeck.partition { it.isOpen(now) } + return open.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..770add1cce 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,9 @@ package com.flipcash.services.repository -import com.codeinc.flipcash.gen.paymentrequest.v1.Model 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 +11,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: ID, + ): Result suspend fun getPaymentRequest( owner: KeyPair, - paymentRequestId: Model.PaymentRequestId, + paymentRequestId: ID, ): Result suspend fun getChatPaymentRequests( @@ -36,13 +36,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..445614451f 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,7 @@ package com.flipcash.services.chat import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.paymentrequest.PaymentRequestText import com.getcode.opencode.model.core.ID import kotlinx.coroutines.test.runTest import org.junit.Test @@ -89,4 +90,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/models/paymentrequest/PaymentRequestTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestTest.kt new file mode 100644 index 0000000000..75fe497ec0 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestTest.kt @@ -0,0 +1,176 @@ +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 `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 `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)) + } +} From 656ee1fbc59ff138e4efcc872803e3496e58ad12 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 22:43:35 -0400 Subject: [PATCH 03/47] feat(payment-requests): pay a request share under the server's intent id directTransfer and IntentTransfer.create take an optional intentId; without one they mint a fresh id as before. Submitting twice under the same id lands once, so a retry or a second tap cannot pay a share twice. PaymentRequestPaymentDelegate.prepare refetches the request and returns Ready only for an open request with an unpaid share and an issued payment_intent_id; pay sends that share to the creator in the request's currency with requested-payment app metadata, then refetches. The id stays in memory inside ShareToPay.Ready, whose toString leaves it out. --- .../shared/payments/SharedStatePayment.kt | 6 + .../request/PaymentRequestPaymentDelegate.kt | 100 +++++++++++ .../PaymentRequestPaymentDelegateTest.kt | 162 ++++++++++++++++++ .../controllers/TransactionController.kt | 5 + .../network/api/intents/IntentTransfer.kt | 8 +- 5 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/request/PaymentRequestPaymentDelegate.kt create mode 100644 apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/request/PaymentRequestPaymentDelegateTest.kt 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..31b3ccae17 --- /dev/null +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/request/PaymentRequestPaymentDelegate.kt @@ -0,0 +1,100 @@ +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 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, + ) + + /** 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) } + return payment.pay( + payees = listOf(request.creator), + priced = priced, + token = token, + source = source, + appMetadata = buildRequestedPaymentMetadata(request.chatId, request.id.toByteArray()), + intentId = PublicKey(ready.intentId.bytes.toList()), + ).onSuccess { paymentRequests.fetch(request.id) } + } +} + +/** 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..f324c28599 --- /dev/null +++ b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/request/PaymentRequestPaymentDelegateTest.kt @@ -0,0 +1,162 @@ +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 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.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) } + } +} 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, From c26f12991b9917d40868ef1d52540ecc746b5609 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 22:54:40 -0400 Subject: [PATCH 04/47] feat(chat): carry the payment request widget through decode, send, storage and preview A payment request widget now decodes to WidgetContent.PaymentRequest instead of unsupported, encodes for sending, and survives the message row. The chat list previews it as " requested " from the request store; one the device doesn't hold yet previews as nothing. The bubble still draws as unsupported until the request card lands. --- .../core/src/main/res/values/strings.xml | 3 ++ .../app/messenger/internal/ChatViewModel.kt | 2 + .../app/search/internal/MessageSearch.kt | 3 +- .../shared/chat/ui/ChatSummaryMapping.kt | 27 ++++++++-- .../flipcash/shared/chat/ui/MessageBubble.kt | 3 +- .../shared/chat/ui/UndecryptableBubble.kt | 2 +- .../shared/chat/ui/ChatSummaryPreviewTest.kt | 49 +++++++++++++++++++ .../converters/ChatTypeConverters.kt | 5 ++ .../sources/mapper/chat/ChatEntityMapper.kt | 13 +++-- .../mapper/chat/ChatEntityMapperTest.kt | 16 ++++++ .../network/extensions/LocalToProtobuf.kt | 16 +++++- .../network/extensions/ProtobufToLocal.kt | 7 ++- .../services/models/chat/WidgetContent.kt | 8 +++ .../extensions/WidgetAndNeverMappingTest.kt | 12 ++++- 14 files changed, 151 insertions(+), 15 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 6b1403246d..b9ae7d917e 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1014,6 +1014,9 @@ %1$s sent %2$s %1$s tipped %2$s Shared a profile + Request + You requested %1$s + %1$s requested %2$s Nothing yet Is Typing… 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 6a10eba653..8068a46a1e 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 @@ -1798,6 +1798,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 -> "" } ) 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/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..bef6075e89 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,13 @@ 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.shared.chat.models.displayText import com.flipcash.shared.chat.models.namedUsers import com.flipcash.shared.chat.ChatSummary @@ -37,6 +39,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 +49,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 +64,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, @@ -98,6 +104,7 @@ private fun ChatSummary.formatPreview( resources: ResourceHelper, senderProfiles: Map, exchange: Exchange?, + paymentRequests: Map, ): String? { val lastMsg = metadata.lastMessage ?: return null val isGroup = metadata.type == ChatType.GROUP @@ -112,7 +119,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 +137,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 +197,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 +220,19 @@ 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) + // The per-person share, as the request card's own amounts read. + is WidgetContent.PaymentRequest -> paymentRequests[widget.id]?.split?.shares?.firstOrNull()?.amount + ?.let { share -> (exchange?.let { share.forViewer(it.preferredRate, it.rates()) } ?: share).formatted() } + ?.let { amount -> + when { + sentBySelf -> resources.getString(R.string.label_chat_preview_requested, amount) + senderName != null -> + resources.getString(R.string.label_chat_preview_requested_bySender, senderName, amount) + 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..864168fa88 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 @@ -378,7 +378,8 @@ fun ContentBubble( attention = attention, ) - WidgetContent.Unsupported -> UndecryptableBubble( + // TODO(group-requests): the request card replaces this in the next commit. + is WidgetContent.PaymentRequest, WidgetContent.Unsupported -> UndecryptableBubble( modifier = modifier, hint = item.undecryptableHint, maxWidth = bubbleMaxWidth, 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/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..9addbd8507 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,50 @@ 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 requested the per-person share`() { + val share = Fiat(20.0, CurrencyCode.USD) + val amount = share.formatted() + every { + resources.getString(R.string.label_chat_preview_requested_bySender, "Alice", amount) + } returns "Alice requested $amount" + every { resources.getString(R.string.label_chat_preview_requested, amount) } returns "You requested $amount" + val widget = listOf(MessageContent.Widget(WidgetContent.PaymentRequest(requestId))) + val requests = mapOf(requestId to request(share)) + + fun groupPreview(senderId: ID) = groupSummary(widget, senderId, "Alice") + .toConversationReference(self, emptyMap(), resources, paymentRequests = requests) + .lastMessagePreview + + assertEquals("Alice requested $amount", groupPreview(other)) + assertEquals("You requested $amount", 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/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/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt index 05eade4390..712cedfd8d 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 @@ -195,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 -> { 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 4bebb05a6b..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 @@ -252,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 } ) 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/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() From 65565e041f4554f3fd147046e7dce5edccaa31c9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 22:57:17 -0400 Subject: [PATCH 05/47] feat(chat): seal and send the payment request widget as a pending chat message A failed send leaves the pending row that the transcript already retries, so a created request is posted again without creating it twice. --- .../com/flipcash/shared/chat/ChatCoordinator.kt | 7 +++++++ .../shared/chat/internal/OutgoingEncryption.kt | 5 +++-- .../internal/delegates/MessagingDelegate.kt | 9 +++++++++ .../flipcash/services/chat/ChatContentCrypto.kt | 7 ++++++- .../services/chat/GroupContentCryptoTest.kt | 17 +++++++++++++++++ 5 files changed, 42 insertions(+), 3 deletions(-) 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..11ca64f020 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,13 @@ interface MessagingOperations { linkPreview: OutgoingLinkPreview? = null, ): Result + /** + * Posts the widget for payment request [requestId] to [chatId], once the request exists. A + * failed send leaves a pending row the transcript retries like any message, so 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 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/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index cef5c5c30b..637eaaead9 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 @@ -37,6 +37,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 @@ -478,6 +479,14 @@ 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) + return send(chatId, payload, clientMessageId) + } + override suspend fun retryMessage(chatId: ChatId, pendingClientIdHex: String, content: List): Result { val clientMessageId = messageDataSource.retryPending(chatId, pendingClientIdHex) 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/test/kotlin/com/flipcash/services/chat/GroupContentCryptoTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupContentCryptoTest.kt index 445614451f..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,7 @@ 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 @@ -35,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") From aef905931f0605fb55f65b672ae16558a49706b3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 23:02:34 -0400 Subject: [PATCH 06/47] feat(messenger): model a new payment request's people, deadline and shares Shares go in roster order with the creator first, a group over 100 members starts with nobody selected, and a preset deadline resolves when Send is tapped. The request's idempotency key now uses the chat IdempotencyKey type. --- .../internal/request/NewRequestDraft.kt | 131 +++++++++++++++++ .../internal/request/NewRequestDraftTest.kt | 133 ++++++++++++++++++ .../flipcash/shared/chat/PaymentRequests.kt | 3 +- .../controllers/PaymentRequestController.kt | 3 +- .../network/extensions/LocalToProtobuf.kt | 4 +- .../network/services/PaymentRequestService.kt | 3 +- .../InternalPaymentRequestRepository.kt | 3 +- .../repository/PaymentRequestRepository.kt | 3 +- 8 files changed, 276 insertions(+), 7 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraft.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraftTest.kt 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/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/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 index 2c2ed4c43c..d10b92f69b 100644 --- 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 @@ -1,5 +1,6 @@ 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.QueryOptions @@ -59,7 +60,7 @@ class PaymentRequests @Inject constructor( suspend fun fetch(id: ID): Result = controller.getPaymentRequest(id).onSuccess { ingest(listOf(it.paymentRequest)) } - suspend fun create(request: NewSplitRequest, idempotencyKey: ID): Result = + suspend fun create(request: NewSplitRequest, idempotencyKey: IdempotencyKey): Result = controller.createPaymentRequest(request, idempotencyKey).map { ingestOne(it) } suspend fun close(id: ID): Result = 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 dfa281d92c..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,5 +1,6 @@ package com.flipcash.services.controllers +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 @@ -16,7 +17,7 @@ class PaymentRequestController @Inject constructor( private val repository: PaymentRequestRepository, private val userManager: UserManager, ) { - suspend fun createPaymentRequest(request: NewSplitRequest, idempotencyKey: ID): 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, request, idempotencyKey) 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 712cedfd8d..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 @@ -432,8 +432,8 @@ internal fun ID.asPaymentRequestId(): PaymentRequestModel.PaymentRequestId { return PaymentRequestModel.PaymentRequestId.newBuilder().setValue(toByteString()).build() } -internal fun ID.asPaymentRequestIdempotencyKey(): PaymentRequestModel.IdempotencyKey { - return PaymentRequestModel.IdempotencyKey.newBuilder().setValue(toByteString()).build() +internal fun IdempotencyKey.asPaymentRequestIdempotencyKey(): PaymentRequestModel.IdempotencyKey { + return PaymentRequestModel.IdempotencyKey.newBuilder().setValue(bytes.toByteString()).build() } internal fun NewSplitRequest.asGroupChatPaymentRequest(): PaymentRequestModel.GroupChatPaymentRequest { 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 35163f399c..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,5 +1,6 @@ package com.flipcash.services.internal.network.services +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 @@ -33,7 +34,7 @@ internal class PaymentRequestService @Inject constructor( suspend fun createPaymentRequest( owner: Ed25519.KeyPair, request: NewSplitRequest, - idempotencyKey: ID, + idempotencyKey: IdempotencyKey, ): Result { return runCatching { api.createPaymentRequest(owner, request.asGroupChatPaymentRequest(), idempotencyKey.asPaymentRequestIdempotencyKey()) 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 7707da7c45..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,5 +1,6 @@ package com.flipcash.services.internal.repositories +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 @@ -22,7 +23,7 @@ internal class InternalPaymentRequestRepository( override suspend fun createPaymentRequest( owner: KeyPair, request: NewSplitRequest, - idempotencyKey: ID, + idempotencyKey: IdempotencyKey, ): Result { return service.createPaymentRequest(owner, request, idempotencyKey) .onFailure { 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 770add1cce..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,5 +1,6 @@ package com.flipcash.services.repository +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 @@ -18,7 +19,7 @@ interface PaymentRequestRepository { suspend fun createPaymentRequest( owner: KeyPair, request: NewSplitRequest, - idempotencyKey: ID, + idempotencyKey: IdempotencyKey, ): Result suspend fun getPaymentRequest( From a40069c9082667acc5a59dd1e29cc774f99836dd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 23:16:32 -0400 Subject: [PATCH 07/47] feat(messenger): offer Send Money or Request Money from a private group's cash button Request Money skips the balance gate, since asking for money spends nothing. The Money sheet reuses Add Money's card actions, now public in :shared:funding. --- .../core/src/main/res/values/strings.xml | 5 ++ .../app/messenger/internal/ChatViewModel.kt | 69 ++++++++++++++----- .../flipcash/shared/chat/PaymentRequests.kt | 13 ++++ .../app/funding/BottomBarCardAction.kt | 69 +++++++++++++++++++ .../flipcash/app/funding/internal/Buttons.kt | 69 ++----------------- 5 files changed, 145 insertions(+), 80 deletions(-) create mode 100644 apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/BottomBarCardAction.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index b9ae7d917e..743111b234 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1017,6 +1017,11 @@ Request You requested %1$s %1$s requested %2$s + Money + Send Money + Send money to someone in the group, or multiple people + Request Money + Collect money from people in the group Nothing yet Is Typing… 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 8068a46a1e..d142b03ef4 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 @@ -3583,22 +3586,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() @@ -3796,6 +3786,52 @@ internal class ChatViewModel @AssistedInject constructor( } } + /** Node 12243:1009 — Send Money or Request Money, as cards like the Add Money sheet's. */ + private fun presentMoneyActions() { + BottomBarManager.showMessage( + title = resources.getString(R.string.title_moneyActions), + 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) }, + ), + BottomBarAction( + text = resources.getString(R.string.action_dismiss), + style = BottomBarManager.BottomBarButtonStyle.Text, + ), + ), + ) + } + + 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?) { @@ -4204,6 +4240,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/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 index d10b92f69b..4223c8a7ab 100644 --- 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 @@ -3,11 +3,13 @@ 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.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 @@ -53,6 +55,17 @@ class PaymentRequests @Inject constructor( 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. 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, - ) - } - }, -) From 37113c542d3b019aa0700afddf40e09ee505eb6a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 23:32:30 -0400 Subject: [PATCH 08/47] feat(messenger): request money from a private group Request Money opens on the group's request history; New Request walks amount per person, a summary with name, people and time-to-pay editors, and a confirm sheet. Send Request creates the request once per confirm, under one idempotency key, then posts the request widget as a pending chat message, so a failed send retries the message and never re-creates the request. Select People takes its button text and shows the viewer as "You" so the request flow reuses it. The Money sheet change in f8a3308ec stopped four group-send tests from reaching Select People; they now pick Send Money from the sheet. --- .../com/flipcash/app/core/chat/ChatStep.kt | 9 + .../core/src/main/res/values/strings.xml | 58 ++ .../flipcash/app/messenger/ChatFlowScreen.kt | 20 + .../internal/request/RequestMoneyScreen.kt | 658 ++++++++++++++++++ .../internal/request/RequestMoneyViewModel.kt | 290 ++++++++ .../internal/screens/cash/GroupSendScreens.kt | 15 +- .../internal/ChatGroupCashLinkTest.kt | 27 + 7 files changed, 1074 insertions(+), 3 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyScreen.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyViewModel.kt 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..bec8c2f57a 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 @@ -189,6 +189,15 @@ 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. A pushed screen: it is somewhere the viewer goes, with its own back stack of + * editors, rather than a choice made over the conversation. + */ + @Parcelize + @Serializable + data class RequestMoney(val chatId: ChatId) : ChatStep + @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 743111b234..93162ede71 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1022,6 +1022,64 @@ 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 + People + Timing + + %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 + 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. + + Something went wrong. Please try again 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..eee4f68e1a 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 @@ -42,6 +42,7 @@ 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.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 @@ -220,6 +221,16 @@ private fun chatEntryProvider( ) } + annotatedEntry { step -> + val navigator = LocalCodeNavigator.current + RequestMoneyScreen( + chatId = step.chatId, + onClose = { navigator.pop() }, + // Request details land with the deck; until then a row has nowhere to go. + onOpenRequest = {}, + ) + } + annotatedEntry { step -> FlowReactionPickerScreen(step.messageId) } @@ -278,6 +289,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/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..666aa2264f --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyScreen.kt @@ -0,0 +1,658 @@ +package com.flipcash.app.messenger.internal.request + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +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.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberTimePickerState +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +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 androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.ui.DisplayTextInput +import com.flipcash.app.messenger.internal.screens.cash.GroupSelectPeopleContent +import com.flipcash.features.messenger.R +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.shared.amountentry.AmountEntryScreen +import com.getcode.opencode.model.financial.Fiat +import com.getcode.theme.CodeTheme +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.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import kotlin.time.Clock +import kotlin.time.Instant +import kotlin.time.toJavaInstant +import kotlin.time.toKotlinInstant + +/** + * 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. + */ +@Composable +internal fun RequestMoneyScreen( + chatId: ChatId, + onClose: () -> Unit, + onOpenRequest: (PaymentRequest) -> Unit, +) { + 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() } + BackHandler(enabled = state.stage != RequestMoneyViewModel.Stage.History) { back() } + + when (state.stage) { + RequestMoneyViewModel.Stage.History -> { + val requests by viewModel.chatRequests.collectAsStateWithLifecycle() + RequestHistoryContent( + requests = requests, + loadingMore = state.loadingPrevious, + canLoadMore = !state.previousEnd, + onBack = onClose, + onNewRequest = viewModel::newRequest, + onOpen = onOpenRequest, + onLoadMore = viewModel::loadMorePrevious, + ) + } + RequestMoneyViewModel.Stage.Amount -> AmountEntryScreen( + controller = viewModel.amountDelegate, + onConfirm = viewModel::amountEntered, + largeHeader = true, + appBar = { + AppBarWithTitle( + title = stringResource(R.string.title_amountPerPerson), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = back, + ) + }, + ) + RequestMoneyViewModel.Stage.Summary -> 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, + ) + } + RequestMoneyViewModel.Stage.People -> 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, + ) + } + RequestMoneyViewModel.Stage.Name -> state.draft?.let { draft -> + RequestNameContent(title = draft.title, onBack = back, onDone = viewModel::rename) + } + } + + when (state.sheet) { + RequestMoneyViewModel.Sheet.None -> Unit + RequestMoneyViewModel.Sheet.Deadline -> state.draft?.let { draft -> + DeadlineSheet( + 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, + onBackIconClicked = onBack, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(bottom = CodeTheme.dimens.grid.x4), + ) { + item { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(vertical = CodeTheme.dimens.grid.x2) + .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(stringResource(R.string.header_previousRequests)) } + 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) { + Text( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x4, bottom = CodeTheme.dimens.grid.x1), + text = text, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) +} + +@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()) + } + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = CodeTheme.dimens.inset), + ) { + Row( + modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text(text = date, style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary) + } + Text(text = progress, style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary) + } + HorizontalDivider(color = CodeTheme.colors.divider) + } +} + +@Composable +private fun RequestSummaryContent( + draft: NewRequestDraft, + sending: Boolean, + onBack: () -> Unit, + onName: () -> Unit, + onPeople: () -> Unit, + onTiming: () -> Unit, + onRequest: () -> Unit, +) { + val amount = draft.perPerson?.text().orEmpty() + 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 = pluralStringResource( + R.plurals.action_requestFromPeople, + draft.requestedCount, + draft.requestedCount, + amount, + ), + 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( + modifier = Modifier.fillMaxWidth(), + text = amount, + style = CodeTheme.typography.displayLarge, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.label_perPerson), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(CodeTheme.dimens.grid.x8)) + 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), + detail = stringResource(R.string.subtitle_requestMarkedPaid).takeIf { draft.people.markedPaid }, + 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) { + Column(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) { + Row( + modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Text( + modifier = Modifier.weight(1f), + text = label, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + ) + Column(horizontalAlignment = Alignment.End) { + Text( + text = value, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (detail != null) { + Text(text = detail, style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary) + } + } + } + HorizontalDivider(color = CodeTheme.colors.divider) + } +} + +@Composable +private fun peopleValue(people: NewRequestDraft.People): String = when (people) { + NewRequestDraft.People.Choose -> stringResource(R.string.value_requestPeople_choose) + NewRequestDraft.People.EveryoneIncludingYou -> stringResource(R.string.value_requestPeople_everyoneIncludingYou) + NewRequestDraft.People.EveryoneExceptYou -> stringResource(R.string.value_requestPeople_everyoneExceptYou) + is NewRequestDraft.People.SomeIncludingYou -> + pluralStringResource(R.plurals.value_requestPeople_someIncludingYou, people.others, people.others) + is NewRequestDraft.People.Some -> pluralStringResource(R.plurals.value_requestPeople_some, people.count, people.count) +} + +@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") + } +} + +@Composable +private fun RequestNameContent(title: String, onBack: () -> Unit, onDone: (String) -> Unit) { + val field = rememberTextFieldState(initialText = title) + val focusRequester = remember { FocusRequester() } + val trimmed = field.text.trim() + var valid by remember { mutableStateOf(trimmed.isNotEmpty()) } + LaunchedEffect(field) { + snapshotFlow { field.text.toString() }.collect { text -> + if (text.length > RequestMoneyViewModel.MAX_TITLE) { + field.edit { replace(0, length, text.take(RequestMoneyViewModel.MAX_TITLE)) } + } + valid = text.isNotBlank() + } + } + LaunchedEffect(Unit) { focusRequester.requestFocus() } + Column { + AppBarWithTitle(onBackIconClicked = onBack) + CodeScaffold( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + topBar = { + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2, bottom = CodeTheme.dimens.inset), + text = stringResource(R.string.label_requestName), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + }, + bottomBar = { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .imePadding() + .padding(bottom = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.action_done), + enabled = valid, + onClick = { onDone(field.text.toString()) }, + ) + }, + ) { padding -> + DisplayTextInput( + state = field, + placeholder = stringResource(R.string.label_requestName), + modifier = Modifier + .padding(padding) + .fillMaxWidth() + .focusRequester(focusRequester), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + imeAction = ImeAction.Done, + ), + onKeyboardAction = { if (valid) onDone(field.text.toString()) }, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DeadlineSheet( + current: NewRequestDraft.Deadline, + onChoose: (NewRequestDraft.Deadline) -> Unit, + onDismiss: () -> Unit, +) { + var pickingCustom by rememberSaveable { mutableStateOf(false) } + if (pickingCustom) { + CustomDeadlinePicker( + initial = (current as? NewRequestDraft.Deadline.Custom)?.at, + onPicked = { pickingCustom = false; onChoose(NewRequestDraft.Deadline.Custom(it)) }, + onDismiss = { pickingCustom = false }, + ) + return + } + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = CodeTheme.colors.background, + ) { + Column(modifier = Modifier.navigationBarsPadding().padding(bottom = CodeTheme.dimens.grid.x4)) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.title_timeToPay), + style = CodeTheme.typography.screenTitle, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + val presets = listOf( + NewRequestDraft.Deadline.Preset.Day to R.string.option_timeToPay_day, + NewRequestDraft.Deadline.Preset.ThreeDays to R.string.option_timeToPay_threeDays, + NewRequestDraft.Deadline.Preset.Week to R.string.option_timeToPay_week, + NewRequestDraft.Deadline.Preset.TwoWeeks to R.string.option_timeToPay_twoWeeks, + NewRequestDraft.Deadline.Preset.FourWeeks to R.string.option_timeToPay_fourWeeks, + ) + presets.forEach { (preset, label) -> + DeadlineRow(stringResource(label), checked = current == preset) { onChoose(preset) } + } + DeadlineRow( + stringResource(R.string.option_timeToPay_custom), + checked = current is NewRequestDraft.Deadline.Custom, + ) { pickingCustom = true } + } + } +} + +@Composable +private fun DeadlineRow(label: String, checked: Boolean, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = label, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + if (checked) Image(painter = painterResource(R.drawable.ic_checked), contentDescription = null) + } +} + +/** + * The platform date picker, then its time picker, bounded to the spec's custom range. Not in the + * design; the spec asks for the standard pickers. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CustomDeadlinePicker(initial: Instant?, onPicked: (Instant) -> Unit, onDismiss: () -> Unit) { + val range = remember { NewRequestDraft.Deadline.customRange(Clock.System.now()) } + val zone = ZoneId.systemDefault() + val start = remember { (initial ?: range.start).toJavaInstant().atZone(zone) } + var pickedDate by remember { mutableStateOf(null) } + + val date = pickedDate + if (date == null) { + val earliest = range.start.toJavaInstant().atZone(zone).toLocalDate() + val latest = range.endInclusive.toJavaInstant().atZone(zone).toLocalDate() + val dateState = rememberDatePickerState( + initialSelectedDateMillis = start.toLocalDate().atStartOfDay(ZoneId.of("UTC")).toInstant().toEpochMilli(), + selectableDates = object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + val day = java.time.Instant.ofEpochMilli(utcTimeMillis).atZone(ZoneId.of("UTC")).toLocalDate() + return !day.isBefore(earliest) && !day.isAfter(latest) + } + }, + ) + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = { + dateState.selectedDateMillis?.let { + pickedDate = java.time.Instant.ofEpochMilli(it).atZone(ZoneId.of("UTC")).toLocalDate() + } + }) { Text(stringResource(R.string.action_next)) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }, + ) { DatePicker(state = dateState) } + return + } + + val timeState = rememberTimePickerState(initialHour = start.hour, initialMinute = start.minute) + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = true)) { + Column( + modifier = Modifier.padding(CodeTheme.dimens.inset), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TimePicker(state = timeState) + Row(horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2)) { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } + TextButton(onClick = { + val at = ZonedDateTime.of(date, java.time.LocalTime.of(timeState.hour, timeState.minute), zone) + .toInstant() + .toKotlinInstant() + onPicked(at.coerceIn(range.start, range.endInclusive)) + }) { Text(stringResource(R.string.action_done)) } + } + } + } +} + +@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..710e87f95e --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestMoneyViewModel.kt @@ -0,0 +1,290 @@ +package com.flipcash.app.messenger.internal.request + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.BottomBarManager +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Fiat +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, + ), + ) + + /** 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) { + BottomBarManager.showInfo( + title = resources.getString(R.string.title_requestMaxPeople), + message = resources.getString(R.string.description_requestMaxPeople), + ) + 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)) } + // A failed send stays in the transcript as a pending message with its own retry, which + // resends the same request id. Create is never called again for this confirm. + chatCoordinator.sendPaymentRequest(chatId, requestId) + _state.update { it.copy(sheet = Sheet.None, sendProgress = LoadingSuccessState(success = true)) } + effects.send(Effect.Sent) + } + } + + 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 -> { + trace(tag = "RequestMoney", message = "create failed: ${error::class.simpleName}", error = error) + R.string.error_description_requestGeneric + } + } + 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/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/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..9da832cf6c 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,27 @@ 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 actions = BottomBarManager.messages.value.single().actions + assertTrue(actions.any { it.testTag == "money_action_send" }) + assertTrue(ChatViewModel.Event.NavigateToAmountEntry !in events) + + 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 +349,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) } From ed7fac60d2a83e68eae529d98d5b60ed29fcd187 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 23:39:26 -0400 Subject: [PATCH 09/47] feat(chat): draw the payment request widget as a request card The card resolves from the store through a pure requestCardState: a request from another chat or creator draws as unsupported, and a DENIED or NOT_FOUND fetch marks the id missing so the card stops refetching. --- .../core/src/main/res/values/strings.xml | 24 +++ .../flipcash/shared/chat/ui/MessageBubble.kt | 41 ++++- .../chat/ui/request/PaymentRequestBubble.kt | 61 +++++++ .../chat/ui/request/PaymentRequestUi.kt | 160 ++++++++++++++++++ .../shared/chat/PaymentRequestCard.kt | 44 +++++ .../flipcash/shared/chat/PaymentRequests.kt | 29 +++- .../shared/chat/RequestCardStateTest.kt | 78 +++++++++ 7 files changed, 434 insertions(+), 3 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestBubble.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestUi.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequestCard.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/RequestCardStateTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 93162ede71..4ae2db4275 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1035,6 +1035,30 @@ Name 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$s of %2$s + + %1$s, %2$s of %3$s paid, %4$s + + %1$d request + %1$d requests + + Next request %1$s Request Everyone, Including You 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 864168fa88..1fca1f184a 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,11 @@ 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.models.ChatAction import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.shared.chat.models.LinkCard @@ -378,8 +383,40 @@ fun ContentBubble( attention = attention, ) - // TODO(group-requests): the request card replaces this in the next commit. - is WidgetContent.PaymentRequest, WidgetContent.Unsupported -> UndecryptableBubble( + 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 = PaymentRequestBubbleDefaults.PADDING, + verticalPadding = PaymentRequestBubbleDefaults.PADDING, + 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, maxWidth = bubbleMaxWidth, 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..713c5e1f5a --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestBubble.kt @@ -0,0 +1,61 @@ +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, + /** Fetches a request the store doesn't hold; safe to call on every composition. */ + val onNeeded: (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 + val PADDING: Dp = 14.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) } +} + +@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..e692175851 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestUi.kt @@ -0,0 +1,160 @@ +package com.flipcash.shared.chat.ui.request + +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.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.pluralStringResource +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.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 = when (this) { + is StatusLine.DaysLeft -> pluralStringResource(R.plurals.label_requestDaysLeft, days, days) + is StatusLine.HoursLeft -> pluralStringResource(R.plurals.label_requestHoursLeft, hours, hours) + is StatusLine.MinutesLeft -> pluralStringResource(R.plurals.label_requestMinutesLeft, minutes, minutes) + StatusLine.PaidInFull -> stringResource(R.string.label_requestPaidInFull) + StatusLine.Closed -> stringResource(R.string.label_requestClosed) + StatusLine.Expired -> stringResource(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. + */ +@Composable +fun PaymentRequestCardContent( + request: PaymentRequest?, + title: String?, + now: Instant, + modifier: Modifier = Modifier, +) { + val loading = request == null || title == null + val ended = request != null && !request.isOpen(now) + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = Modifier + .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) + } + Column { + Text( + text = stringResource(R.string.label_paymentRequest_card), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + 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 Modifier, + ) + } + } + 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 Modifier, + ) + RequestProgressBar(fraction = request?.split?.paidFraction ?: 0f, ended = ended) + 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 Modifier, + ) + } +} 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 index 4223c8a7ab..8767aff282 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -40,6 +41,13 @@ class PaymentRequests @Inject constructor( 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() @@ -71,7 +79,25 @@ class PaymentRequests @Inject constructor( * 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)) } + 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) } @@ -90,6 +116,7 @@ class PaymentRequests @Inject constructor( fun clear() { requests.value = emptyMap() + _missing.value = emptySet() } private suspend fun ingestOne(request: PaymentRequest): PaymentRequest { 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)) + } +} From ba59a8b9ac35058a384348833a37d0e75367d560 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 23:43:59 -0400 Subject: [PATCH 10/47] feat(messenger): poll a group chat's payment requests and draw their cards in the transcript --- .../com/flipcash/app/core/chat/ChatStep.kt | 8 ++ .../internal/request/ChatRequests.kt | 50 ++++++++ .../internal/request/ChatRequestsViewModel.kt | 120 ++++++++++++++++++ .../internal/screens/MessengerScreen.kt | 14 ++ .../flipcash/shared/chat/ui/MessageBubble.kt | 4 + .../chat/ui/request/PaymentRequestBubble.kt | 12 ++ 6 files changed, 208 insertions(+) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequests.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt 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 bec8c2f57a..a6ffa7aaf6 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 @@ -198,6 +198,14 @@ sealed interface ChatStep : FlowStep, Parcelable { @Serializable data class RequestMoney(val chatId: ChatId) : ChatStep + /** + * 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. + */ + @Parcelize + @Serializable + data class RequestDetails(val chatId: ChatId, val requestId: String) : ChatStep + @Parcelize @Serializable data class ReactionBoost( 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..02af063593 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt @@ -0,0 +1,120 @@ +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.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.stateIn +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 + + /** The deck, front card first. Empty hides it. */ + val deck: StateFlow> = combine( + chatId.flatMapLatest { id -> id?.let { requests.inChat(it) } ?: flowOf(emptyList()) }, + _now, + cycled, + ) { inChat, now, cycled -> deckOrder(inChat, now).rotated(cycled) } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + /** Requests refetched because a message about them arrived, so a re-shown row doesn't refetch. */ + private val refreshed = mutableSetOf() + + init { + viewModelScope.launch { + while (true) { + delay(1.minutes) + _now.value = Clock.System.now() + } + } + } + + /** + * 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/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index daefc9d7ac..bc90ce46a8 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 @@ -92,6 +92,12 @@ 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.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) { @@ -428,6 +434,13 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } // The group's profile is one tap away; have its cover downloaded before that tap. + val requestsViewModel = hiltViewModel() + 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)) } @@ -539,6 +552,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) { 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 1fca1f184a..dd4604bfa6 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 @@ -74,6 +74,7 @@ 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 @@ -216,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, 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 index 713c5e1f5a..a6ccd59b51 100644 --- 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 @@ -25,8 +25,12 @@ class PaymentRequestCards( 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( @@ -52,6 +56,14 @@ internal fun FetchWhenAbsent(id: ID, cards: PaymentRequestCards) { 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) { From 42da578d56b44b0e1d42f6431c57deca53871b35 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 23:48:22 -0400 Subject: [PATCH 11/47] feat(messenger): show a group chat's payment requests as a deck in the app bar --- .../messenger/internal/request/RequestDeck.kt | 381 ++++++++++++++++++ .../internal/screens/MessengerScreen.kt | 37 +- .../internal/screens/components/ChatTopBar.kt | 7 +- .../internal/request/DeckRotationTest.kt | 23 ++ 4 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckRotationTest.kt 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..21001fd696 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt @@ -0,0 +1,381 @@ +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.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +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.offset +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.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.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +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.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.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.IntOffset +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) = "payment-request-${id.hashCode()}" + +/** + * The app-bar deck (concept B): the front request with up to two cards peeking beneath it. Swipe + * down cycles; tap expands. Hidden while [expanded], when the front card is over the transcript. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +internal fun RequestDeckPill( + deck: List, + now: Instant, + expanded: Boolean, + onExpand: () -> Unit, + onCycle: () -> Unit, + modifier: Modifier = Modifier, +) { + val front = deck.firstOrNull() + AnimatedVisibility( + visible = front != null && !expanded, + enter = fadeIn(), + exit = fadeOut(), + modifier = modifier, + ) { + front ?: return@AnimatedVisibility + val transition = LocalDeckTransition.current + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val drag = remember { Animatable(0f) } + val pillHeightPx = with(density) { PILL_HEIGHT.toPx() } + val canCycle = deck.size > 1 + + val label = deckLabel(deck, now) + val nextLabel = stringResource(R.string.action_nextRequest) + Box( + modifier = Modifier + .padding(bottom = PEEK_STEP * 2) + .clearAndSetSemantics { + contentDescription = label + role = Role.Button + onClick { onExpand(); true } + if (canCycle) customActions = listOf(CustomAccessibilityAction(nextLabel) { onCycle(); true }) + }, + ) { + // The peeking cards, back first. Widths step in so the front card's edges stay clear. + deck.drop(1).take(2).withIndex().reversed().forEach { (index, _) -> + val depth = index + 1 + Box( + modifier = Modifier + .matchParentSize() + .padding(horizontal = PEEK_INSET * depth) + .offset(y = PEEK_STEP * depth) + .background( + if (depth == 1) CodeTheme.colors.surfaceVariant else CodeTheme.colors.surface, + PILL_SHAPE, + ) + .border(1.dp, CodeTheme.colors.border, PILL_SHAPE), + ) + } + DeckFront( + request = front, + now = now, + modifier = Modifier + .then( + if (transition != null) with(transition) { + Modifier.sharedBounds( + sharedContentState = rememberSharedContentState(sharedKey(front.id)), + animatedVisibilityScope = this@AnimatedVisibility, + clipInOverlayDuringTransition = OverlayClip(PILL_SHAPE), + ) + } else Modifier + ) + .offset { IntOffset(0, drag.value.roundToInt()) } + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { delta -> + scope.launch { + // One request: a small elastic pull and nothing more. + val limit = if (canCycle) pillHeightPx * 1.5f else pillHeightPx * 0.25f + drag.snapTo((drag.value + delta * if (canCycle) 1f else 0.3f).coerceIn(0f, limit)) + } + }, + onDragStopped = { velocity -> + val past = drag.value > pillHeightPx * CYCLE_THRESHOLD || velocity > FLING_VELOCITY + if (canCycle && past) { + onCycle() + drag.snapTo(0f) + } else { + drag.animateTo(0f, spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + } + }, + ) + .clickable(onClick = onExpand), + ) + } + } +} + +@Composable +private fun DeckFront(request: PaymentRequest, now: Instant, modifier: Modifier = Modifier) { + val ended = !request.isOpen(now) + val split = request.split + Row( + modifier = modifier + .alpha(if (ended) ENDED_ALPHA else 1f) + .background(CodeTheme.colors.surfaceVariant, PILL_SHAPE) + .border(1.dp, CodeTheme.colors.border, PILL_SHAPE) + .padding(start = 5.dp, end = 12.dp, top = 5.dp, bottom = 5.dp) + .widthIn(max = PILL_MAX_WIDTH), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + RequestRing(request = request, now = now, size = 26.dp) + Column { + Text( + text = request.deckTitle.orEmpty(), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (split != null) { + Text( + text = stringResource( + R.string.label_requestDeckProgress, + split.paid.requestText(), + split.requested.requestText(), + ), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + ) + } + } + } +} + +/** + * 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) { + 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, + ) + } +} + +/** " requests, , <paid> of <requested> paid, <status line>". */ +@Composable +private fun deckLabel(deck: List<PaymentRequest>, now: Instant): String { + val front = deck.first() + val split = front.split + val main = 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<PaymentRequest>, + now: Instant, + expanded: Boolean, + top: Dp, + onCollapse: () -> Unit, + onOpen: (PaymentRequest) -> Unit, +) { + BackHandler(enabled = expanded, onBack = onCollapse) + AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = top) + .background(CodeTheme.colors.scrim) + .pointerInput(Unit) { detectTapGestures { onCollapse() } }, + ) + } + AnimatedVisibility( + visible = 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() + .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(10.dp), + ) { + 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)), + 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(), + ) + } + ) + .alpha(if (request.isOpen(now)) 1f else ENDED_ALPHA) + .background(CodeTheme.colors.surfaceVariant, CARD_SHAPE) + .clickable { onOpen(request) } + .padding(14.dp) + PaymentRequestCardContent(request = request, title = title, now = now, modifier = card) + } + item(key = "handle") { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Box( + modifier = Modifier + .size(width = 36.dp, height = 5.dp) + .background(CodeTheme.colors.textSecondary, RoundedCornerShape(2.5.dp)) + .clickable(onClick = onCollapse), + ) + } + } + } + } +} + +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 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 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 bc90ce46a8..b08eda17e2 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 @@ -94,6 +94,10 @@ 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 @@ -445,7 +449,18 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { PrefetchProfileCover(group.coverPicture, BlobAccessContext.ChatProfile(group.chatId)) } - Box(modifier = Modifier.fillMaxSize()) { + 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 openRequest: (com.flipcash.services.models.paymentrequest.PaymentRequest) -> Unit = { request -> + state.chatId?.let { chatId -> + requestsViewModel.setExpanded(false) + keyboard.hideIfVisible { navigator.push(ChatStep.RequestDetails(chatId, request.id.base58)) } + } + } + + RequestDeckLayout(modifier = Modifier.fillMaxSize()) { CodeScaffold( // The input bar rides the keyboard; the message list is inset by it either way. modifier = Modifier.imePadding(), @@ -463,6 +478,17 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { onBarHeightChange = { barHeight = it }, chatActionHandler = chatActionHandler, dispatch = viewModel::dispatchEvent, + trailing = { + if (state.chatType == ChatType.GROUP) { + RequestDeckPill( + deck = drawnDeck, + now = deckNow, + expanded = deckExpanded, + onExpand = { keyboard.hideIfVisible { requestsViewModel.setExpanded(true) } }, + onCycle = requestsViewModel::cycle, + ) + } + }, ) // Hold the last list through the exit, so the card doesn't empty as it slides away. var lastRequests by remember { mutableStateOf(state.joinRequests) } @@ -602,6 +628,15 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } + ExpandedRequests( + deck = drawnDeck, + now = deckNow, + expanded = deckExpanded, + top = barHeight, + onCollapse = { requestsViewModel.setExpanded(false) }, + onOpen = openRequest, + ) + openPhotoId?.let { id -> ChatPhotoOverlay( messageId = id, 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 ffe5fdffbc..be5c812757 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 @@ -80,6 +80,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() @@ -108,7 +109,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, @@ -170,6 +171,7 @@ private fun ConversationTitleBar( navigator: CodeNavigator, state: ChatViewModel.State, chatActionHandler: ChatActionHandler, + trailing: @Composable () -> Unit, ) { AppBarWithTitle( leftIcon = { @@ -256,6 +258,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/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)) + } +} From db550ec6bc90acc6a0bcdc2050eb467ba3f14aa8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sat, 24 Oct 2026 23:59:58 -0400 Subject: [PATCH 12/47] feat(messenger): add Request details with paying a share, closing and marking shares paid --- .../core/src/main/res/values/strings.xml | 19 + .../flipcash/app/messenger/ChatFlowScreen.kt | 15 +- .../internal/request/RequestDetailsScreen.kt | 511 ++++++++++++++++++ .../request/RequestDetailsViewModel.kt | 339 ++++++++++++ .../request/RequestDetailsRulesTest.kt | 84 +++ 5 files changed, 966 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsRulesTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 4ae2db4275..759eb9242a 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1104,6 +1104,25 @@ <string name="error_description_requestCurrency">Requests aren\'t available in this currency yet.</string> <!-- Placeholder: the spec names no copy for a network failure or an unreachable result. --> <string name="error_description_requestGeneric">Something went wrong. Please try again</string> + <!-- Request details, group requests spec frames 09–11, 24, 28, 29 and 33–35. --> + <string name="title_requestDetails">Request</string> + <string name="label_requestedBy">Requested by %1$s</string> + <string name="label_requestedByYou">Requested by you</string> + <string name="label_sharePaid">✓ Paid</string> + <string name="label_shareUnpaid">Hasn’t paid yet</string> + <string name="label_shareUnpaidEnded">Didn’t pay</string> + <!-- Under the member's name on the Mark as Paid sheet: "$20 · Hasn't paid yet". --> + <string name="label_shareUnpaidAmount">%1$s · Hasn’t paid yet</string> + <string name="action_sendShare">Send %1$s</string> + <string name="cd_requestOptions">Request options</string> + <string name="action_closeRequest">Close Request</string> + <string name="title_closeRequestConfirm">Close Request?</string> + <string name="description_closeRequestConfirm">This will stop payments. Anyone who hasn’t paid will be marked Didn’t pay. Payments already made won’t be affected.</string> + <string name="action_markAsPaid">Mark as Paid</string> + <string name="title_markPaidConfirm">Mark %1$s as Paid?</string> + <string name="description_markPaidConfirm">Use this if %1$s paid you outside Flipcash. No money will be sent. This action cannot be reversed.</string> + <string name="error_title_requestClosed">Request Closed</string> + <string name="error_description_requestClosed">This request is no longer accepting payments.</string> <string name="label_chat_preview_noMessages">Nothing yet</string> <string name="label_isTyping">Is Typing…</string> 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 eee4f68e1a..05547514c1 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 @@ -42,6 +42,7 @@ 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.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 @@ -88,6 +89,7 @@ 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( @@ -226,8 +228,17 @@ private fun chatEntryProvider( RequestMoneyScreen( chatId = step.chatId, onClose = { navigator.pop() }, - // Request details land with the deck; until then a row has nowhere to go. - onOpenRequest = {}, + onOpenRequest = { request -> navigator.push(ChatStep.RequestDetails(step.chatId, request.id.base58)) }, + ) + } + + annotatedEntry<ChatStep.RequestDetails> { step -> + val navigator = LocalCodeNavigator.current + RequestDetailsScreen( + chatId = step.chatId, + requestId = step.requestId, + onBack = { navigator.pop() }, + onOpenScreen = { route -> navigator.navigate(route) }, ) } 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..29eaea68a7 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt @@ -0,0 +1,511 @@ +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.MoreHoriz +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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 <share>" 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, +) { + val viewModel = hiltViewModel<RequestDetailsViewModel>() + LaunchedEffect(chatId, requestId) { viewModel.open(chatId, requestId) } + + // 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, + onBackIconClicked = onBack, + endContent = { + if (isCreatorOnOpen) { + IconButton( + modifier = Modifier.testTag("request_details_options"), + onClick = viewModel::showOptions, + ) { + Icon( + imageVector = Icons.Filled.MoreHoriz, + contentDescription = stringResource(R.string.cd_requestOptions), + tint = CodeTheme.colors.textMain, + ) + } + } + }, + ) + }, + bottomBar = { + val share = current?.let { unpaidShareOf(it, viewerId, now) } + 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<ID, ChatMember>, + 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.displaySmall, + 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.textMedium, + 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() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = CodeTheme.dimens.inset), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + MemberAvatar(member, Modifier.requiredSize(CodeTheme.dimens.staticGrid.x6)) + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + 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, + ) + } + HorizontalDivider(color = CodeTheme.colors.divider) + } + } +} + +/** + * 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..445fe89de7 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt @@ -0,0 +1,339 @@ +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.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.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.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, + ) + + sealed interface Effect { + data class OpenScreen(val route: AppRoute) : Effect + } + + private val _state = MutableStateFlow(State()) + val state: StateFlow<State> = _state.asStateFlow() + + private val effects = Channel<Effect>(Channel.BUFFERED) + val effectFlow: Flow<Effect> = 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<Instant> = _now.asStateFlow() + + val viewerId: ID? get() = userManager.accountId + + val request: StateFlow<PaymentRequest?> = _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<Map<ID, ChatMember>> = combine(_state, rosters.state) { s, all -> + all[s.chatId].orEmpty().associateBy { it.userId } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) + + val token: StateFlow<Token?> = 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) + _now.value = Clock.System.now() + delay(ChatRequestsViewModel.POLL_INTERVAL) + } + } + + /** "Send <share>": 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 -> Unit + 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<PaymentRequest>) { + 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) } + BottomBarManager.showError( + title = resources.getString(R.string.error_title_cashFailedToSend), + message = resources.getString(R.string.error_description_requestGeneric), + ) + } + } + } + + 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 +} + +/** Whether the viewer has a share to pay on [request] now: the "Send <amount>" button. */ +internal fun unpaidShareOf(request: PaymentRequest, viewerId: ID?, now: Instant) = + if (viewerId == null || !request.isOpen(now)) null + else request.split?.shareOf(viewerId)?.takeIf { !it.isPaid } 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..a339850c4b --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsRulesTest.kt @@ -0,0 +1,84 @@ +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 <share>" on Request details. */ +class RequestDetailsRulesTest { + + private val creator = List<Byte>(16) { 1 } + private val debtor = List<Byte>(16) { 2 } + private val payer = List<Byte>(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)) + } +} From 7904345dfb6603283201e2f02e529116f2732db9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 00:44:06 -0400 Subject: [PATCH 13/47] fix(messenger): match request screens to the design after a device pass The amount keypad had no currency symbol until the user typed, the expanded deck and pill cards let the transcript show through, and the summary and details screens used divider rows where the design has cards. --- .../messenger/internal/request/RequestDeck.kt | 5 ++ .../internal/request/RequestDetailsScreen.kt | 62 +++++++++---------- .../internal/request/RequestMoneyScreen.kt | 57 +++++++++-------- .../internal/request/RequestMoneyViewModel.kt | 5 +- 4 files changed, 67 insertions(+), 62 deletions(-) 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 index 21001fd696..81fa9e5558 100644 --- 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 @@ -151,6 +151,7 @@ internal fun RequestDeckPill( .matchParentSize() .padding(horizontal = PEEK_INSET * depth) .offset(y = PEEK_STEP * depth) + .background(CodeTheme.colors.background, PILL_SHAPE) .background( if (depth == 1) CodeTheme.colors.surfaceVariant else CodeTheme.colors.surface, PILL_SHAPE, @@ -203,6 +204,7 @@ private fun DeckFront(request: PaymentRequest, now: Instant, modifier: Modifier val split = request.split 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) @@ -350,6 +352,9 @@ internal fun ExpandedRequests( ) } ) + // 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) } 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 index 29eaea68a7..7a592d2f01 100644 --- 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 @@ -19,7 +19,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ModalBottomSheet @@ -252,7 +251,7 @@ private fun RequestDetailsContent( ) { Text( text = request.deckTitle.orEmpty(), - style = CodeTheme.typography.displaySmall, + style = CodeTheme.typography.displayCompact, color = CodeTheme.colors.textMain, ) Row( @@ -276,7 +275,7 @@ private fun RequestDetailsContent( } Text( text = request.progressText(), - style = CodeTheme.typography.textMedium, + style = CodeTheme.typography.displaySmall, color = CodeTheme.colors.textMain, ) RequestProgressBar(fraction = split.paidFraction, ended = !open) @@ -311,44 +310,39 @@ private fun ShareRow( 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(horizontal = CodeTheme.dimens.inset), + .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)) { - Row( - modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x3), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - ) { - 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, - ) - } - HorizontalDivider(color = CodeTheme.colors.divider) + 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, + ) } } 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 index 666aa2264f..c9de0bed70 100644 --- 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 @@ -1,5 +1,9 @@ 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 androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.clickable @@ -329,20 +333,16 @@ private fun RequestSummaryContent( ) { Spacer(Modifier.height(CodeTheme.dimens.grid.x6)) Text( - modifier = Modifier.fillMaxWidth(), text = amount, style = CodeTheme.typography.displayLarge, color = CodeTheme.colors.textMain, - textAlign = TextAlign.Center, ) Text( - modifier = Modifier.fillMaxWidth(), text = stringResource(R.string.label_perPerson), style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textSecondary, - textAlign = TextAlign.Center, ) - Spacer(Modifier.height(CodeTheme.dimens.grid.x8)) + Spacer(Modifier.height(CodeTheme.dimens.grid.x6)) SummaryRow( label = stringResource(R.string.label_requestName), value = draft.title, @@ -367,32 +367,35 @@ private fun RequestSummaryContent( @Composable private fun SummaryRow(label: String, value: String, detail: String?, onClick: () -> Unit) { - Column(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) { - Row( - modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x3), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - ) { + 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( - modifier = Modifier.weight(1f), - text = label, - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textSecondary, + text = value, + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) - Column(horizontalAlignment = Alignment.End) { - Text( - text = value, - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (detail != null) { - Text(text = detail, style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary) - } + if (detail != null) { + Text(text = detail, style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary) } } - HorizontalDivider(color = CodeTheme.colors.divider) + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) } } 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 index 710e87f95e..d4744325c2 100644 --- 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 @@ -96,7 +96,10 @@ internal class RequestMoneyViewModel @Inject constructor( 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<List<PaymentRequest>> = _state From 775175a45f4ae64e8449e8e49517a52e79d9ea40 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 10:47:40 -0400 Subject: [PATCH 14/47] fix(messenger): match the group request spec on errors, Send, and copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Money sheet has two rows, Send Money and Request Money, with no title or Dismiss row. - Create and details-action failures that have no specific copy show the app's network error instead of a new generic string. - Send is hidden on request details when the fetch carried no payment intent id for the viewer (case 20). - A request's widget message is retried twice on the same pending row, then shows the chat's Message Not Sent error with a retry. Create is never called again. - Request copy uses ' rather than ’, as the spec does. - Tests cover cases 11 and 12 through the store merge, the summary copy for 14 to 18, and case 22's "24 hours left to pay". --- .../core/src/main/res/values/strings.xml | 13 ++- .../app/messenger/internal/ChatViewModel.kt | 7 +- .../messenger/internal/request/RequestCopy.kt | 29 +++++++ .../internal/request/RequestDetailsScreen.kt | 2 +- .../request/RequestDetailsViewModel.kt | 24 ++++-- .../internal/request/RequestMoneyScreen.kt | 23 ++--- .../internal/request/RequestMoneyViewModel.kt | 38 +++++++-- .../internal/ChatGroupCashLinkTest.kt | 9 +- .../internal/request/RequestCopyTest.kt | 83 +++++++++++++++++++ .../request/RequestDetailsRulesTest.kt | 6 ++ .../chat/ui/request/PaymentRequestUi.kt | 19 +++-- .../chat/ui/request/RequestStatusTextTest.kt | 28 +++++++ .../flipcash/shared/chat/ChatCoordinator.kt | 12 ++- .../internal/delegates/MessagingDelegate.kt | 19 ++++- .../chat/MessagingSendEncryptionTest.kt | 28 +++++++ .../shared/chat/PaymentRequestsMergeTest.kt | 15 ++++ .../paymentrequest/PaymentRequestTest.kt | 2 +- 17 files changed, 297 insertions(+), 60 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestCopy.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestCopyTest.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/request/RequestStatusTextTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 759eb9242a..df74d92b1c 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1017,7 +1017,6 @@ <string name="label_paymentRequest_card">Request</string> <string name="label_chat_preview_requested">You requested %1$s</string> <string name="label_chat_preview_requested_bySender">%1$s requested %2$s</string> - <string name="title_moneyActions">Money</string> <string name="action_sendMoney">Send Money</string> <string name="subtitle_sendMoney_group">Send money to someone in the group, or multiple people</string> <string name="action_requestMoney">Request Money</string> @@ -1072,7 +1071,7 @@ <item quantity="one">%1$d Person</item> <item quantity="other">%1$d People</item> </plurals> - <string name="subtitle_requestMarkedPaid">You’ll be marked as paid</string> + <string name="subtitle_requestMarkedPaid">You\'ll be marked as paid</string> <plurals name="value_requestDaysToPay"> <item quantity="one">%1$d Day To Pay</item> <item quantity="other">%1$d Days To Pay</item> @@ -1102,22 +1101,20 @@ <string name="error_description_requestEndsAt">Choose a deadline within the next 4 weeks.</string> <string name="error_description_requestShares">Someone you included is no longer in the group. Check who\'s included and try again.</string> <string name="error_description_requestCurrency">Requests aren\'t available in this currency yet.</string> - <!-- Placeholder: the spec names no copy for a network failure or an unreachable result. --> - <string name="error_description_requestGeneric">Something went wrong. Please try again</string> <!-- Request details, group requests spec frames 09–11, 24, 28, 29 and 33–35. --> <string name="title_requestDetails">Request</string> <string name="label_requestedBy">Requested by %1$s</string> <string name="label_requestedByYou">Requested by you</string> <string name="label_sharePaid">✓ Paid</string> - <string name="label_shareUnpaid">Hasn’t paid yet</string> - <string name="label_shareUnpaidEnded">Didn’t pay</string> + <string name="label_shareUnpaid">Hasn\'t paid yet</string> + <string name="label_shareUnpaidEnded">Didn\'t pay</string> <!-- Under the member's name on the Mark as Paid sheet: "$20 · Hasn't paid yet". --> - <string name="label_shareUnpaidAmount">%1$s · Hasn’t paid yet</string> + <string name="label_shareUnpaidAmount">%1$s · Hasn\'t paid yet</string> <string name="action_sendShare">Send %1$s</string> <string name="cd_requestOptions">Request options</string> <string name="action_closeRequest">Close Request</string> <string name="title_closeRequestConfirm">Close Request?</string> - <string name="description_closeRequestConfirm">This will stop payments. Anyone who hasn’t paid will be marked Didn’t pay. Payments already made won’t be affected.</string> + <string name="description_closeRequestConfirm">This will stop payments. Anyone who hasn\'t paid will be marked Didn\'t pay. Payments already made won\'t be affected.</string> <string name="action_markAsPaid">Mark as Paid</string> <string name="title_markPaidConfirm">Mark %1$s as Paid?</string> <string name="description_markPaidConfirm">Use this if %1$s paid you outside Flipcash. No money will be sent. This action cannot be reversed.</string> 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 d142b03ef4..38350a6d44 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 @@ -3788,8 +3788,9 @@ 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 = resources.getString(R.string.title_moneyActions), + title = "", actions = listOf( bottomBarCardAction( title = resources.getString(R.string.action_sendMoney), @@ -3805,10 +3806,6 @@ internal class ChatViewModel @AssistedInject constructor( testTag = "money_action_request", onClick = { dispatchEvent(Event.NavigateToRequestMoney) }, ), - BottomBarAction( - text = resources.getString(R.string.action_dismiss), - style = BottomBarManager.BottomBarButtonStyle.Text, - ), ), ) } 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 <amount> from <n> 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<String, String> = + 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/RequestDetailsScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt index 7a592d2f01..6abfa34dd4 100644 --- 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 @@ -133,7 +133,7 @@ internal fun RequestDetailsScreen( ) }, bottomBar = { - val share = current?.let { unpaidShareOf(it, viewerId, now) } + val share = current?.let { sendableShareOf(it, viewerId, now, state.hasPaymentIntent) } if (share != null) { CodeButton( modifier = Modifier 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 index 445fe89de7..e96fe435a2 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -22,6 +23,7 @@ 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 @@ -88,6 +90,11 @@ internal class RequestDetailsViewModel @Inject constructor( 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 { @@ -146,6 +153,7 @@ internal class RequestDetailsViewModel @Inject constructor( 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) } @@ -166,7 +174,7 @@ internal class RequestDetailsViewModel @Inject constructor( ready = found _state.update { it.copy(sheet = Sheet.Pay, payProgress = LoadingSuccessState()) } } - is ShareToPay.NothingToPay -> Unit + is ShareToPay.NothingToPay -> _state.update { it.copy(hasPaymentIntent = false) } is ShareToPay.Closed -> presentClosed() } } @@ -270,10 +278,7 @@ internal class RequestDetailsViewModel @Inject constructor( .onFailure { // The request may have changed under the action, e.g. closed or paid elsewhere. _state.value.requestId?.let { id -> requests.fetch(id) } - BottomBarManager.showError( - title = resources.getString(R.string.error_title_cashFailedToSend), - message = resources.getString(R.string.error_description_requestGeneric), - ) + ErrorUtils.showNetworkError(resources) } } } @@ -333,7 +338,14 @@ internal fun canMarkPaid(request: PaymentRequest, userId: ID, viewerId: ID?, now return !share.isPaid } -/** Whether the viewer has a share to pay on [request] now: the "Send <amount>" button. */ +/** + * The share the "Send <amount>" 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) = if (viewerId == null || !request.isOpen(now)) null else request.split?.shareOf(viewerId)?.takeIf { !it.isPaid } 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 index c9de0bed70..d190023110 100644 --- 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 @@ -50,6 +50,7 @@ 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.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextAlign @@ -297,6 +298,7 @@ private fun RequestSummaryContent( onRequest: () -> Unit, ) { val amount = draft.perPerson?.text().orEmpty() + val resources = LocalResources.current CodeScaffold( topBar = { AppBarWithTitle( @@ -313,12 +315,7 @@ private fun RequestSummaryContent( .padding(horizontal = CodeTheme.dimens.inset) .padding(bottom = CodeTheme.dimens.grid.x2) .testTag("request_summary_send"), - text = pluralStringResource( - R.plurals.action_requestFromPeople, - draft.requestedCount, - draft.requestedCount, - amount, - ), + text = requestButtonText(draft, amount, resources), enabled = draft.canSend, isLoading = sending, onClick = onRequest, @@ -351,8 +348,8 @@ private fun RequestSummaryContent( ) SummaryRow( label = stringResource(R.string.label_requestPeople), - value = peopleValue(draft.people), - detail = stringResource(R.string.subtitle_requestMarkedPaid).takeIf { draft.people.markedPaid }, + value = peopleValue(draft.people, resources), + detail = peopleDetail(draft.people, resources), onClick = onPeople, ) SummaryRow( @@ -399,16 +396,6 @@ private fun SummaryRow(label: String, value: String, detail: String?, onClick: ( } } -@Composable -private fun peopleValue(people: NewRequestDraft.People): String = when (people) { - NewRequestDraft.People.Choose -> stringResource(R.string.value_requestPeople_choose) - NewRequestDraft.People.EveryoneIncludingYou -> stringResource(R.string.value_requestPeople_everyoneIncludingYou) - NewRequestDraft.People.EveryoneExceptYou -> stringResource(R.string.value_requestPeople_everyoneExceptYou) - is NewRequestDraft.People.SomeIncludingYou -> - pluralStringResource(R.plurals.value_requestPeople_someIncludingYou, people.others, people.others) - is NewRequestDraft.People.Some -> pluralStringResource(R.plurals.value_requestPeople_some, people.count, people.count) -} - @Composable private fun deadlineValue(deadline: NewRequestDraft.Deadline): String = when (deadline) { is NewRequestDraft.Deadline.Preset -> 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 index d4744325c2..9af3ca5ce5 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -18,10 +19,15 @@ 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 @@ -181,10 +187,8 @@ internal class RequestMoneyViewModel @Inject constructor( val withMember = if (member in draft.members) draft else draft.copy(members = draft.members + listOf(member)) val selected = withMember.toggled(member) if (selected == null) { - BottomBarManager.showInfo( - title = resources.getString(R.string.title_requestMaxPeople), - message = resources.getString(R.string.description_requestMaxPeople), - ) + val (title, message) = maxPeopleNotice(resources) + BottomBarManager.showInfo(title = title, message = message) return } _state.update { it.copy(draft = withMember.copy(selected = selected)) } @@ -253,14 +257,32 @@ internal class RequestMoneyViewModel @Inject constructor( requests.create(split, key).getOrElse { return@launch failed(it, chatId) }.id } _state.update { it.copy(draft = it.draft?.copy(createdRequestId = requestId)) } - // A failed send stays in the transcript as a pending message with its own retry, which - // resends the same request id. Create is never called again for this confirm. + // 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) { @@ -272,8 +294,10 @@ internal class RequestMoneyViewModel @Inject constructor( } 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) - R.string.error_description_requestGeneric + return ErrorUtils.showNetworkError(resources) } } BottomBarManager.showError( 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 9da832cf6c..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 @@ -248,9 +248,12 @@ class ChatGroupCashLinkTest { vm.dispatchEvent(ChatViewModel.Event.OnSendCash) advanceUntilIdle() - val actions = BottomBarManager.messages.value.single().actions - assertTrue(actions.any { it.testTag == "money_action_send" }) - assertTrue(ChatViewModel.Event.NavigateToAmountEntry !in events) + 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() 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<UserProfile>(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 index a339850c4b..78cb7651b0 100644 --- 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 @@ -81,4 +81,10 @@ class RequestDetailsRulesTest { 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/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 index e692175851..dc0a23f40a 100644 --- 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 @@ -1,5 +1,7 @@ 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 @@ -22,7 +24,6 @@ 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.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -60,13 +61,15 @@ fun rememberMinuteClock(): Instant { fun Fiat.requestText(): String = inDisplayCurrency().formatted(Fiat.FormattingRule.Truncated) @Composable -fun StatusLine.text(): String = when (this) { - is StatusLine.DaysLeft -> pluralStringResource(R.plurals.label_requestDaysLeft, days, days) - is StatusLine.HoursLeft -> pluralStringResource(R.plurals.label_requestHoursLeft, hours, hours) - is StatusLine.MinutesLeft -> pluralStringResource(R.plurals.label_requestMinutesLeft, minutes, minutes) - StatusLine.PaidInFull -> stringResource(R.string.label_requestPaidInFull) - StatusLine.Closed -> stringResource(R.string.label_requestClosed) - StatusLine.Expired -> stringResource(R.string.label_requestExpired) +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". */ 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 11ca64f020..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 @@ -517,8 +517,9 @@ interface MessagingOperations { /** * Posts the widget for payment request [requestId] to [chatId], once the request exists. A - * failed send leaves a pending row the transcript retries like any message, so the request is - * never created twice for one post. + * 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<ChatMessage> @@ -687,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/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 637eaaead9..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 @@ -59,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 @@ -74,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, @@ -484,7 +488,17 @@ class MessagingDelegate @Inject constructor( ?: 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) - return send(chatId, payload, clientMessageId) + 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<MessageContent>): Result<ChatMessage> { @@ -811,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<PaymentRequestNotSent>(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 index 773921ea30..6c1eaa49e6 100644 --- 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 @@ -57,4 +57,19 @@ class PaymentRequestsMergeTest { 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/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 index 75fe497ec0..bdcbfd099b 100644 --- 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 @@ -118,7 +118,7 @@ class PaymentRequestTest { assertEquals(StatusLine.HoursLeft(24), request(endsIn = 23.hours + 10.minutes).statusLine(now)) @Test - fun `exactly 24 hours left reads 24 hours, not 1 day`() = + fun `22 - exactly 24 hours left reads 24 hours, not 1 day`() = assertEquals(StatusLine.HoursLeft(24), request(endsIn = 24.hours).statusLine(now)) @Test From ca1dbccb3f2b8ab35e7a4aef41aa76759a362cbd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 11:09:51 -0400 Subject: [PATCH 15/47] fix(payments): log a request share's intent id in full only once paid The pay delegate holds the server's payment_intent_id in LiveSecrets before it submits, so the SubmitIntent body and trace show only its last 6 characters. A failed payment logs that suffix and keeps the id held for the retry. A successful one releases the id and logs it in full. --- .../request/PaymentRequestPaymentDelegate.kt | 26 ++++++++++++- .../PaymentRequestPaymentDelegateTest.kt | 37 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) 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 index 31b3ccae17..f8c4a91718 100644 --- 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 @@ -16,6 +16,11 @@ 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 @@ -44,6 +49,12 @@ class PaymentRequestPaymentDelegate @Inject constructor( 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<ShareToPay> = paymentRequests.fetch(requestId).map { details -> @@ -68,14 +79,25 @@ class PaymentRequestPaymentDelegate @Inject constructor( ?: 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 = PublicKey(ready.intentId.bytes.toList()), - ).onSuccess { paymentRequests.fetch(request.id) } + 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) + } } } 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 index f324c28599..3d77db7594 100644 --- 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 @@ -26,6 +26,8 @@ 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 @@ -37,6 +39,7 @@ 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 @@ -159,4 +162,38 @@ class PaymentRequestPaymentDelegateTest { assertContentEquals(buildRequestedPaymentMetadata(chatId, requestId.toByteArray()), metadata.captured) coVerify(exactly = 2) { paymentRequests.fetch(requestId) } } + + private suspend fun payWith(outcome: Result<Unit>): List<String> { + 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<LocalFiat>(relaxed = true), state)) + coEvery { + transactionController.directTransfer(any(), token, source, creatorKey, any(), any(), any()) + } returns outcome.map { mockk() } + val lines = mutableListOf<String>() + 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) + } } From 8d3719e3168f635efe6492b5b9798092cbcb9758 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 14:02:01 -0400 Subject: [PATCH 16/47] fix(messenger): give Request Money's screens their own nav host Request Money swapped its content in place between history, amount, summary and the editors, so none of them slid in. Each stage is now a step on the screen's own FlowHost, the way group send hosts Select People since dc3692f8e. The view model's stage still decides which step is on top, and back goes through the view model so the stack follows the stage. The deadline and confirm sheets stay drawn over Summary. --- .../flipcash/app/messenger/ChatFlowScreen.kt | 1 + .../internal/request/RequestMoneyScreen.kt | 153 ++++++++++++------ .../messenger/internal/request/RequestStep.kt | 65 ++++++++ .../internal/request/RequestStepTest.kt | 32 ++++ 4 files changed, 199 insertions(+), 52 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestStep.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/RequestStepTest.kt 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 05547514c1..e365d32a67 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 @@ -227,6 +227,7 @@ private fun chatEntryProvider( val navigator = LocalCodeNavigator.current RequestMoneyScreen( chatId = step.chatId, + resultStateRegistry = resultStateRegistry, onClose = { navigator.pop() }, onOpenRequest = { request -> navigator.push(ChatStep.RequestDetails(step.chatId, request.id.base58)) }, ) 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 index d190023110..bc5b0e242f 100644 --- 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 @@ -4,7 +4,13 @@ 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.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -85,14 +91,17 @@ import kotlin.time.toKotlinInstant /** * 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. + * 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, onOpenRequest: (PaymentRequest) -> Unit, ) { + // Read here, not inside an entry: an entry's hiltViewModel would be scoped to that step. val viewModel = hiltViewModel<RequestMoneyViewModel>() val state by viewModel.state.collectAsStateWithLifecycle() LaunchedEffect(chatId) { viewModel.open(chatId) } @@ -104,62 +113,102 @@ internal fun RequestMoneyScreen( } } val back = { if (!viewModel.back()) onClose() } - BackHandler(enabled = state.stage != RequestMoneyViewModel.Stage.History) { back() } + val target = stackFor(state.stage) - when (state.stage) { - RequestMoneyViewModel.Stage.History -> { - val requests by viewModel.chatRequests.collectAsStateWithLifecycle() - RequestHistoryContent( - requests = requests, - loadingMore = state.loadingPrevious, - canLoadMore = !state.previousEnd, - onBack = onClose, - onNewRequest = viewModel::newRequest, - onOpen = onOpenRequest, - onLoadMore = viewModel::loadMorePrevious, - ) - } - RequestMoneyViewModel.Stage.Amount -> AmountEntryScreen( - controller = viewModel.amountDelegate, - onConfirm = viewModel::amountEntered, - largeHeader = true, - appBar = { - AppBarWithTitle( - title = stringResource(R.string.title_amountPerPerson), - titleAlignment = Alignment.CenterHorizontally, - onBackIconClicked = back, + FlowHost<RequestStep, Parcelable>( + initialStack = target, + resultStateRegistry = resultStateRegistry, + onExit = { _, _ -> onClose() }, + entryProvider = entryProvider { + annotatedEntry<RequestStep.History> { + FollowStage(RequestStep.History, target) + val requests by viewModel.chatRequests.collectAsStateWithLifecycle() + RequestHistoryContent( + requests = requests, + loadingMore = state.loadingPrevious, + canLoadMore = !state.previousEnd, + onBack = onClose, + onNewRequest = viewModel::newRequest, + onOpen = onOpenRequest, + onLoadMore = viewModel::loadMorePrevious, ) - }, - ) - RequestMoneyViewModel.Stage.Summary -> 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, - ) - } - RequestMoneyViewModel.Stage.People -> 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, - ) - } - RequestMoneyViewModel.Stage.Name -> state.draft?.let { draft -> - RequestNameContent(title = draft.title, onBack = back, onDone = viewModel::rename) + } + annotatedEntry<RequestStep.Amount> { + 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<RequestStep.Summary> { + 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<RequestStep.People> { + 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<RequestStep.Name> { + 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<RequestStep>, onBack: (() -> Unit)? = null) { + val flowNavigator = rememberFlowNavigator<RequestStep, Parcelable>() + 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 -> 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..73bdbdfc4a --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestStep.kt @@ -0,0 +1,65 @@ +package com.flipcash.app.messenger.internal.request + +import android.os.Parcelable +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. + */ +internal sealed interface RequestStep : FlowStep, Parcelable { + @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 +} + +/** The back stack that puts [stage] on top: History, then Amount, then Summary and its editors. */ +internal fun stackFor(stage: RequestMoneyViewModel.Stage): List<RequestStep> { + 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<RequestStep>): 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/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))) + } +} From 7202e0dec255670658422df539abecb55da8ae03 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 14:16:50 -0400 Subject: [PATCH 17/47] fix(messenger): present Request Money and Request details as full sheets with a close Both now open the way the share sheet does: a fullscreen sheet whose root shows a close X. Drag-to-dismiss is off on both, so the close is the only way out and a half-made request can't be swiped away. --- .../com/flipcash/app/core/chat/ChatStep.kt | 15 ++++++++++----- .../com/flipcash/app/messenger/ChatFlowScreen.kt | 16 ++++++++++++++-- .../messenger/internal/request/RequestStep.kt | 6 +++++- 3 files changed, 29 insertions(+), 8 deletions(-) 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 a6ffa7aaf6..a7f2bbb3c8 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 @@ -191,20 +192,24 @@ sealed interface ChatStep : FlowStep, Parcelable { */ /** * Frames 03–06 — a private group's Request Money history, and the create flow behind its New - * Request button. A pushed screen: it is somewhere the viewer goes, with its own back stack of - * editors, rather than a choice made over the conversation. + * 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 + 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. + * 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. */ @Parcelize @Serializable - data class RequestDetails(val chatId: ChatId, val requestId: String) : ChatStep + data class RequestDetails(val chatId: ChatId, val requestId: String) : + ChatStep, + Sheet, + FullscreenSheet @Parcelize @Serializable 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 e365d32a67..2d04c8275d 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,6 +5,7 @@ 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 @@ -225,20 +226,31 @@ private fun chatEntryProvider( annotatedEntry<ChatStep.RequestMoney> { step -> val navigator = LocalCodeNavigator.current + val dismissSheet = LocalBottomSheetDismissDispatcher.current RequestMoneyScreen( chatId = step.chatId, resultStateRegistry = resultStateRegistry, - onClose = { navigator.pop() }, + onClose = { dismissSheet() }, onOpenRequest = { request -> navigator.push(ChatStep.RequestDetails(step.chatId, request.id.base58)) }, ) } annotatedEntry<ChatStep.RequestDetails> { 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 } + } RequestDetailsScreen( chatId = step.chatId, requestId = step.requestId, - onBack = { navigator.pop() }, + onBack = { dismissSheet() }, onOpenScreen = { route -> navigator.navigate(route) }, ) } 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 index 73bdbdfc4a..72c67c26a9 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -9,8 +10,11 @@ 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 { +internal sealed interface RequestStep : FlowStep, Parcelable, NonDraggableRoute { @Parcelize @Serializable data object History : RequestStep From 7162075f29fa5f52c689c35e2e373ca276b4d1af Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 14:27:42 -0400 Subject: [PATCH 18/47] fix(messenger): close Request Money and Request details with an X, as the share sheet does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are sheets now, so the history root's back arrow becomes the share sheet's close at the end of the app bar. Request details keeps its ••• beside the close. --- .../internal/request/RequestDetailsScreen.kt | 26 +++++++++++-------- .../internal/request/RequestMoneyScreen.kt | 4 ++- 2 files changed, 18 insertions(+), 12 deletions(-) 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 index 6abfa34dd4..f9ea52e78c 100644 --- 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 @@ -64,6 +64,7 @@ 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.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.SlideToConfirm import com.getcode.ui.theme.ButtonState @@ -115,19 +116,22 @@ internal fun RequestDetailsScreen( AppBarWithTitle( title = stringResource(R.string.title_requestDetails), titleAlignment = Alignment.CenterHorizontally, - onBackIconClicked = onBack, endContent = { - if (isCreatorOnOpen) { - IconButton( - modifier = Modifier.testTag("request_details_options"), - onClick = viewModel::showOptions, - ) { - Icon( - imageVector = Icons.Filled.MoreHoriz, - contentDescription = stringResource(R.string.cd_requestOptions), - tint = CodeTheme.colors.textMain, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + if (isCreatorOnOpen) { + IconButton( + modifier = Modifier.testTag("request_details_options"), + onClick = viewModel::showOptions, + ) { + Icon( + imageVector = Icons.Filled.MoreHoriz, + contentDescription = stringResource(R.string.cd_requestOptions), + tint = CodeTheme.colors.textMain, + ) + } } + // Its own sheet, closed the way the share sheet is. + AppBarDefaults.Close(onClick = onBack) } }, ) 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 index bc5b0e242f..d6d9b0e3c9 100644 --- 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 @@ -75,6 +75,7 @@ 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 @@ -247,7 +248,8 @@ private fun RequestHistoryContent( AppBarWithTitle( title = stringResource(R.string.title_requestMoneyHistory), titleAlignment = Alignment.CenterHorizontally, - onBackIconClicked = onBack, + // The sheet's root, closed the way the share sheet is. + endContent = { AppBarDefaults.Close(onClick = onBack) }, ) }, ) { padding -> From 0c570adf11047beba5c7437fc9d26c5c0910d7f5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 18:30:07 -0400 Subject: [PATCH 19/47] fix(messenger): collapse the deck from its scrim, share each card piece, push details from history Expanded deck: tapping the scrim collapses it. Title, ring, amounts and status are each their own shared element between the pill and the first expanded card. Request details' options use the haze circle button. Deadline date and time pickers take the app's colors and type. A history row pushes Request details on Request Money's own host. --- .../flipcash/app/messenger/ChatFlowScreen.kt | 9 +- .../messenger/internal/request/RequestDeck.kt | 69 +++++++++++++-- .../internal/request/RequestDetailsScreen.kt | 24 ++++-- .../internal/request/RequestMoneyScreen.kt | 85 ++++++++++++++++++- .../messenger/internal/request/RequestStep.kt | 5 ++ .../chat/ui/request/PaymentRequestUi.kt | 9 +- 6 files changed, 179 insertions(+), 22 deletions(-) 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 2d04c8275d..9d831eb329 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 @@ -202,6 +202,13 @@ private fun chatEntryProvider( val viewModel = flowSharedViewModel<ChatViewModel>() 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, @@ -231,7 +238,7 @@ private fun chatEntryProvider( chatId = step.chatId, resultStateRegistry = resultStateRegistry, onClose = { dismissSheet() }, - onOpenRequest = { request -> navigator.push(ChatStep.RequestDetails(step.chatId, request.id.base58)) }, + onOpenScreen = { route -> navigator.navigate(route) }, ) } 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 index 81fa9e5558..6c41933b16 100644 --- 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 @@ -6,6 +6,7 @@ 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.Spring import androidx.compose.animation.core.spring @@ -34,6 +35,7 @@ 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 @@ -100,7 +102,40 @@ internal fun RequestDeckLayout(modifier: Modifier = Modifier, content: @Composab internal val PaymentRequest.deckTitle: String? get() = (split?.text as? PaymentRequestText.Plain)?.title -private fun sharedKey(id: ID) = "payment-request-${id.hashCode()}" +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 + 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(), + ) + } + } +} /** * The app-bar deck (concept B): the front request with up to two cards peeking beneath it. Swipe @@ -162,11 +197,12 @@ internal fun RequestDeckPill( DeckFront( request = front, now = now, + visibility = this@AnimatedVisibility, modifier = Modifier .then( if (transition != null) with(transition) { Modifier.sharedBounds( - sharedContentState = rememberSharedContentState(sharedKey(front.id)), + sharedContentState = rememberSharedContentState(sharedKey(front.id, "card")), animatedVisibilityScope = this@AnimatedVisibility, clipInOverlayDuringTransition = OverlayClip(PILL_SHAPE), ) @@ -199,7 +235,12 @@ internal fun RequestDeckPill( } @Composable -private fun DeckFront(request: PaymentRequest, now: Instant, modifier: Modifier = Modifier) { +private fun DeckFront( + request: PaymentRequest, + now: Instant, + visibility: AnimatedVisibilityScope, + modifier: Modifier = Modifier, +) { val ended = !request.isOpen(now) val split = request.split Row( @@ -213,7 +254,12 @@ private fun DeckFront(request: PaymentRequest, now: Instant, modifier: Modifier verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { - RequestRing(request = request, now = now, size = 26.dp) + RequestRing( + request = request, + now = now, + size = 26.dp, + modifier = Modifier.deckShared(request.id, "glyph", visibility, CircleShape), + ) Column { Text( text = request.deckTitle.orEmpty(), @@ -221,6 +267,7 @@ private fun DeckFront(request: PaymentRequest, now: Instant, modifier: Modifier color = CodeTheme.colors.textMain, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.deckShared(request.id, "title", visibility), ) if (split != null) { Text( @@ -232,6 +279,7 @@ private fun DeckFront(request: PaymentRequest, now: Instant, modifier: Modifier style = CodeTheme.typography.caption, color = CodeTheme.colors.textSecondary, maxLines = 1, + modifier = Modifier.deckShared(request.id, "amounts", visibility), ) } } @@ -340,7 +388,7 @@ internal fun ExpandedRequests( .then( if (index == 0 && transition != null) with(transition) { Modifier.sharedBounds( - sharedContentState = rememberSharedContentState(sharedKey(request.id)), + sharedContentState = rememberSharedContentState(sharedKey(request.id, "card")), animatedVisibilityScope = visibility, clipInOverlayDuringTransition = OverlayClip(CARD_SHAPE), ) @@ -359,7 +407,16 @@ internal fun ExpandedRequests( .background(CodeTheme.colors.surfaceVariant, CARD_SHAPE) .clickable { onOpen(request) } .padding(14.dp) - PaymentRequestCardContent(request = request, title = title, now = now, modifier = card) + val front = index == 0 + 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, + ) } item(key = "handle") { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { 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 index f9ea52e78c..d8a687109c 100644 --- 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 @@ -20,7 +20,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState @@ -37,6 +36,8 @@ 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.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -65,6 +66,7 @@ import com.getcode.opencode.model.financial.Token import com.getcode.theme.CodeTheme import com.getcode.theme.White10 import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.CircularIconButton import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.SlideToConfirm import com.getcode.ui.theme.ButtonState @@ -83,6 +85,7 @@ internal fun RequestDetailsScreen( requestId: String, onBack: () -> Unit, onOpenScreen: (AppRoute) -> Unit, + pushed: Boolean = false, ) { val viewModel = hiltViewModel<RequestDetailsViewModel>() LaunchedEffect(chatId, requestId) { viewModel.open(chatId, requestId) } @@ -116,22 +119,27 @@ internal fun RequestDetailsScreen( 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 = { Row(verticalAlignment = Alignment.CenterVertically) { if (isCreatorOnOpen) { - IconButton( - modifier = Modifier.testTag("request_details_options"), + val optionsLabel = stringResource(R.string.cd_requestOptions) + CircularIconButton( + modifier = Modifier.semantics { contentDescription = optionsLabel }, onClick = viewModel::showOptions, - ) { + testTag = "request_details_options", + ) { size -> Icon( imageVector = Icons.Filled.MoreHoriz, - contentDescription = stringResource(R.string.cd_requestOptions), - tint = CodeTheme.colors.textMain, + contentDescription = null, + tint = Color.White, + modifier = Modifier.requiredSize(size), ) } } - // Its own sheet, closed the way the share sheet is. - AppBarDefaults.Close(onClick = onBack) + if (!pushed) AppBarDefaults.Close(onClick = onBack) } }, ) 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 index d6d9b0e3c9..9e5e21463b 100644 --- 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 @@ -32,11 +32,14 @@ import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SelectableDates import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TimePicker +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTimePickerState @@ -50,6 +53,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.testTag @@ -69,7 +73,9 @@ import com.flipcash.app.core.ui.DisplayTextInput import com.flipcash.app.messenger.internal.screens.cash.GroupSelectPeopleContent 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 @@ -100,7 +106,7 @@ internal fun RequestMoneyScreen( chatId: ChatId, resultStateRegistry: NavResultStateRegistry, onClose: () -> Unit, - onOpenRequest: (PaymentRequest) -> Unit, + onOpenScreen: (AppRoute) -> Unit, ) { // Read here, not inside an entry: an entry's hiltViewModel would be scoped to that step. val viewModel = hiltViewModel<RequestMoneyViewModel>() @@ -123,6 +129,7 @@ internal fun RequestMoneyScreen( entryProvider = entryProvider { annotatedEntry<RequestStep.History> { FollowStage(RequestStep.History, target) + val flowNavigator = rememberFlowNavigator<RequestStep, Parcelable>() val requests by viewModel.chatRequests.collectAsStateWithLifecycle() RequestHistoryContent( requests = requests, @@ -130,7 +137,7 @@ internal fun RequestMoneyScreen( canLoadMore = !state.previousEnd, onBack = onClose, onNewRequest = viewModel::newRequest, - onOpen = onOpenRequest, + onOpen = { flowNavigator.navigateTo(RequestStep.Details(it.id.base58)) }, onLoadMore = viewModel::loadMorePrevious, ) } @@ -180,6 +187,16 @@ internal fun RequestMoneyScreen( ) } } + annotatedEntry<RequestStep.Details> { step -> + val flowNavigator = rememberFlowNavigator<RequestStep, Parcelable>() + RequestDetailsScreen( + chatId = chatId, + requestId = step.requestId, + onBack = { flowNavigator.back() }, + onOpenScreen = onOpenScreen, + pushed = true, + ) + } annotatedEntry<RequestStep.Name> { FollowStage(RequestStep.Name, target, onBack = back) state.draft?.let { draft -> @@ -590,7 +607,13 @@ private fun DeadlineRow(label: String, checked: Boolean, onClick: () -> Unit) { */ @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun CustomDeadlinePicker(initial: Instant?, onPicked: (Instant) -> Unit, onDismiss: () -> Unit) { +private fun CustomDeadlinePicker(initial: Instant?, onPicked: (Instant) -> Unit, onDismiss: () -> Unit) = PickerTheme { + CustomDeadlinePickerContent(initial, onPicked, onDismiss) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CustomDeadlinePickerContent(initial: Instant?, onPicked: (Instant) -> Unit, onDismiss: () -> Unit) { val range = remember { NewRequestDraft.Deadline.customRange(Clock.System.now()) } val zone = ZoneId.systemDefault() val start = remember { (initial ?: range.start).toJavaInstant().atZone(zone) } @@ -626,7 +649,9 @@ private fun CustomDeadlinePicker(initial: Instant?, onPicked: (Instant) -> Unit, val timeState = rememberTimePickerState(initialHour = start.hour, initialMinute = start.minute) Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = true)) { Column( - modifier = Modifier.padding(CodeTheme.dimens.inset), + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceContainerHigh, CodeTheme.shapes.large) + .padding(CodeTheme.dimens.inset), horizontalAlignment = Alignment.CenterHorizontally, ) { TimePicker(state = timeState) @@ -643,6 +668,58 @@ private fun CustomDeadlinePicker(initial: Instant?, onPicked: (Instant) -> Unit, } } +/** + * 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.surfaceVariant, + onPrimaryContainer = colors.textMain, + secondaryContainer = colors.surfaceVariant, + onSecondaryContainer = colors.textMain, + tertiaryContainer = colors.surfaceVariant, + onTertiaryContainer = colors.textMain, + background = colors.background, + onBackground = colors.textMain, + surface = colors.background, + onSurface = colors.textMain, + surfaceVariant = colors.surfaceVariant, + onSurfaceVariant = colors.textSecondary, + surfaceContainer = colors.background, + surfaceContainerLow = colors.background, + surfaceContainerHigh = colors.background, + surfaceContainerHighest = colors.surfaceVariant, + 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, + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ConfirmSendSheet(sending: Boolean, onSend: () -> Unit, onDismiss: () -> Unit) { 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 index 72c67c26a9..2de466acec 100644 --- 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 @@ -34,6 +34,11 @@ internal sealed interface RequestStep : FlowStep, Parcelable, NonDraggableRoute @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. */ 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 index dc0a23f40a..32c43594cf 100644 --- 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 @@ -111,6 +111,9 @@ fun PaymentRequestCardContent( title: String?, now: Instant, modifier: Modifier = Modifier, + glyphModifier: Modifier = Modifier, + titleModifier: Modifier = Modifier, + amountsModifier: Modifier = Modifier, ) { val loading = request == null || title == null val ended = request != null && !request.isOpen(now) @@ -123,7 +126,7 @@ fun PaymentRequestCardContent( horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Box( - modifier = Modifier + modifier = glyphModifier .size(34.dp) .background(if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success, CircleShape), contentAlignment = Alignment.Center, @@ -142,7 +145,7 @@ fun PaymentRequestCardContent( color = CodeTheme.colors.textMain, maxLines = 2, overflow = TextOverflow.Ellipsis, - modifier = if (loading) Modifier.width(120.dp).shimmer(RoundedCornerShape(4.dp)) else Modifier, + modifier = if (loading) Modifier.width(120.dp).shimmer(RoundedCornerShape(4.dp)) else titleModifier, ) } } @@ -150,7 +153,7 @@ fun PaymentRequestCardContent( 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 Modifier, + modifier = if (loading) Modifier.width(160.dp).shimmer(RoundedCornerShape(4.dp)) else amountsModifier, ) RequestProgressBar(fraction = request?.split?.paidFraction ?: 0f, ended = ended) Text( From 4c3fe87cd6cd040d6ceff8382a65171ba7c61d6c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 18:43:28 -0400 Subject: [PATCH 20/47] fix(messenger): edit a request's name in the group name editor The request's Name step now renders through NameEditScaffold, the layout EditGroupNameScreen uses, so both edits share the heading, field, counter and keyboard handling. Done stays the request's button. --- .../internal/request/RequestMoneyScreen.kt | 61 +++------ .../profile/edit/EditGroupNameScreen.kt | 112 ++-------------- .../screens/profile/edit/NameEditScaffold.kt | 121 ++++++++++++++++++ 3 files changed, 151 insertions(+), 143 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/NameEditScaffold.kt 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 index 9e5e21463b..a67205ccf7 100644 --- 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 @@ -46,6 +46,7 @@ import androidx.compose.material3.rememberTimePickerState 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.saveable.rememberSaveable @@ -71,6 +72,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.ui.DisplayTextInput 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 @@ -476,61 +478,32 @@ private fun deadlineValue(deadline: NewRequestDraft.Deadline): String = when (de } } +/** 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) - val focusRequester = remember { FocusRequester() } - val trimmed = field.text.trim() - var valid by remember { mutableStateOf(trimmed.isNotEmpty()) } + 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() } } - LaunchedEffect(Unit) { focusRequester.requestFocus() } - Column { - AppBarWithTitle(onBackIconClicked = onBack) - CodeScaffold( - modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), - topBar = { - Text( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2, bottom = CodeTheme.dimens.inset), - text = stringResource(R.string.label_requestName), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - ) - }, - bottomBar = { - CodeButton( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .imePadding() - .padding(bottom = CodeTheme.dimens.grid.x3), - text = stringResource(R.string.action_done), - enabled = valid, - onClick = { onDone(field.text.toString()) }, - ) - }, - ) { padding -> - DisplayTextInput( - state = field, - placeholder = stringResource(R.string.label_requestName), - modifier = Modifier - .padding(padding) - .fillMaxWidth() - .focusRequester(focusRequester), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Sentences, - imeAction = ImeAction.Done, - ), - onKeyboardAction = { if (valid) onDone(field.text.toString()) }, - ) - } - } + NameEditScaffold( + heading = stringResource(R.string.label_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) 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<EditGroupNameViewModel>() 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() } + } + } +} From ebb423e5009348da67cb89c769dba5a22c992e2a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 18:52:34 -0400 Subject: [PATCH 21/47] fix(messenger): ask what the request is for on its Name screen The heading reads "What is the request for?" from its own string; the summary row keeps "Name". --- apps/flipcash/core/src/main/res/values/strings.xml | 1 + .../app/messenger/internal/request/RequestMoneyScreen.kt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index df74d92b1c..4c83390a4d 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1032,6 +1032,7 @@ <string name="title_newRequest">New Request</string> <string name="label_perPerson">per person</string> <string name="label_requestName">Name</string> + <string name="title_requestName">What is the request for?</string> <string name="label_requestPeople">People</string> <string name="label_requestTiming">Timing</string> <plurals name="label_requestDaysLeft"> 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 index a67205ccf7..e499035023 100644 --- 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 @@ -494,7 +494,7 @@ private fun RequestNameContent(title: String, onBack: () -> Unit, onDone: (Strin } } NameEditScaffold( - heading = stringResource(R.string.label_requestName), + heading = stringResource(R.string.title_requestName), placeholder = stringResource(R.string.label_requestName), field = field, buttonText = stringResource(R.string.action_done), From 27a7a959bc5bee2d7125bf0c2fbb9e1d30873abf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 19:11:19 -0400 Subject: [PATCH 22/47] fix(messenger): space the options and close buttons on request details --- .../app/messenger/internal/request/RequestDetailsScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index d8a687109c..b93dc0d4c3 100644 --- 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 @@ -123,7 +123,10 @@ internal fun RequestDetailsScreen( // sheet of its own, closed the way the share sheet is. onBackIconClicked = onBack.takeIf { pushed }, endContent = { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { if (isCreatorOnOpen) { val optionsLabel = stringResource(R.string.cd_requestOptions) CircularIconButton( From 1f4b44e39ae6148596e075a31b1af822923eebc1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 19:24:47 -0400 Subject: [PATCH 23/47] fix(messenger): fade the deck card's details in late and out first At 5x animator scale the expanded card's label, progress bar, deadline line and grab handle faded in from the first frame, squeezed into the pill's bounds over the title and amounts, and the handle outlived the card on collapse. They now fade in from about 60% of the card's ~300ms bounds spring and fade out in 90ms on collapse. --- .../messenger/internal/request/RequestDeck.kt | 21 ++++++++++++++++++- .../chat/ui/request/PaymentRequestUi.kt | 8 +++++-- 2 files changed, 26 insertions(+), 3 deletions(-) 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 index 6c41933b16..d0f719daa6 100644 --- 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 @@ -10,6 +10,7 @@ import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.sca import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically @@ -408,6 +409,7 @@ internal fun ExpandedRequests( .clickable { onOpen(request) } .padding(14.dp) val front = index == 0 + val detail = if (front) Modifier.expandedOnly(visibility) else Modifier PaymentRequestCardContent( request = request, title = title, @@ -416,12 +418,13 @@ internal fun ExpandedRequests( 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, ) } item(key = "handle") { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box( - modifier = Modifier + modifier = Modifier.expandedOnly(visibility) .size(width = 36.dp, height = 5.dp) .background(CodeTheme.colors.textSecondary, RoundedCornerShape(2.5.dp)) .clickable(onClick = onCollapse), @@ -432,6 +435,17 @@ internal fun ExpandedRequests( } } +/** + * 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 @@ -441,3 +455,8 @@ 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 + +// 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/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 index 32c43594cf..50db044fc9 100644 --- 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 @@ -104,6 +104,8 @@ fun RequestProgressBar(fraction: Float, ended: Boolean, modifier: Modifier = Mod * 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. */ @Composable fun PaymentRequestCardContent( @@ -114,6 +116,7 @@ fun PaymentRequestCardContent( glyphModifier: Modifier = Modifier, titleModifier: Modifier = Modifier, amountsModifier: Modifier = Modifier, + detailModifier: Modifier = Modifier, ) { val loading = request == null || title == null val ended = request != null && !request.isOpen(now) @@ -138,6 +141,7 @@ fun PaymentRequestCardContent( text = stringResource(R.string.label_paymentRequest_card), style = CodeTheme.typography.caption, color = CodeTheme.colors.textSecondary, + modifier = detailModifier, ) Text( text = title.orEmpty(), @@ -155,12 +159,12 @@ fun PaymentRequestCardContent( 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) + RequestProgressBar(fraction = request?.split?.paidFraction ?: 0f, ended = ended, modifier = detailModifier) 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 Modifier, + modifier = if (loading) Modifier.width(100.dp).shimmer(RoundedCornerShape(4.dp)) else detailModifier, ) } } From fa5f7b3fa2bb9c8c3a8c529e3e1527190707c6c7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 19:27:34 -0400 Subject: [PATCH 24/47] fix(messenger): take request card and deck spacing from the grid --- .../app/messenger/internal/request/RequestDeck.kt | 11 ++++++++--- .../com/flipcash/shared/chat/ui/MessageBubble.kt | 4 ++-- .../shared/chat/ui/request/PaymentRequestBubble.kt | 1 - .../shared/chat/ui/request/PaymentRequestUi.kt | 4 ++-- 4 files changed, 12 insertions(+), 8 deletions(-) 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 index d0f719daa6..ae4a2bb884 100644 --- 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 @@ -250,7 +250,12 @@ private fun DeckFront( .alpha(if (ended) ENDED_ALPHA else 1f) .background(CodeTheme.colors.surfaceVariant, PILL_SHAPE) .border(1.dp, CodeTheme.colors.border, PILL_SHAPE) - .padding(start = 5.dp, end = 12.dp, top = 5.dp, bottom = 5.dp) + .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), @@ -380,7 +385,7 @@ internal fun ExpandedRequests( horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2, ), - verticalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { itemsIndexed(deck, key = { _, request -> request.id.hashCode() }) { index, request -> val title = request.deckTitle @@ -407,7 +412,7 @@ internal fun ExpandedRequests( .alpha(if (request.isOpen(now)) 1f else ENDED_ALPHA) .background(CodeTheme.colors.surfaceVariant, CARD_SHAPE) .clickable { onOpen(request) } - .padding(14.dp) + .padding(CodeTheme.dimens.grid.x3) val front = index == 0 val detail = if (front) Modifier.expandedOnly(visibility) else Modifier PaymentRequestCardContent( 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 dd4604bfa6..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 @@ -407,8 +407,8 @@ fun ContentBubble( position = position, minWidth = width, maxWidth = width, - horizontalPadding = PaymentRequestBubbleDefaults.PADDING, - verticalPadding = PaymentRequestBubbleDefaults.PADDING, + 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 }, 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 index a6ccd59b51..73f67ec4e4 100644 --- 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 @@ -46,7 +46,6 @@ val LocalPaymentRequestCards = compositionLocalOf<PaymentRequestCards?> { null } /** Node 12254:1929: the card at its transcript width. */ internal object PaymentRequestBubbleDefaults { val WIDTH: Dp = 264.dp - val PADDING: Dp = 14.dp } /** Fetches [id] when the card comes on screen without it, which covers a widget scrolled into view. */ 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 index 50db044fc9..a9d1b2b92f 100644 --- 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 @@ -122,11 +122,11 @@ fun PaymentRequestCardContent( val ended = request != null && !request.isOpen(now) Column( modifier = modifier, - verticalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { Box( modifier = glyphModifier From 5950b1a9fd3464e5654321bf199d2fe50139dac3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 19:50:22 -0400 Subject: [PATCH 25/47] fix(messenger): fade the request deck in and out of the app bar --- .../internal/request/ChatRequestsViewModel.kt | 30 +++- .../messenger/internal/request/RequestDeck.kt | 165 ++++++++++-------- .../internal/screens/MessengerScreen.kt | 3 + 3 files changed, 119 insertions(+), 79 deletions(-) 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 index 02af063593..d77afba1fb 100644 --- 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 @@ -11,13 +11,11 @@ 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.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.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -57,18 +55,23 @@ internal class ChatRequestsViewModel @Inject constructor( val held: StateFlow<Map<ID, PaymentRequest>> = requests.state val missing: StateFlow<Set<ID>> = requests.missing + private val _deck = MutableStateFlow<List<PaymentRequest>>(emptyList()) + /** The deck, front card first. Empty hides it. */ - val deck: StateFlow<List<PaymentRequest>> = combine( - chatId.flatMapLatest { id -> id?.let { requests.inChat(it) } ?: flowOf(emptyList()) }, - _now, - cycled, - ) { inChat, now, cycled -> deckOrder(inChat, now).rotated(cycled) } - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + 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).rotated(cycled) } + .collect { _deck.value = it } + } viewModelScope.launch { while (true) { delay(1.minutes) @@ -77,6 +80,17 @@ internal class ChatRequestsViewModel @Inject constructor( } } + /** + * 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).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. 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 index ae4a2bb884..fa71c8b090 100644 --- 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 @@ -11,8 +11,12 @@ import androidx.compose.animation.core.Animatable 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.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.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Canvas @@ -42,8 +46,11 @@ 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.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -152,85 +159,99 @@ internal fun RequestDeckPill( onCycle: () -> Unit, modifier: Modifier = Modifier, ) { - val front = deck.firstOrNull() + // 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 = front != null && !expanded, - enter = fadeIn(), - exit = fadeOut(), + 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, ) { - front ?: return@AnimatedVisibility - val transition = LocalDeckTransition.current - val density = LocalDensity.current - val scope = rememberCoroutineScope() - val drag = remember { Animatable(0f) } - val pillHeightPx = with(density) { PILL_HEIGHT.toPx() } - val canCycle = deck.size > 1 - - val label = deckLabel(deck, now) - val nextLabel = stringResource(R.string.action_nextRequest) - Box( - modifier = Modifier - .padding(bottom = PEEK_STEP * 2) - .clearAndSetSemantics { - contentDescription = label - role = Role.Button - onClick { onExpand(); true } - if (canCycle) customActions = listOf(CustomAccessibilityAction(nextLabel) { onCycle(); true }) - }, + AnimatedVisibility( + visible = !expanded, + enter = fadeIn(), + exit = fadeOut(), ) { - // The peeking cards, back first. Widths step in so the front card's edges stay clear. - deck.drop(1).take(2).withIndex().reversed().forEach { (index, _) -> - val depth = index + 1 - Box( + val front = shown.firstOrNull() ?: return@AnimatedVisibility + val transition = LocalDeckTransition.current + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val drag = remember { Animatable(0f) } + val pillHeightPx = with(density) { PILL_HEIGHT.toPx() } + val canCycle = shown.size > 1 + + val label = deckLabel(shown, now) + val nextLabel = stringResource(R.string.action_nextRequest) + Box( + modifier = Modifier + .padding(bottom = PEEK_STEP * 2) + .clearAndSetSemantics { + contentDescription = label + role = Role.Button + onClick { onExpand(); true } + if (canCycle) customActions = listOf(CustomAccessibilityAction(nextLabel) { onCycle(); true }) + }, + ) { + // The peeking cards, back first. Widths step in so the front card's edges stay clear. + shown.drop(1).take(2).withIndex().reversed().forEach { (index, _) -> + val depth = index + 1 + Box( + modifier = Modifier + .matchParentSize() + .padding(horizontal = PEEK_INSET * depth) + .offset(y = PEEK_STEP * depth) + .background(CodeTheme.colors.background, PILL_SHAPE) + .background( + if (depth == 1) CodeTheme.colors.surfaceVariant else CodeTheme.colors.surface, + PILL_SHAPE, + ) + .border(1.dp, CodeTheme.colors.border, PILL_SHAPE), + ) + } + DeckFront( + request = front, + now = now, + visibility = this@AnimatedVisibility, modifier = Modifier - .matchParentSize() - .padding(horizontal = PEEK_INSET * depth) - .offset(y = PEEK_STEP * depth) - .background(CodeTheme.colors.background, PILL_SHAPE) - .background( - if (depth == 1) CodeTheme.colors.surfaceVariant else CodeTheme.colors.surface, - PILL_SHAPE, + .then( + if (transition != null) with(transition) { + Modifier.sharedBounds( + sharedContentState = rememberSharedContentState(sharedKey(front.id, "card")), + animatedVisibilityScope = this@AnimatedVisibility, + clipInOverlayDuringTransition = OverlayClip(PILL_SHAPE), + ) + } else Modifier ) - .border(1.dp, CodeTheme.colors.border, PILL_SHAPE), + .offset { IntOffset(0, drag.value.roundToInt()) } + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { delta -> + scope.launch { + // One request: a small elastic pull and nothing more. + val limit = if (canCycle) pillHeightPx * 1.5f else pillHeightPx * 0.25f + drag.snapTo((drag.value + delta * if (canCycle) 1f else 0.3f).coerceIn(0f, limit)) + } + }, + onDragStopped = { velocity -> + val past = drag.value > pillHeightPx * CYCLE_THRESHOLD || velocity > FLING_VELOCITY + if (canCycle && past) { + onCycle() + drag.snapTo(0f) + } else { + drag.animateTo(0f, spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + } + }, + ) + .clickable(onClick = onExpand), ) } - DeckFront( - request = front, - now = now, - visibility = this@AnimatedVisibility, - modifier = Modifier - .then( - if (transition != null) with(transition) { - Modifier.sharedBounds( - sharedContentState = rememberSharedContentState(sharedKey(front.id, "card")), - animatedVisibilityScope = this@AnimatedVisibility, - clipInOverlayDuringTransition = OverlayClip(PILL_SHAPE), - ) - } else Modifier - ) - .offset { IntOffset(0, drag.value.roundToInt()) } - .draggable( - orientation = Orientation.Vertical, - state = rememberDraggableState { delta -> - scope.launch { - // One request: a small elastic pull and nothing more. - val limit = if (canCycle) pillHeightPx * 1.5f else pillHeightPx * 0.25f - drag.snapTo((drag.value + delta * if (canCycle) 1f else 0.3f).coerceIn(0f, limit)) - } - }, - onDragStopped = { velocity -> - val past = drag.value > pillHeightPx * CYCLE_THRESHOLD || velocity > FLING_VELOCITY - if (canCycle && past) { - onCycle() - drag.snapTo(0f) - } else { - drag.animateTo(0f, spring(dampingRatio = Spring.DampingRatioMediumBouncy)) - } - }, - ) - .clickable(onClick = onExpand), - ) } } } @@ -455,6 +476,8 @@ 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 const val APPEAR_MILLIS = 250 +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 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 b08eda17e2..1a24c69809 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 @@ -449,6 +449,9 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { PrefetchProfileCover(group.coverPicture, BlobAccessContext.ChatProfile(group.chatId)) } + // 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() From 9837dc17bab8e6c6ee93aa4636aa41fb353dee92 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 20:05:19 -0400 Subject: [PATCH 26/47] fix(messenger): put the expanded request deck over the long-press backdrop The expanded deck dimmed the chat with its own scrim, which stopped at the app bar. It now uses the blur and dim the long-press message menu uses, over the whole screen, the back button, avatar and title included. Tapping anywhere outside the cards collapses, including the gaps between them. The backdrop was hand-copied in three places (message rows, their reaction pills, the composer). It moves into a shared chatBackdrop modifier with the same 8dp blur and 0.4 alpha; the long-press menu should look and behave as before. --- .../messenger/internal/request/RequestDeck.kt | 7 ++- .../internal/screens/MessengerScreen.kt | 26 +++------- .../screens/components/ChatBackdrop.kt | 48 +++++++++++++++++++ .../internal/screens/components/MessageRow.kt | 27 +---------- 4 files changed, 62 insertions(+), 46 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBackdrop.kt 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 index fa71c8b090..794db432dd 100644 --- 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 @@ -376,12 +376,13 @@ internal fun ExpandedRequests( 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. AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { Box( modifier = Modifier .fillMaxSize() - .padding(top = top) - .background(CodeTheme.colors.scrim) .pointerInput(Unit) { detectTapGestures { onCollapse() } }, ) } @@ -397,6 +398,8 @@ internal fun ExpandedRequests( 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 { }, 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 1a24c69809..a92cc1d67e 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 @@ -6,8 +6,6 @@ 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 @@ -34,9 +32,6 @@ 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.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler @@ -64,6 +59,8 @@ import com.flipcash.app.messenger.internal.ChatViewModel 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 @@ -466,7 +463,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { 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, @@ -533,20 +533,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, 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<Float> = 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<Float> = + 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/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( From 960cd032e6871c948da59c49cf4474d24a29baad Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 20:33:04 -0400 Subject: [PATCH 27/47] fix(messenger): keep the request deck expanded under Request details Opening a card from the expanded deck collapsed the deck first, so the collapse ran behind the details screen as it came up. The deck now stays expanded, and back from details returns to the list as it was. The expanded deck drops its grab handle; drag up still collapses it. A close button takes the collapsed pill's place in the app bar, above the backdrop. It fades in with the backdrop and out first on collapse, so it is gone before the pill lands where it sits. --- .../messenger/internal/request/RequestDeck.kt | 45 +++++++++++++++---- .../internal/screens/MessengerScreen.kt | 13 +++++- 2 files changed, 49 insertions(+), 9 deletions(-) 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 index 794db432dd..65cde31c00 100644 --- 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 @@ -53,6 +53,11 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import com.getcode.ui.components.AppBarDefaults +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.layout +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.draw.alpha import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size @@ -372,6 +377,7 @@ internal fun ExpandedRequests( now: Instant, expanded: Boolean, top: Dp, + pill: Rect?, onCollapse: () -> Unit, onOpen: (PaymentRequest) -> Unit, ) { @@ -450,16 +456,39 @@ internal fun ExpandedRequests( detailModifier = detail, ) } - item(key = "handle") { - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - Box( - modifier = Modifier.expandedOnly(visibility) - .size(width = 36.dp, height = 5.dp) - .background(CodeTheme.colors.textSecondary, RoundedCornerShape(2.5.dp)) - .clickable(onClick = onCollapse), + } + } + // 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, ) } - } + }, + ) { + AppBarDefaults.Close(onClick = onCollapse) } } } 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 a92cc1d67e..d5c1d74b17 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 @@ -32,6 +32,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler @@ -454,12 +457,15 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { val deckNow by requestsViewModel.now.collectAsStateWithLifecycle() val drawnDeck = remember(deck) { deck.filter { it.deckTitle != null } } val openRequest: (com.flipcash.services.models.paymentrequest.PaymentRequest) -> Unit = { request -> + // The deck stays expanded under the details, as it was left: back returns to the list. state.chatId?.let { chatId -> - requestsViewModel.setExpanded(false) 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<Rect?>(null) } + RequestDeckLayout(modifier = Modifier.fillMaxSize()) { CodeScaffold( // The input bar rides the keyboard; the message list is inset by it either way. @@ -489,6 +495,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { expanded = deckExpanded, onExpand = { keyboard.hideIfVisible { requestsViewModel.setExpanded(true) } }, 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() + }, ) } }, @@ -624,6 +634,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { now = deckNow, expanded = deckExpanded, top = barHeight, + pill = deckPill, onCollapse = { requestsViewModel.setExpanded(false) }, onOpen = openRequest, ) From dac2a03123205993035c2aad4b00610e51a9f567 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 21:11:32 -0400 Subject: [PATCH 28/47] fix(messenger): combine Request details' options and close in one capsule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Request details showed "•••" and X as two separate circles; iOS shows them in one capsule. ActionCapsule in chat-ui has the photo viewer's pill shape, surface and spacing (#1854), so the viewer can switch to it once both land. StripSurface becomes internal for it, as #1854 also does. The deck's close X uses the same capsule; its placement is unchanged. --- .../core/src/main/res/values/strings.xml | 1 + .../messenger/internal/request/RequestDeck.kt | 15 ++++- .../internal/request/RequestDetailsScreen.kt | 43 +++++++------ .../flipcash/shared/chat/ui/ActionCapsule.kt | 60 +++++++++++++++++++ .../shared/chat/ui/QuickReactionStrip.kt | 2 +- 5 files changed, 96 insertions(+), 25 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ActionCapsule.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 4c83390a4d..3d873ae711 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1113,6 +1113,7 @@ <string name="label_shareUnpaidAmount">%1$s · Hasn\'t paid yet</string> <string name="action_sendShare">Send %1$s</string> <string name="cd_requestOptions">Request options</string> + <string name="cd_close">Close</string> <string name="action_closeRequest">Close Request</string> <string name="title_closeRequestConfirm">Close Request?</string> <string name="description_closeRequestConfirm">This will stop payments. Anyone who hasn\'t paid will be marked Didn\'t pay. Payments already made won\'t be affected.</string> 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 index 65cde31c00..da5c58698e 100644 --- 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 @@ -53,7 +53,10 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.getcode.ui.components.AppBarDefaults +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 @@ -488,7 +491,15 @@ private fun DeckCloseButton(expanded: Boolean, pill: Rect?, onCollapse: () -> Un } }, ) { - AppBarDefaults.Close(onClick = onCollapse) + // 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", + ) + } } } } 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 index b93dc0d4c3..e9adb82469 100644 --- 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 @@ -17,9 +17,11 @@ 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.Icon import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState @@ -36,8 +38,6 @@ 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.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -65,8 +65,6 @@ 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.AppBarDefaults -import com.getcode.ui.components.CircularIconButton import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.SlideToConfirm import com.getcode.ui.theme.ButtonState @@ -123,26 +121,27 @@ internal fun RequestDetailsScreen( // sheet of its own, closed the way the share sheet is. onBackIconClicked = onBack.takeIf { pushed }, endContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - ) { - if (isCreatorOnOpen) { - val optionsLabel = stringResource(R.string.cd_requestOptions) - CircularIconButton( - modifier = Modifier.semantics { contentDescription = optionsLabel }, - onClick = viewModel::showOptions, - testTag = "request_details_options", - ) { size -> - Icon( - imageVector = Icons.Filled.MoreHoriz, - contentDescription = null, - tint = Color.White, - modifier = Modifier.requiredSize(size), + // 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", ) } } - if (!pushed) AppBarDefaults.Close(onClick = onBack) } }, ) 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/QuickReactionStrip.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt index d322191f84..da2c9ea349 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 @@ -100,7 +100,7 @@ private val GrowSpring = spring<Float>(dampingRatio = 0.9f, stiffness = 900f) private val PopSpring = spring<Float>(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 { From c02bd6babf7c195f1eac8f51fc7c6b4b71b49d95 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 21:37:03 -0400 Subject: [PATCH 29/47] fix(messenger): minimize the deck once Request details covers the chat Opening a request from the expanded deck left the deck expanded under the sheet, so back returned to the expanded list. The Request details step now waits for its sheet to be fully up, then sets the deck minimized while it is hidden. Back shows the chat with the deck in the app bar and no collapse animation as the sheet leaves. ChatRequestsViewModel moves to the flow's store so the step can reach the chat's instance. --- .../com/flipcash/app/messenger/ChatFlowScreen.kt | 13 +++++++++++++ .../messenger/internal/screens/MessengerScreen.kt | 6 ++++-- 2 files changed, 17 insertions(+), 2 deletions(-) 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 9d831eb329..ed200813c8 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 @@ -12,6 +12,7 @@ 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 @@ -43,6 +44,7 @@ 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 @@ -85,6 +87,7 @@ 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 @@ -254,6 +257,16 @@ private fun chatEntryProvider( 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<ChatRequestsViewModel>() + 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, 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 d5c1d74b17..08896eb0cc 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 @@ -52,6 +52,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 @@ -438,7 +439,8 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } // The group's profile is one tap away; have its cover downloaded before that tap. - val requestsViewModel = hiltViewModel<ChatRequestsViewModel>() + // The flow's, so the Request details step can minimize the deck once it covers the chat. + val requestsViewModel = flowSharedViewModel<ChatRequestsViewModel>() val requestCards = rememberPaymentRequestCards( viewModel = requestsViewModel, chatId = state.chatId.takeIf { state.chatType == ChatType.GROUP }, @@ -457,7 +459,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { val deckNow by requestsViewModel.now.collectAsStateWithLifecycle() val drawnDeck = remember(deck) { deck.filter { it.deckTitle != null } } val openRequest: (com.flipcash.services.models.paymentrequest.PaymentRequest) -> Unit = { request -> - // The deck stays expanded under the details, as it was left: back returns to the list. + // 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)) } } From d4348fcbf0d15ebf0d7ed924e6270adbe432b04d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 21:55:35 -0400 Subject: [PATCH 30/47] fix(messenger): carry the next request up with the swipe through the deck The card behind now moves up with the front card as it is swiped and shows its request over the second half of the swipe, so the deck lands on a filled card instead of an empty slot. The swiped card fades in at the back, the pill eases between title lengths, and the press ripple is clipped to the pill. --- .../messenger/internal/request/RequestDeck.kt | 78 ++++++++++++++++--- 1 file changed, 69 insertions(+), 9 deletions(-) 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 index da5c58698e..f1e6dce4af 100644 --- 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 @@ -5,6 +5,7 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.animateContentSize import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.scaleToBounds import androidx.compose.animation.core.Animatable @@ -62,6 +63,8 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.layout 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.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color @@ -130,10 +133,11 @@ private fun sharedKey(id: ID, part: String) = "payment-request-${id.hashCode()}- private fun Modifier.deckShared( id: ID, part: String, - visibility: AnimatedVisibilityScope, + 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) { @@ -192,6 +196,7 @@ internal fun RequestDeckPill( val density = LocalDensity.current val scope = rememberCoroutineScope() val drag = remember { Animatable(0f) } + val arrive = remember { Animatable(1f) } val pillHeightPx = with(density) { PILL_HEIGHT.toPx() } val canCycle = shown.size > 1 @@ -207,21 +212,55 @@ internal fun RequestDeckPill( if (canCycle) customActions = listOf(CustomAccessibilityAction(nextLabel) { onCycle(); true }) }, ) { + // How far the front card is through being swiped to the back, 0 to 1. The cards behind + // move up a slot with it, so the next request is already in place when the swipe lands. + val progress = (drag.value / pillHeightPx).coerceIn(0f, 1f) + // Over the second half of the swipe the front card fades as the next one's text comes in. + val handover = if (canCycle) ((progress - 0.5f) * 2f).coerceIn(0f, 1f) else 0f // The peeking cards, back first. Widths step in so the front card's edges stay clear. - shown.drop(1).take(2).withIndex().reversed().forEach { (index, _) -> - val depth = index + 1 + val peeks = shown.drop(1).take(3) + peeks.withIndex().reversed().forEach { (index, request) -> + val depth = index + 1 - progress + if (depth > 2f) { + // A third card behind only shows as the others move up. + if (progress == 0f) return@forEach + } + val isBack = index == peeks.lastIndex Box( modifier = Modifier .matchParentSize() .padding(horizontal = PEEK_INSET * depth) .offset(y = PEEK_STEP * depth) + .graphicsLayer { + alpha = when { + depth > 2f -> 1f - (depth - 2f) + isBack -> arrive.value + else -> 1f + } + } .background(CodeTheme.colors.background, PILL_SHAPE) .background( - if (depth == 1) CodeTheme.colors.surfaceVariant else CodeTheme.colors.surface, + lerp( + CodeTheme.colors.surfaceVariant, + CodeTheme.colors.surface, + (depth - 1f).coerceIn(0f, 1f), + ), PILL_SHAPE, ) .border(1.dp, CodeTheme.colors.border, PILL_SHAPE), - ) + ) { + // The card moving up to the front shows its request as it arrives, so the + // swipe lands on a finished card rather than an empty one. + if (index == 0 && handover > 0f) { + DeckFront( + request = request, + now = now, + visibility = null, + contentAlpha = { handover }, + modifier = Modifier.fillMaxSize(), + ) + } + } } DeckFront( request = front, @@ -238,6 +277,11 @@ internal fun RequestDeckPill( } else Modifier ) .offset { IntOffset(0, drag.value.roundToInt()) } + .graphicsLayer { + alpha = 1f - handover + scaleX = 1f - 0.06f * handover + scaleY = scaleX + } .draggable( orientation = Orientation.Vertical, state = rememberDraggableState { delta -> @@ -250,13 +294,22 @@ internal fun RequestDeckPill( onDragStopped = { velocity -> val past = drag.value > pillHeightPx * CYCLE_THRESHOLD || velocity > FLING_VELOCITY if (canCycle && past) { + // Finish the swipe from where the finger left it, then hand over: + // the card that moved up becomes the front, and the one swiped away + // fades in at the back. + if (drag.value < pillHeightPx) { + drag.animateTo(pillHeightPx, tween(HANDOVER_MILLIS)) + } + arrive.snapTo(0f) onCycle() drag.snapTo(0f) + arrive.animateTo(1f, tween(ARRIVE_MILLIS)) } else { - drag.animateTo(0f, spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + drag.animateTo(0f, spring(stiffness = Spring.StiffnessMediumLow), initialVelocity = velocity) } }, ) + .clip(PILL_SHAPE) .clickable(onClick = onExpand), ) } @@ -268,13 +321,16 @@ internal fun RequestDeckPill( private fun DeckFront( request: PaymentRequest, now: Instant, - visibility: AnimatedVisibilityScope, + visibility: AnimatedVisibilityScope?, modifier: Modifier = Modifier, + contentAlpha: () -> Float = { 1f }, ) { val ended = !request.isOpen(now) val split = request.split Row( modifier = modifier + // Eases between requests of different lengths when the deck cycles. + .animateContentSize(spring(stiffness = Spring.StiffnessMediumLow)) .background(CodeTheme.colors.background, PILL_SHAPE) .alpha(if (ended) ENDED_ALPHA else 1f) .background(CodeTheme.colors.surfaceVariant, PILL_SHAPE) @@ -293,9 +349,11 @@ private fun DeckFront( request = request, now = now, size = 26.dp, - modifier = Modifier.deckShared(request.id, "glyph", visibility, CircleShape), + modifier = Modifier + .deckShared(request.id, "glyph", visibility, CircleShape) + .graphicsLayer { alpha = contentAlpha() }, ) - Column { + Column(modifier = Modifier.graphicsLayer { alpha = contentAlpha() }) { Text( text = request.deckTitle.orEmpty(), style = CodeTheme.typography.caption, @@ -526,6 +584,8 @@ 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 ARRIVE_MILLIS = 150 +private const val HANDOVER_MILLIS = 120 // The card's bounds spring settles in about 300ms; the details start at 60% of that. private const val DETAIL_DELAY_MS = 180 From dc83a3a94fec71858550bbd095a5dd9b6ec6f460 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 21:58:41 -0400 Subject: [PATCH 31/47] fix(chats): preview a payment request in the chat list The mapping could word a request's preview, but no list passed it the requests, so a chat whose latest message is a request previewed as nothing. The Chats and Archived lists now read the requests store and fetch a previewed request the device doesn't hold yet, once, so the row fills in on a cold start without opening the chat. --- .../app/tipping/internal/ArchivedChatsViewModel.kt | 9 +++++++-- .../flipcash/app/tipping/internal/ChatsViewModel.kt | 13 ++++++++++--- .../app/tipping/internal/ConversationMapping.kt | 10 +++++++++- .../tipping/internal/ChatsViewModelArchiveTest.kt | 1 + .../flipcash/shared/chat/ui/ChatSummaryMapping.kt | 4 ++++ 5 files changed, 31 insertions(+), 6 deletions(-) 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<ArchivedChatsViewModel.State, ArchivedChatsViewModel.Event>( 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<ChatsViewModel.State, ChatsViewModel.Event>( 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<String, UserProfile>?, + requests: Map<ID, PaymentRequest>, ): 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<ID, PaymentRequest>, + // Asked for each previewed request the store doesn't hold yet; the row redraws when it lands. + requestPaymentRequest: (ID) -> Unit, ): List<ConversationReference> { 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<ChatsViewModel.State>() 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 bef6075e89..4d55c9392c 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 @@ -98,6 +98,10 @@ private fun ChatSummary.groupSenderName(senderId: ID, senderProfiles: Map<String ?: senderProfiles[senderId.hexEncodedString()]?.displayName?.takeIf { it.isNotBlank() } } +/** The payment request the row's preview names, when the last message is one; its share comes from the store. */ +fun ChatSummary.previewedPaymentRequestId(): ID? = + ((metadata.lastMessage?.content?.firstOrNull() as? MessageContent.Widget)?.widget as? WidgetContent.PaymentRequest)?.id + private fun ChatSummary.formatPreview( selfId: ID?, tokensByMint: Map<Mint, Token>, From e98fc4e6a9c6086d17d779e0356a68c6dd64358f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 22:05:27 -0400 Subject: [PATCH 32/47] fix(chats): preview a request by who sent it and its title The chat list previewed a request as "You requested $5.00". It now reads "You sent request for Dinner" or "Alice sent request for Dinner", with no amount. A title that can't be opened drops the "for" part. --- .../core/src/main/res/values/strings.xml | 6 ++- .../shared/chat/ui/ChatSummaryMapping.kt | 23 ++++++----- .../shared/chat/ui/ChatSummaryPreviewTest.kt | 38 ++++++++++++++----- 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 3d873ae711..0e9db7e82b 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1015,8 +1015,10 @@ <string name="label_chat_preview_tippedCash_bySender">%1$s tipped %2$s</string> <string name="label_chat_preview_sharedProfile">Shared a profile</string> <string name="label_paymentRequest_card">Request</string> - <string name="label_chat_preview_requested">You requested %1$s</string> - <string name="label_chat_preview_requested_bySender">%1$s requested %2$s</string> + <string name="label_chat_preview_sentRequest">You sent request for %1$s</string> + <string name="label_chat_preview_sentRequest_bySender">%1$s sent request for %2$s</string> + <string name="label_chat_preview_sentRequest_untitled">You sent request</string> + <string name="label_chat_preview_sentRequest_untitled_bySender">%1$s sent request</string> <string name="action_sendMoney">Send Money</string> <string name="subtitle_sendMoney_group">Send money to someone in the group, or multiple people</string> <string name="action_requestMoney">Request Money</string> 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 4d55c9392c..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 @@ -8,6 +8,7 @@ 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 @@ -226,17 +227,19 @@ private fun MessageContent.previewText( // A widget this client can't draw previews as nothing, as an undecryptable message does. is MessageContent.Widget -> when (val widget = widget) { is WidgetContent.ShareProfile -> resources.getString(R.string.label_chat_preview_sharedProfile) - // The per-person share, as the request card's own amounts read. - is WidgetContent.PaymentRequest -> paymentRequests[widget.id]?.split?.shares?.firstOrNull()?.amount - ?.let { share -> (exchange?.let { share.forViewer(it.preferredRate, it.rates()) } ?: share).formatted() } - ?.let { amount -> - when { - sentBySelf -> resources.getString(R.string.label_chat_preview_requested, amount) - senderName != null -> - resources.getString(R.string.label_chat_preview_requested_bySender, senderName, amount) - else -> null - } + // 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/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 9addbd8507..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 @@ -432,22 +432,42 @@ class ChatSummaryPreviewTest { ) @Test - fun `a payment request previews as who requested the per-person share`() { - val share = Fiat(20.0, CurrencyCode.USD) - val amount = share.formatted() + fun `a payment request previews as who sent it and its title`() { every { - resources.getString(R.string.label_chat_preview_requested_bySender, "Alice", amount) - } returns "Alice requested $amount" - every { resources.getString(R.string.label_chat_preview_requested, amount) } returns "You requested $amount" + 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(share)) + 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 requested $amount", groupPreview(other)) - assertEquals("You requested $amount", groupPreview(self)) + 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 From bdafa5138f88116badd0fbdc37b0cf818f2e1be4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 22:06:17 -0400 Subject: [PATCH 33/47] fix(messenger): keep a deck title whole while the pill widens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit animateContentSize measured the pill's text at each in-between width, so a longer title arriving in front read "Sund…" until the pill caught up. The title and amounts now measure up to the widest pill's text room and the easing pill clips them, so a title ellipsizes only when it is too long for that width. --- .../messenger/internal/request/RequestDeck.kt | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) 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 index f1e6dce4af..579c546fed 100644 --- 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 @@ -348,11 +348,14 @@ private fun DeckFront( RequestRing( request = request, now = now, - size = 26.dp, + size = RING_SIZE, modifier = Modifier .deckShared(request.id, "glyph", visibility, CircleShape) .graphicsLayer { alpha = contentAlpha() }, ) + // 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(modifier = Modifier.graphicsLayer { alpha = contentAlpha() }) { Text( text = request.deckTitle.orEmpty(), @@ -360,7 +363,9 @@ private fun DeckFront( color = CodeTheme.colors.textMain, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.deckShared(request.id, "title", visibility), + modifier = Modifier + .measuredUpTo(textMax) + .deckShared(request.id, "title", visibility), ) if (split != null) { Text( @@ -372,13 +377,26 @@ private fun DeckFront( style = CodeTheme.typography.caption, color = CodeTheme.colors.textSecondary, maxLines = 1, - modifier = Modifier.deckShared(request.id, "amounts", visibility), + modifier = Modifier + .measuredUpTo(textMax) + .deckShared(request.id, "amounts", visibility), ) } } } } +/** + * 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 "✓". @@ -577,6 +595,7 @@ 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 const val APPEAR_MILLIS = 250 private const val APPEAR_SCALE = 0.95f private val PEEK_STEP = 5.dp From b5fc19fbc1fa8ca33ab9e3087afd7382782f4f7a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 22:49:36 -0400 Subject: [PATCH 34/47] fix(messenger): swipe the deck like a stack of cards The front card now stops at one pill height and rubber-bands past it. The card behind scales up into the front slot as you drag and shows its request the whole way, so the pill is never empty. On release the swiped card drops behind and springs into the back slot instead of fading in there. Release and spring-back are springs, so a new drag picks the deck up where it is. --- .../messenger/internal/request/RequestDeck.kt | 144 +++++++++++------- 1 file changed, 90 insertions(+), 54 deletions(-) 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 index 579c546fed..5c20389c2d 100644 --- 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 @@ -35,7 +35,6 @@ 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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn @@ -65,10 +64,12 @@ 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 @@ -83,7 +84,6 @@ 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.IntOffset import androidx.compose.ui.unit.dp import com.flipcash.core.R import com.flipcash.services.models.paymentrequest.PaymentRequest @@ -213,31 +213,20 @@ internal fun RequestDeckPill( }, ) { // How far the front card is through being swiped to the back, 0 to 1. The cards behind - // move up a slot with it, so the next request is already in place when the swipe lands. + // 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) - // Over the second half of the swipe the front card fades as the next one's text comes in. - val handover = if (canCycle) ((progress - 0.5f) * 2f).coerceIn(0f, 1f) else 0f - // The peeking cards, back first. Widths step in so the front card's edges stay clear. - val peeks = shown.drop(1).take(3) - peeks.withIndex().reversed().forEach { (index, request) -> - val depth = index + 1 - progress - if (depth > 2f) { - // A third card behind only shows as the others move up. - if (progress == 0f) return@forEach - } - val isBack = index == peeks.lastIndex + val insetPx = with(density) { PEEK_INSET.toPx() } + val stepPx = with(density) { PEEK_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) shown.last() else null + val swipedDepth = (shown.size - 1).coerceAtMost(2).toFloat() + + @Composable + fun Card(request: PaymentRequest, layer: GraphicsLayerScope.() -> Unit, depth: Float, content: Boolean) { Box( modifier = Modifier .matchParentSize() - .padding(horizontal = PEEK_INSET * depth) - .offset(y = PEEK_STEP * depth) - .graphicsLayer { - alpha = when { - depth > 2f -> 1f - (depth - 2f) - isBack -> arrive.value - else -> 1f - } - } + .graphicsLayer(layer) .background(CodeTheme.colors.background, PILL_SHAPE) .background( lerp( @@ -249,19 +238,50 @@ internal fun RequestDeckPill( ) .border(1.dp, CodeTheme.colors.border, PILL_SHAPE), ) { - // The card moving up to the front shows its request as it arrives, so the - // swipe lands on a finished card rather than an empty one. - if (index == 0 && handover > 0f) { - DeckFront( - request = request, - now = now, - visibility = null, - contentAlpha = { handover }, - modifier = Modifier.fillMaxSize(), - ) + if (content) { + DeckFront(request = request, now = now, visibility = null, 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, slotScale(swipedDepth, insetPx, size.width), t) + scaleX = scale + scaleY = scale + translationY = lerp(pillHeightPx, slotY(swipedDepth, scale, stepPx, size.height), t) + // Past the second slot it tucks out of sight rather than stacking up. + if (shown.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 = shown.drop(1).take(3) + peeks.withIndex().reversed().forEach { (index, request) -> + if (request == swiped) return@forEach + val depth = index + 1 - progress + // A third card behind only shows as the others move up. + if (depth > 2f && progress == 0f) 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) + }, + ) + } DeckFront( request = front, now = now, @@ -276,36 +296,44 @@ internal fun RequestDeckPill( ) } else Modifier ) - .offset { IntOffset(0, drag.value.roundToInt()) } .graphicsLayer { - alpha = 1f - handover - scaleX = 1f - 0.06f * handover - scaleY = scaleX + // Falls back as it goes, so it hands off at the size the back card starts from. + val scale = 1f - DROP_SCALE * progress + scaleX = scale + scaleY = scale + translationY = drag.value } .draggable( orientation = Orientation.Vertical, state = rememberDraggableState { delta -> scope.launch { - // One request: a small elastic pull and nothing more. - val limit = if (canCycle) pillHeightPx * 1.5f else pillHeightPx * 0.25f - drag.snapTo((drag.value + delta * if (canCycle) 1f else 0.3f).coerceIn(0f, limit)) + 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) { - // Finish the swipe from where the finger left it, then hand over: - // the card that moved up becomes the front, and the one swiped away - // fades in at the back. - if (drag.value < pillHeightPx) { - drag.animateTo(pillHeightPx, tween(HANDOVER_MILLIS)) - } + // Carry the swipe on to one pill height, then hand over: the card that + // rose becomes the front and the swiped one drops behind to the back. + drag.animateTo(pillHeightPx, DECK_SPRING, initialVelocity = velocity) arrive.snapTo(0f) onCycle() drag.snapTo(0f) - arrive.animateTo(1f, tween(ARRIVE_MILLIS)) + arrive.animateTo(1f, DECK_SPRING) } else { - drag.animateTo(0f, spring(stiffness = Spring.StiffnessMediumLow), initialVelocity = velocity) + drag.animateTo(0f, DECK_SPRING, initialVelocity = velocity) } }, ) @@ -323,7 +351,6 @@ private fun DeckFront( now: Instant, visibility: AnimatedVisibilityScope?, modifier: Modifier = Modifier, - contentAlpha: () -> Float = { 1f }, ) { val ended = !request.isOpen(now) val split = request.split @@ -350,13 +377,12 @@ private fun DeckFront( now = now, size = RING_SIZE, modifier = Modifier - .deckShared(request.id, "glyph", visibility, CircleShape) - .graphicsLayer { alpha = contentAlpha() }, + .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(modifier = Modifier.graphicsLayer { alpha = contentAlpha() }) { + Column { Text( text = request.deckTitle.orEmpty(), style = CodeTheme.typography.caption, @@ -603,8 +629,18 @@ 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 ARRIVE_MILLIS = 150 -private const val HANDOVER_MILLIS = 120 +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<Float>(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 From 313b5a96b9a8956d2d4ff25950d61c974d7626b9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Sun, 25 Oct 2026 23:19:24 -0400 Subject: [PATCH 35/47] fix(messenger): spring the deck width from release with the cards The pill held the old request's width through the hand-over, then jumped to the new one, so a longer title showed clipped and a shorter one left a gap. The deck width is now its own spring, started when the swipe is let go and run on the cards' spring, so every card stretches with it as on iOS. Release also hands over at once: the swiped card and the cards behind finish their move from where the finger left them instead of first carrying the swipe on to a full pill height. --- .../messenger/internal/request/RequestDeck.kt | 66 +++++++++++++++---- 1 file changed, 53 insertions(+), 13 deletions(-) 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 index 5c20389c2d..339dc8c25e 100644 --- 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 @@ -5,7 +5,6 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout -import androidx.compose.animation.animateContentSize import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.scaleToBounds import androidx.compose.animation.core.Animatable @@ -47,10 +46,13 @@ 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 @@ -197,8 +199,13 @@ internal fun RequestDeckPill( 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) { PILL_HEIGHT.toPx() } val canCycle = shown.size > 1 + val pillWidth = remember(scope) { PillWidth(scope) } val label = deckLabel(shown, now) val nextLabel = stringResource(R.string.action_nextRequest) @@ -220,6 +227,9 @@ internal fun RequestDeckPill( // The card just swiped off, travelling from below the pill to its slot at the back. val swiped = if (canCycle && arrive.value < 1f) shown.last() else null val swipedDepth = (shown.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) { @@ -227,6 +237,9 @@ internal fun RequestDeckPill( 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(PILL_SHAPE) .background(CodeTheme.colors.background, PILL_SHAPE) .background( lerp( @@ -251,10 +264,10 @@ internal fun RequestDeckPill( content = true, layer = { val t = arrive.value - val scale = lerp(1f - DROP_SCALE, slotScale(swipedDepth, insetPx, size.width), t) + val scale = lerp(1f - DROP_SCALE * releaseProgress, slotScale(swipedDepth, insetPx, size.width), t) scaleX = scale scaleY = scale - translationY = lerp(pillHeightPx, slotY(swipedDepth, scale, stepPx, size.height), t) + translationY = lerp(releaseDrag, slotY(swipedDepth, scale, stepPx, size.height), t) // Past the second slot it tucks out of sight rather than stacking up. if (shown.size > 3) alpha = 1f - t }, @@ -265,9 +278,9 @@ internal fun RequestDeckPill( val peeks = shown.drop(1).take(3) peeks.withIndex().reversed().forEach { (index, request) -> if (request == swiped) return@forEach - val depth = index + 1 - progress + val depth = index + 1 - progress + handover // A third card behind only shows as the others move up. - if (depth > 2f && progress == 0f) return@forEach + if (depth >= 3f) return@forEach Card( request = request, depth = depth, @@ -287,6 +300,7 @@ internal fun RequestDeckPill( now = now, visibility = this@AnimatedVisibility, modifier = Modifier + .then(pillWidth.modifier) .then( if (transition != null) with(transition) { Modifier.sharedBounds( @@ -298,10 +312,11 @@ internal fun RequestDeckPill( ) .graphicsLayer { // Falls back as it goes, so it hands off at the size the back card starts from. - val scale = 1f - DROP_SCALE * progress + // 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 + translationY = drag.value + slotY(handover, scale, stepPx, size.height) } .draggable( orientation = Orientation.Vertical, @@ -325,12 +340,14 @@ internal fun RequestDeckPill( onDragStopped = { velocity -> val past = drag.value > pillHeightPx * CYCLE_THRESHOLD || velocity > FLING_VELOCITY if (canCycle && past) { - // Carry the swipe on to one pill height, then hand over: the card that - // rose becomes the front and the swiped one drops behind to the back. - drag.animateTo(pillHeightPx, DECK_SPRING, initialVelocity = velocity) + // 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() - drag.snapTo(0f) arrive.animateTo(1f, DECK_SPRING) } else { drag.animateTo(0f, DECK_SPRING, initialVelocity = velocity) @@ -356,8 +373,6 @@ private fun DeckFront( val split = request.split Row( modifier = modifier - // Eases between requests of different lengths when the deck cycles. - .animateContentSize(spring(stiffness = Spring.StiffnessMediumLow)) .background(CodeTheme.colors.background, PILL_SHAPE) .alpha(if (ended) ENDED_ALPHA else 1f) .background(CodeTheme.colors.surfaceVariant, PILL_SHAPE) @@ -412,6 +427,30 @@ private fun DeckFront( } } +/** + * 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. @@ -634,6 +673,7 @@ private const val DROP_SCALE = 0.06f private const val RUBBER_BAND = 0.25f private val DECK_SPRING = spring<Float>(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 From 7425042a60cda175cb27dbfd78968f7eb14dd041 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 07:42:28 -0400 Subject: [PATCH 36/47] feat(messenger): move the deck into a strip when the viewer owes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the viewer has an unpaid share in an open request, the deck leaves the app bar for a full-width card under it: title, "You owe <share> · <time left>" and a Pay button that opens Request details straight into paying. Scrolled away from the newest messages, it shrinks to a green app-bar pill instead. Requests the viewer owes on sort to the front. The first time this device sees an owed request, the deck expands by itself with a NEW chip on that card and collapses after 2.5 s. It skips this with a screen reader or reduced motion. The pill, green pill and strip draw one stack, so they swipe the same way. The compact pill toggles when the newest message leaves the screen rather than tracking the scroll. --- .../com/flipcash/app/core/chat/ChatStep.kt | 5 +- .../core/src/main/res/values/strings.xml | 20 + .../flipcash/app/messenger/ChatFlowScreen.kt | 1 + .../internal/request/ChatRequestsViewModel.kt | 44 +- .../messenger/internal/request/RequestDeck.kt | 715 +++++++++++++----- .../internal/request/RequestDetailsScreen.kt | 6 +- .../request/RequestDetailsViewModel.kt | 13 +- .../internal/screens/MessengerScreen.kt | 39 + .../screens/components/MessageList.kt | 12 + .../internal/request/OwedIntroTest.kt | 34 + .../shared/chat/ui/media/ReducedMotion.kt | 13 +- .../shared/chat/OwedRequestIntroStore.kt | 14 + .../flipcash/shared/chat/inject/ChatModule.kt | 7 + .../DataStoreOwedRequestIntroStore.kt | 47 ++ .../models/paymentrequest/PaymentRequest.kt | 19 +- .../paymentrequest/PaymentRequestTest.kt | 20 + 16 files changed, 822 insertions(+), 187 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/OwedIntroTest.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/OwedRequestIntroStore.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreOwedRequestIntroStore.kt 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 a7f2bbb3c8..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 @@ -202,11 +202,12 @@ sealed interface ChatStep : FlowStep, Parcelable { /** * 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. + * 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) : + data class RequestDetails(val chatId: ChatId, val requestId: String, val pay: Boolean = false) : ChatStep, Sheet, FullscreenSheet diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 0e9db7e82b..cbf6ab1e33 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1052,6 +1052,26 @@ <string name="label_requestPaidInFull">Paid in full</string> <string name="label_requestClosed">Closed</string> <string name="label_requestExpired">Expired</string> + <!-- The owed deck's time left: the status line without "to pay". --> + <plurals name="label_requestDaysLeftShort"> + <item quantity="one">%1$d day left</item> + <item quantity="other">%1$d days left</item> + </plurals> + <plurals name="label_requestHoursLeftShort"> + <item quantity="one">%1$d hour left</item> + <item quantity="other">%1$d hours left</item> + </plurals> + <plurals name="label_requestMinutesLeftShort"> + <item quantity="one">%1$d minute left</item> + <item quantity="other">%1$d minutes left</item> + </plurals> + <!-- The owed deck: "You owe $20", then "You owe $20 · 2 days left" and "You owe $20 · Dinner · 2 days left". --> + <string name="label_requestYouOwe">You owe %1$s</string> + <string name="label_requestDotJoined">%1$s · %2$s</string> + <string name="action_requestPay">Pay</string> + <string name="cd_requestPayShare">Pay %1$s</string> + <string name="label_requestNew">NEW</string> + <string name="cd_requestOwed">You owe %1$s for %2$s, %3$s</string> <!-- The app-bar deck's front card: "$180 of $520". --> <string name="label_requestDeckProgress">%1$s of %2$s</string> <!-- Screen reader label for the deck: title, "<paid> of <requested> paid", status line. --> 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 ed200813c8..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 @@ -270,6 +270,7 @@ private fun chatEntryProvider( RequestDetailsScreen( chatId = step.chatId, requestId = step.requestId, + payOnOpen = step.pay, onBack = { dismissSheet() }, onOpenScreen = { route -> navigator.navigate(route) }, ) 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 index d77afba1fb..3fb1826f74 100644 --- 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 @@ -5,7 +5,9 @@ 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.models.paymentrequest.unpaidShareOf import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.OwedRequestIntroStore import com.flipcash.shared.chat.PaymentRequests import com.getcode.opencode.model.core.ID import dagger.hilt.android.lifecycle.HiltViewModel @@ -32,6 +34,7 @@ import kotlin.time.Instant internal class ChatRequestsViewModel @Inject constructor( private val requests: PaymentRequests, private val userManager: UserManager, + private val intros: OwedRequestIntroStore, ) : ViewModel() { val viewerId: ID? get() = userManager.accountId @@ -69,7 +72,7 @@ internal class ChatRequestsViewModel @Inject constructor( chatId.flatMapLatest { id -> id?.let { requests.inChat(it) } ?: flowOf(emptyList()) }, _now, cycled, - ) { inChat, now, cycled -> deckOrder(inChat, now).rotated(cycled) } + ) { inChat, now, cycled -> deckOrder(inChat, now, viewerId).rotated(cycled) } .collect { _deck.value = it } } viewModelScope.launch { @@ -88,7 +91,7 @@ internal class ChatRequestsViewModel @Inject constructor( if (chatId.value == chat) return chatId.value = chat val held = requests.state.value.values.filter { it.chatId == chat } - _deck.value = deckOrder(held, _now.value).rotated(cycled.value) + _deck.value = deckOrder(held, _now.value, viewerId).rotated(cycled.value) } /** @@ -120,13 +123,50 @@ internal class ChatRequestsViewModel @Inject constructor( fun setExpanded(expanded: Boolean) { _expanded.value = expanded && deck.value.isNotEmpty() + if (!expanded) _introduced.value = emptySet() + } + + private val _introduced = MutableStateFlow<Set<ID>>(emptySet()) + + /** The owed requests the deck opened itself for, each marked "NEW" until it collapses. */ + val introduced: StateFlow<Set<ID>> = _introduced.asStateFlow() + + /** + * Opens the deck by itself for owed requests this device hasn't opened it for, the nearest + * deadline in front, and collapses it after [INTRO_HOLD] unless something collapsed it first. + * Call once the chat has settled. + */ + suspend fun introduceOwed(screenReader: Boolean, reducedMotion: Boolean) { + val now = _now.value + val owed = deck.value.filter { it.unpaidShareOf(viewerId, now) != null }.map { it.id } + val fresh = requestsToIntroduce(owed, intros.shown(), screenReader, reducedMotion) + if (fresh.isEmpty()) return + intros.markShown(fresh) + val front = deck.value.indexOfFirst { it.id == fresh.first() } + if (front > 0) cycled.update { it + front } + _introduced.value = fresh.toSet() + setExpanded(true) + delay(INTRO_HOLD) + if (_introduced.value.isNotEmpty()) setExpanded(false) } companion object { val POLL_INTERVAL = 45.seconds + val INTRO_HOLD = 2.5.seconds } } +/** + * The owed requests, in deck order, the deck should open itself for: those this device hasn't + * shown, and none while a screen reader is on or motion is reduced. + */ +internal fun requestsToIntroduce( + owed: List<ID>, + shown: Set<ID>, + screenReader: Boolean, + reducedMotion: Boolean, +): List<ID> = if (screenReader || reducedMotion) emptyList() else owed.filterNot { it in shown } + internal fun <T> List<T>.rotated(by: Int): List<T> { if (size < 2) return this val shift = by.mod(size) 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 index 339dc8c25e..4f021d39ba 100644 --- 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 @@ -12,11 +12,13 @@ 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 @@ -34,6 +36,7 @@ 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 @@ -77,6 +80,13 @@ 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.AnnotatedString +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 androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.clearAndSetSemantics @@ -159,15 +169,36 @@ private fun Modifier.deckShared( } } +/** Where the deck sits and how loud it is. */ +internal enum class DeckPlacement { + /** The grey pill in the app bar: the viewer owes nothing. */ + Pill, + + /** The green pill in the app bar: the viewer owes, scrolled away from the newest messages. */ + OwedPill, + + /** The full-width card under the app bar: the viewer owes, at the newest messages. */ + Strip, +} + +/** Grey pill when the viewer owes nothing; when they owe, the strip at the newest messages and the green pill away from them. */ +internal fun deckPlacement(owes: Boolean, atNewest: Boolean): DeckPlacement = when { + !owes -> DeckPlacement.Pill + atNewest -> DeckPlacement.Strip + else -> DeckPlacement.OwedPill +} + /** * The app-bar deck (concept B): the front request with up to two cards peeking beneath it. Swipe - * down cycles; tap expands. Hidden while [expanded], when the front card is over the transcript. + * down cycles; tap expands. Hidden while [expanded], when the front card is over the transcript, + * and while [placement] puts it in the strip, which it moves to and from as one shared element. */ -@OptIn(ExperimentalSharedTransitionApi::class) @Composable internal fun RequestDeckPill( deck: List<PaymentRequest>, now: Instant, + viewerId: ID?, + placement: DeckPlacement, expanded: Boolean, onExpand: () -> Unit, onCycle: () -> Unit, @@ -176,6 +207,9 @@ internal fun RequestDeckPill( // 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 + // The pill's look is held while the deck is in the strip, so it leaves and comes back as it was. + var style by remember { mutableStateOf(placement) } + if (placement != DeckPlacement.Strip) style = placement // 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( @@ -188,180 +222,478 @@ internal fun RequestDeckPill( shrinkHorizontally(tween(APPEAR_MILLIS), shrinkTowards = Alignment.End, clip = false), modifier = modifier, ) { - AnimatedVisibility( - visible = !expanded, - enter = fadeIn(), - exit = fadeOut(), + DeckStack( + deck = shown, + now = now, + viewerId = viewerId, + style = style, + visible = !expanded && placement != DeckPlacement.Strip, + 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<PaymentRequest>, + now: Instant, + viewerId: ID?, + placement: DeckPlacement, + expanded: Boolean, + onExpand: () -> Unit, + onCycle: () -> Unit, + onPay: (PaymentRequest) -> Unit, + modifier: Modifier = Modifier, +) { + var shown by remember { mutableStateOf(deck) } + if (deck.isNotEmpty()) shown = deck + val inStrip = placement == DeckPlacement.Strip && deck.isNotEmpty() + AnimatedVisibility( + visible = inStrip, + enter = expandVertically(spring(stiffness = Spring.StiffnessMedium), expandFrom = Alignment.Top), + exit = shrinkVertically(spring(stiffness = Spring.StiffnessMedium), shrinkTowards = Alignment.Top), + modifier = modifier, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x2) + .height(DeckStyle.Strip.height + DeckStyle.Strip.step * 2), ) { - val front = shown.firstOrNull() ?: return@AnimatedVisibility - 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) { PILL_HEIGHT.toPx() } - val canCycle = shown.size > 1 - val pillWidth = remember(scope) { PillWidth(scope) } - - val label = deckLabel(shown, now) - val nextLabel = stringResource(R.string.action_nextRequest) - Box( - modifier = Modifier - .padding(bottom = PEEK_STEP * 2) - .clearAndSetSemantics { - contentDescription = label - role = Role.Button - onClick { onExpand(); true } - if (canCycle) customActions = listOf(CustomAccessibilityAction(nextLabel) { onCycle(); true }) - }, - ) { - // 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) { PEEK_INSET.toPx() } - val stepPx = with(density) { PEEK_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) shown.last() else null - val swipedDepth = (shown.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(PILL_SHAPE) - .background(CodeTheme.colors.background, PILL_SHAPE) - .background( - lerp( - CodeTheme.colors.surfaceVariant, - CodeTheme.colors.surface, - (depth - 1f).coerceIn(0f, 1f), - ), - PILL_SHAPE, - ) - .border(1.dp, CodeTheme.colors.border, PILL_SHAPE), - ) { - if (content) { - DeckFront(request = request, now = now, visibility = null, modifier = Modifier.fillMaxSize()) - } + DeckStack( + deck = shown, + now = now, + viewerId = viewerId, + style = DeckPlacement.Strip, + visible = !expanded && inStrip, + 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. [visible] fades it in and out, and is the scope its front card + * moves between placements in. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun DeckStack( + deck: List<PaymentRequest>, + now: Instant, + viewerId: ID?, + style: DeckPlacement, + visible: Boolean, + onExpand: () -> Unit, + onCycle: () -> Unit, + onPay: (PaymentRequest) -> Unit, +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(), + exit = fadeOut(), + ) { + 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) + 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 (shown.size > 3) alpha = 1f - t - }, + 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 ) - } - // 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 = shown.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) + .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 -> lerp(CodeTheme.colors.surfaceVariant, CodeTheme.colors.surface, back) + DeckPlacement.OwedPill -> lerp(CodeTheme.colors.success, CodeTheme.colors.success.copy(alpha = 0.5f), 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 -> DeckFront(request = request, now = now, visibility = visibility, modifier = modifier) + DeckPlacement.OwedPill -> OwedPillFront(request, now, viewerId, visibility, modifier) + DeckPlacement.Strip -> StripFront(request, now, viewerId, visibility, onPay, modifier, bodyModifier) + } +} + +/** + * The green app-bar pill (concept node 12670:1372): glyph and "You owe <share> · <title> · <time + * left>", truncated. A front card the viewer doesn't owe on reads "<title> · <paid> of <requested>". + */ +@Composable +private fun OwedPillFront( + request: PaymentRequest, + now: Instant, + viewerId: ID?, + visibility: AnimatedVisibilityScope?, + modifier: Modifier = Modifier, +) { + val shape = DeckStyle.Pill.shape + val share = request.unpaidShareOf(viewerId, now) + val title = request.deckTitle.orEmpty() + val line = if (share != null) { + stringResource( + R.string.label_requestDotJoined, + stringResource(R.string.label_requestDotJoined, stringResource(R.string.label_requestYouOwe, share.amount.requestText()), title), + request.statusLine(now)?.timeLeftText().orEmpty(), + ) + } else { + stringResource(R.string.label_requestDotJoined, title, request.progressLabel()) + } + Row( + modifier = modifier + .background(CodeTheme.colors.success, shape) + .padding( + start = CodeTheme.dimens.grid.x1, + end = CodeTheme.dimens.grid.x3, + 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), + ) { + Box( + modifier = Modifier + .deckShared(request.id, "glyph", visibility, CircleShape) + .size(RING_SIZE) + .background(Color.Black, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text(text = "$", style = CodeTheme.typography.caption, color = CodeTheme.colors.success) + } + Text( + text = line, + style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.SemiBold), + color = Color.Black, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.deckShared(request.id, "title", visibility), + ) + } +} + +/** + * The strip's front card (concept node 12670:1262): glyph, title, "You owe <share> · <time left>" + * and Pay; or "<paid> of <requested>" 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 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, + 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 line = 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 + buildAnnotatedString { + withStyle(SpanStyle(color = success)) { append(owe) } + if (timeLeft.isNotEmpty()) append(" · $timeLeft") + } + } else { + AnnotatedString(request.progressLabel()) } - DeckFront( - request = front, - now = now, - visibility = this@AnimatedVisibility, - modifier = Modifier - .then(pillWidth.modifier) - .then( - if (transition != null) with(transition) { - Modifier.sharedBounds( - sharedContentState = rememberSharedContentState(sharedKey(front.id, "card")), - animatedVisibilityScope = this@AnimatedVisibility, - clipInOverlayDuringTransition = OverlayClip(PILL_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(PILL_SHAPE) - .clickable(onClick = onExpand), + Text( + text = line, + style = CodeTheme.typography.textSmall.copy(fontWeight = FontWeight.Medium), + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + 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, ) } } } } +/** "<paid> of <requested>". */ +@Composable +private fun PaymentRequest.progressLabel(): String { + val split = split ?: return "" + return stringResource(R.string.label_requestDeckProgress, split.paid.requestText(), split.requested.requestText()) +} + +/** 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, @@ -493,12 +825,18 @@ internal fun RequestRing(request: PaymentRequest, now: Instant, size: Dp, modifi } } -/** "<N> requests, <title>, <paid> of <requested> paid, <status line>". */ +/** "<N> requests, <title>, <paid> of <requested> paid, <status line>", or "You owe <share> for <title>, <status line>". */ @Composable -private fun deckLabel(deck: List<PaymentRequest>, now: Instant): String { +private fun deckLabel(deck: List<PaymentRequest>, now: Instant, viewerId: ID?): String { val front = deck.first() val split = front.split - val main = stringResource( + val share = front.unpaidShareOf(viewerId, now) + val main = if (share != null) stringResource( + R.string.cd_requestOwed, + share.amount.requestText(), + front.deckTitle.orEmpty(), + front.statusLine(now).text(), + ) else stringResource( R.string.cd_requestDeck, front.deckTitle.orEmpty(), split?.paid?.requestText().orEmpty(), @@ -524,6 +862,7 @@ internal fun ExpandedRequests( pill: Rect?, onCollapse: () -> Unit, onOpen: (PaymentRequest) -> Unit, + introduced: Set<ID> = emptySet(), ) { BackHandler(enabled = expanded, onBack = onCollapse) // The chat behind goes under its backdrop (chatBackdrop, on the scaffold); this only takes the @@ -589,16 +928,27 @@ internal fun ExpandedRequests( .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, - ) + Box { + 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, + ) + // A request the deck opened itself for, the first time this device saw it owed. + if (request.id in introduced) { + NewChip( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(CodeTheme.dimens.grid.x2) + .then(detail), + ) + } + } } } } @@ -608,6 +958,19 @@ internal fun ExpandedRequests( DeckCloseButton(expanded = expanded, pill = pill, onCollapse = onCollapse) } +/** The green "NEW" chip on a request the deck opened itself for. */ +@Composable +private fun NewChip(modifier: Modifier = Modifier) { + Text( + text = stringResource(R.string.label_requestNew), + style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.Bold), + color = Color.Black, + modifier = modifier + .background(CodeTheme.colors.success, PAY_SHAPE) + .padding(horizontal = CodeTheme.dimens.grid.x2, vertical = 2.dp), + ) +} + /** * 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. @@ -661,6 +1024,8 @@ 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 private const val APPEAR_SCALE = 0.95f private val PEEK_STEP = 5.dp 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 index e9adb82469..d3f5114079 100644 --- 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 @@ -84,9 +84,13 @@ internal fun RequestDetailsScreen( onBack: () -> Unit, onOpenScreen: (AppRoute) -> Unit, pushed: Boolean = false, + payOnOpen: Boolean = false, ) { val viewModel = hiltViewModel<RequestDetailsViewModel>() - LaunchedEffect(chatId, requestId) { viewModel.open(chatId, requestId) } + 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 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 index e96fe435a2..9c2dbb8144 100644 --- 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 @@ -9,6 +9,7 @@ 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 @@ -159,6 +160,15 @@ internal class RequestDetailsViewModel @Inject constructor( } } + 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 <share>": 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 @@ -347,5 +357,4 @@ internal fun sendableShareOf(request: PaymentRequest, viewerId: ID?, now: Instan /** Whether the viewer has a share to pay on [request] now. */ internal fun unpaidShareOf(request: PaymentRequest, viewerId: ID?, now: Instant) = - if (viewerId == null || !request.isOpen(now)) null - else request.split?.shareOf(viewerId)?.takeIf { !it.isPaid } + request.unpaidShareOf(viewerId, now) 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 08896eb0cc..877d4ea7af 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,5 +1,10 @@ package com.flipcash.app.messenger.internal.screens +import com.flipcash.shared.chat.ui.media.rememberReducedMotion +import com.flipcash.shared.chat.ui.media.rememberScreenReaderOn +import com.flipcash.services.models.paymentrequest.unpaidShareOf +import com.flipcash.app.messenger.internal.request.RequestDeckStrip +import com.flipcash.app.messenger.internal.request.deckPlacement import android.os.Parcelable import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility @@ -457,7 +462,18 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { val deck by requestsViewModel.deck.collectAsStateWithLifecycle() val deckExpanded by requestsViewModel.expanded.collectAsStateWithLifecycle() val deckNow by requestsViewModel.now.collectAsStateWithLifecycle() + val introduced by requestsViewModel.introduced.collectAsStateWithLifecycle() val drawnDeck = remember(deck) { deck.filter { it.deckTitle != null } } + var atNewest by remember { mutableStateOf(true) } + val viewerId = requestsViewModel.viewerId + val owes = drawnDeck.any { it.unpaidShareOf(viewerId, deckNow) != null } + val deckPlacement = deckPlacement(owes = owes, atNewest = atNewest) + val screenReader = rememberScreenReaderOn() + val reducedMotion = rememberReducedMotion() + var settled by remember { mutableStateOf(false) } + LaunchedEffect(settled, owes) { + if (settled && owes) requestsViewModel.introduceOwed(screenReader, reducedMotion) + } 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 -> @@ -494,6 +510,8 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { RequestDeckPill( deck = drawnDeck, now = deckNow, + viewerId = viewerId, + placement = deckPlacement, expanded = deckExpanded, onExpand = { keyboard.hideIfVisible { requestsViewModel.setExpanded(true) } }, onCycle = requestsViewModel::cycle, @@ -505,6 +523,24 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } }, ) + if (state.chatType == ChatType.GROUP) { + RequestDeckStrip( + deck = drawnDeck, + now = deckNow, + viewerId = viewerId, + placement = deckPlacement, + expanded = deckExpanded, + onExpand = { keyboard.hideIfVisible { requestsViewModel.setExpanded(true) } }, + onCycle = requestsViewModel::cycle, + onPay = { request -> + state.chatId?.let { chatId -> + keyboard.hideIfVisible { + navigator.push(ChatStep.RequestDetails(chatId, request.id.base58, pay = true)) + } + } + }, + ) + } // 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 @@ -612,6 +648,8 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { onReturnToOrigin = { viewModel.dispatchEvent(ChatViewModel.Event.ReturnToOrigin) }, onOriginsReached = { viewModel.dispatchEvent(ChatViewModel.Event.ReturnOriginsReached(it)) }, onReachedNewest = { viewModel.dispatchEvent(ChatViewModel.Event.ReturnStackCleared) }, + onAtNewestChanged = { atNewest = it }, + onSettled = { settled = true }, hazeState = hazeState, ) } @@ -638,6 +676,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { top = barHeight, pill = deckPill, onCollapse = { requestsViewModel.setExpanded(false) }, + introduced = introduced, onOpen = openRequest, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index af6f409f39..2996878127 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -120,6 +120,8 @@ internal fun MessageList( onReturnToOrigin: () -> Unit = {}, onOriginsReached: (Set<Long>) -> Unit = {}, onReachedNewest: () -> Unit = {}, + onAtNewestChanged: (Boolean) -> Unit = {}, + onSettled: () -> Unit = {}, hazeState: HazeState? = null, ) { val keyboard = rememberKeyboardController() @@ -209,6 +211,16 @@ internal fun MessageList( .distinctUntilChanged() .collect { atNewest -> if (atNewest && currentReturnStack.isNotEmpty()) currentOnReachedNewest() } } + // Whether the newest message is on screen, however the list got there, for the request deck + // to sit in the strip or shrink to the app bar. Settled is the list's first position. + val currentOnAtNewestChanged by rememberUpdatedState(onAtNewestChanged) + val currentOnSettled by rememberUpdatedState(onSettled) + LaunchedEffect(listState) { + snapshotFlow { !hasPositioned || listState.firstVisibleItemIndex == 0 } + .distinctUntilChanged() + .collect { currentOnAtNewestChanged(it) } + } + LaunchedEffect(hasPositioned) { if (hasPositioned) currentOnSettled() } // A tap on the button that found no origin left in the chat goes to the bottom, as one with // an empty stack does. LaunchedEffect(state.newestRequests) { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/OwedIntroTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/OwedIntroTest.kt new file mode 100644 index 0000000000..d93357decc --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/OwedIntroTest.kt @@ -0,0 +1,34 @@ +package com.flipcash.app.messenger.internal.request + +import org.junit.Assert.assertEquals +import org.junit.Test + +class OwedIntroTest { + + private val a = listOf<Byte>(1) + private val b = listOf<Byte>(2) + + @Test + fun `a new owed request opens the deck on the first open only`() { + val first = requestsToIntroduce(listOf(a), shown = emptySet(), screenReader = false, reducedMotion = false) + assertEquals(listOf(a), first) + val second = requestsToIntroduce(listOf(a), shown = first.toSet(), screenReader = false, reducedMotion = false) + assertEquals(emptyList<List<Byte>>(), second) + } + + @Test + fun `several new owed requests open it once, in deck order`() { + assertEquals(listOf(b, a), requestsToIntroduce(listOf(b, a), emptySet(), screenReader = false, reducedMotion = false)) + } + + @Test + fun `a request already shown is left out`() { + assertEquals(listOf(b), requestsToIntroduce(listOf(a, b), setOf(a), screenReader = false, reducedMotion = false)) + } + + @Test + fun `nothing opens with a screen reader or reduced motion`() { + assertEquals(emptyList<List<Byte>>(), requestsToIntroduce(listOf(a), emptySet(), screenReader = true, reducedMotion = false)) + assertEquals(emptyList<List<Byte>>(), requestsToIntroduce(listOf(a), emptySet(), screenReader = false, reducedMotion = true)) + } +} 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..5834cee0ec 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 @@ -1,6 +1,7 @@ package com.flipcash.shared.chat.ui.media import android.provider.Settings +import android.view.accessibility.AccessibilityManager import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext @@ -13,7 +14,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 { @@ -21,3 +22,13 @@ internal fun rememberReducedMotion(): Boolean { }.getOrDefault(false) } } + +/** True while a touch-exploring screen reader, such as TalkBack, is on. */ +@Composable +fun rememberScreenReaderOn(): Boolean { + val context = LocalContext.current + return remember(context) { + val manager = context.getSystemService(AccessibilityManager::class.java) + manager?.isTouchExplorationEnabled == true + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/OwedRequestIntroStore.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/OwedRequestIntroStore.kt new file mode 100644 index 0000000000..8175ef993f --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/OwedRequestIntroStore.kt @@ -0,0 +1,14 @@ +package com.flipcash.shared.chat + +import com.getcode.opencode.model.core.ID + +/** + * The owed requests whose chat has already opened its deck by itself on this device, per signed-in + * account, so each does it once. + */ +interface OwedRequestIntroStore { + /** The current account's shown requests; empty before sign-in. */ + suspend fun shown(): Set<ID> + + suspend fun markShown(ids: Collection<ID>) +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt index fc277f5f8a..a1b1a1f8fd 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt @@ -7,11 +7,13 @@ import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.HostLobbies import com.flipcash.shared.chat.LobbyRequestStore +import com.flipcash.shared.chat.OwedRequestIntroStore import com.flipcash.shared.chat.LobbyUpdateSink import com.flipcash.shared.chat.GroupRosters import com.flipcash.shared.chat.RosterUpdateSink import com.flipcash.shared.chat.RosterSearchSource import com.flipcash.shared.chat.internal.DataStoreLobbyRequestStore +import com.flipcash.shared.chat.internal.DataStoreOwedRequestIntroStore import com.flipcash.shared.chat.internal.DmOutgoingEncryption import com.flipcash.shared.chat.internal.MentionPoolUpdates import com.flipcash.shared.chat.internal.MentionSuggestionPool @@ -52,6 +54,11 @@ abstract class ChatModule { impl: RealChatArchiveStore ): ChatArchiveStore + @Binds + internal abstract fun bindOwedRequestIntroStore( + impl: DataStoreOwedRequestIntroStore + ): OwedRequestIntroStore + @Binds internal abstract fun bindLobbyRequestStore( impl: DataStoreLobbyRequestStore diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreOwedRequestIntroStore.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreOwedRequestIntroStore.kt new file mode 100644 index 0000000000..2a64aa36e1 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreOwedRequestIntroStore.kt @@ -0,0 +1,47 @@ +package com.flipcash.shared.chat.internal + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.OwedRequestIntroStore +import com.getcode.opencode.model.core.ID +import com.getcode.utils.hexEncodedString +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.owedRequestIntrosDataStore: DataStore<Preferences> by preferencesDataStore(name = "owed_request_intros") + +/** One string set of request ids in hex per account, as [DataStoreLobbyRequestStore] keys its own. */ +@Singleton +internal class DataStoreOwedRequestIntroStore @Inject constructor( + @ApplicationContext context: Context, + private val userManager: UserManager, +) : OwedRequestIntroStore { + + private val dataStore = context.owedRequestIntrosDataStore + + @OptIn(ExperimentalStdlibApi::class) + override suspend fun shown(): Set<ID> { + val owner = userManager.accountId?.hexEncodedString() ?: return emptySet() + return dataStore.data.first()[key(owner)].orEmpty() + .mapNotNull { runCatching { it.hexToByteArray().toList() }.getOrNull() } + .toSet() + } + + @OptIn(ExperimentalStdlibApi::class) + override suspend fun markShown(ids: Collection<ID>) { + val owner = userManager.accountId?.hexEncodedString() ?: return + dataStore.edit { prefs -> + val key = key(owner) + prefs[key] = prefs[key].orEmpty() + ids.map { it.toByteArray().toHexString() } + } + } + + private fun key(owner: String) = stringSetPreferencesKey("shown-$owner") +} 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 index 033aaf4254..06ddb442a1 100644 --- 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 @@ -191,12 +191,23 @@ fun PaymentRequest.statusLine(now: Instant): StatusLine = when (resolvedStatus(n } /** - * The app-bar deck's default order: open requests by deadline, nearest first, then requests that - * ended within the last 24 hours, most recently ended first. Anything else is left out. + * 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 deckOrder(requests: Collection<PaymentRequest>, now: Instant): List<PaymentRequest> { +fun PaymentRequest.unpaidShareOf(viewerId: ID?, now: Instant): Share? = + if (viewerId == null || viewerId == creator || !isOpen(now)) null + else split?.shareOf(viewerId)?.takeIf { !it.isPaid } + +/** + * 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<PaymentRequest>, now: Instant, viewerId: ID? = null): List<PaymentRequest> { val inDeck = requests.filter { it.isInDeck(now) } val (open, ended) = inDeck.partition { it.isOpen(now) } - return open.sortedBy { it.split?.endsAt } + + 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/test/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestTest.kt index bdcbfd099b..a9fc52a2c0 100644 --- 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 @@ -167,6 +167,26 @@ class PaymentRequestTest { ) } + @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) From 33b9d1dd258fbed3ce3f2b9740e1815f4fec2dcb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 08:34:36 -0400 Subject: [PATCH 37/47] fix(messenger): shrink the owed strip into the grey pill, drop auto-expand Scrolled away from the newest messages, the strip now shrinks into the normal grey app-bar pill instead of a green one, and grows back at the newest messages. The deck no longer opens itself the first time it sees an owed request: the NEW chip and the per-device store of shown requests are gone. The owed screen-reader label is now read in the strip only. --- .../core/src/main/res/values/strings.xml | 4 +- .../internal/request/ChatRequestsViewModel.kt | 40 ------ .../messenger/internal/request/RequestDeck.kt | 130 +++--------------- .../internal/screens/MessengerScreen.kt | 11 -- .../screens/components/MessageList.kt | 5 +- .../internal/request/DeckPlacementTest.kt | 23 ++++ .../internal/request/OwedIntroTest.kt | 34 ----- .../shared/chat/ui/media/ReducedMotion.kt | 13 +- .../shared/chat/OwedRequestIntroStore.kt | 14 -- .../flipcash/shared/chat/inject/ChatModule.kt | 7 - .../DataStoreOwedRequestIntroStore.kt | 47 ------- 11 files changed, 46 insertions(+), 282 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckPlacementTest.kt delete mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/OwedIntroTest.kt delete mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/OwedRequestIntroStore.kt delete mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreOwedRequestIntroStore.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index cbf6ab1e33..a71faa4b96 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1065,12 +1065,10 @@ <item quantity="one">%1$d minute left</item> <item quantity="other">%1$d minutes left</item> </plurals> - <!-- The owed deck: "You owe $20", then "You owe $20 · 2 days left" and "You owe $20 · Dinner · 2 days left". --> + <!-- The owed deck: "You owe $20", then "You owe $20 · 2 days left". --> <string name="label_requestYouOwe">You owe %1$s</string> - <string name="label_requestDotJoined">%1$s · %2$s</string> <string name="action_requestPay">Pay</string> <string name="cd_requestPayShare">Pay %1$s</string> - <string name="label_requestNew">NEW</string> <string name="cd_requestOwed">You owe %1$s for %2$s, %3$s</string> <!-- The app-bar deck's front card: "$180 of $520". --> <string name="label_requestDeckProgress">%1$s of %2$s</string> 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 index 3fb1826f74..e9cab01c02 100644 --- 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 @@ -5,9 +5,7 @@ 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.models.paymentrequest.unpaidShareOf import com.flipcash.services.user.UserManager -import com.flipcash.shared.chat.OwedRequestIntroStore import com.flipcash.shared.chat.PaymentRequests import com.getcode.opencode.model.core.ID import dagger.hilt.android.lifecycle.HiltViewModel @@ -34,7 +32,6 @@ import kotlin.time.Instant internal class ChatRequestsViewModel @Inject constructor( private val requests: PaymentRequests, private val userManager: UserManager, - private val intros: OwedRequestIntroStore, ) : ViewModel() { val viewerId: ID? get() = userManager.accountId @@ -123,50 +120,13 @@ internal class ChatRequestsViewModel @Inject constructor( fun setExpanded(expanded: Boolean) { _expanded.value = expanded && deck.value.isNotEmpty() - if (!expanded) _introduced.value = emptySet() - } - - private val _introduced = MutableStateFlow<Set<ID>>(emptySet()) - - /** The owed requests the deck opened itself for, each marked "NEW" until it collapses. */ - val introduced: StateFlow<Set<ID>> = _introduced.asStateFlow() - - /** - * Opens the deck by itself for owed requests this device hasn't opened it for, the nearest - * deadline in front, and collapses it after [INTRO_HOLD] unless something collapsed it first. - * Call once the chat has settled. - */ - suspend fun introduceOwed(screenReader: Boolean, reducedMotion: Boolean) { - val now = _now.value - val owed = deck.value.filter { it.unpaidShareOf(viewerId, now) != null }.map { it.id } - val fresh = requestsToIntroduce(owed, intros.shown(), screenReader, reducedMotion) - if (fresh.isEmpty()) return - intros.markShown(fresh) - val front = deck.value.indexOfFirst { it.id == fresh.first() } - if (front > 0) cycled.update { it + front } - _introduced.value = fresh.toSet() - setExpanded(true) - delay(INTRO_HOLD) - if (_introduced.value.isNotEmpty()) setExpanded(false) } companion object { val POLL_INTERVAL = 45.seconds - val INTRO_HOLD = 2.5.seconds } } -/** - * The owed requests, in deck order, the deck should open itself for: those this device hasn't - * shown, and none while a screen reader is on or motion is reduced. - */ -internal fun requestsToIntroduce( - owed: List<ID>, - shown: Set<ID>, - screenReader: Boolean, - reducedMotion: Boolean, -): List<ID> = if (screenReader || reducedMotion) emptyList() else owed.filterNot { it in shown } - internal fun <T> List<T>.rotated(by: Int): List<T> { if (size < 2) return this val shift = by.mod(size) 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 index 4f021d39ba..b7d718f8fd 100644 --- 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 @@ -171,22 +171,16 @@ private fun Modifier.deckShared( /** Where the deck sits and how loud it is. */ internal enum class DeckPlacement { - /** The grey pill in the app bar: the viewer owes nothing. */ + /** The grey pill in the app bar: the viewer owes nothing, or is scrolled away from the newest messages. */ Pill, - /** The green pill in the app bar: the viewer owes, scrolled away from the newest messages. */ - OwedPill, - /** The full-width card under the app bar: the viewer owes, at the newest messages. */ Strip, } -/** Grey pill when the viewer owes nothing; when they owe, the strip at the newest messages and the green pill away from them. */ -internal fun deckPlacement(owes: Boolean, atNewest: Boolean): DeckPlacement = when { - !owes -> DeckPlacement.Pill - atNewest -> DeckPlacement.Strip - else -> DeckPlacement.OwedPill -} +/** The strip while the viewer owes and the timeline is at its newest messages; the grey pill otherwise. */ +internal fun deckPlacement(owes: Boolean, atNewest: Boolean): DeckPlacement = + if (owes && atNewest) DeckPlacement.Strip else DeckPlacement.Pill /** * The app-bar deck (concept B): the front request with up to two cards peeking beneath it. Swipe @@ -207,9 +201,6 @@ internal fun RequestDeckPill( // 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 - // The pill's look is held while the deck is in the strip, so it leaves and comes back as it was. - var style by remember { mutableStateOf(placement) } - if (placement != DeckPlacement.Strip) style = placement // 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( @@ -226,7 +217,7 @@ internal fun RequestDeckPill( deck = shown, now = now, viewerId = viewerId, - style = style, + style = DeckPlacement.Pill, visible = !expanded && placement != DeckPlacement.Strip, onExpand = onExpand, onCycle = onCycle, @@ -337,7 +328,7 @@ private fun DeckStack( val canCycle = deck.size > 1 val pillWidth = remember(scope) { PillWidth(scope) } - val label = deckLabel(deck, now, viewerId) + val label = deckLabel(deck, now, viewerId.takeIf { 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 { @@ -504,7 +495,6 @@ private fun peekColor(style: DeckPlacement, depth: Float): Color { val back = (depth - 1f).coerceIn(0f, 1f) return when (style) { DeckPlacement.Pill -> lerp(CodeTheme.colors.surfaceVariant, CodeTheme.colors.surface, back) - DeckPlacement.OwedPill -> lerp(CodeTheme.colors.success, CodeTheme.colors.success.copy(alpha = 0.5f), back) DeckPlacement.Strip -> lerp(CodeTheme.colors.surfaceVariant, Color.White.copy(alpha = 0.08f), back) } } @@ -523,68 +513,10 @@ private fun DeckCardFront( ) { when (style) { DeckPlacement.Pill -> DeckFront(request = request, now = now, visibility = visibility, modifier = modifier) - DeckPlacement.OwedPill -> OwedPillFront(request, now, viewerId, visibility, modifier) DeckPlacement.Strip -> StripFront(request, now, viewerId, visibility, onPay, modifier, bodyModifier) } } -/** - * The green app-bar pill (concept node 12670:1372): glyph and "You owe <share> · <title> · <time - * left>", truncated. A front card the viewer doesn't owe on reads "<title> · <paid> of <requested>". - */ -@Composable -private fun OwedPillFront( - request: PaymentRequest, - now: Instant, - viewerId: ID?, - visibility: AnimatedVisibilityScope?, - modifier: Modifier = Modifier, -) { - val shape = DeckStyle.Pill.shape - val share = request.unpaidShareOf(viewerId, now) - val title = request.deckTitle.orEmpty() - val line = if (share != null) { - stringResource( - R.string.label_requestDotJoined, - stringResource(R.string.label_requestDotJoined, stringResource(R.string.label_requestYouOwe, share.amount.requestText()), title), - request.statusLine(now)?.timeLeftText().orEmpty(), - ) - } else { - stringResource(R.string.label_requestDotJoined, title, request.progressLabel()) - } - Row( - modifier = modifier - .background(CodeTheme.colors.success, shape) - .padding( - start = CodeTheme.dimens.grid.x1, - end = CodeTheme.dimens.grid.x3, - 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), - ) { - Box( - modifier = Modifier - .deckShared(request.id, "glyph", visibility, CircleShape) - .size(RING_SIZE) - .background(Color.Black, CircleShape), - contentAlignment = Alignment.Center, - ) { - Text(text = "$", style = CodeTheme.typography.caption, color = CodeTheme.colors.success) - } - Text( - text = line, - style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.SemiBold), - color = Color.Black, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.deckShared(request.id, "title", visibility), - ) - } -} - /** * The strip's front card (concept node 12670:1262): glyph, title, "You owe <share> · <time left>" * and Pay; or "<paid> of <requested>" and no Pay on a card the viewer doesn't owe on. @@ -825,7 +757,10 @@ internal fun RequestRing(request: PaymentRequest, now: Instant, size: Dp, modifi } } -/** "<N> requests, <title>, <paid> of <requested> paid, <status line>", or "You owe <share> for <title>, <status line>". */ +/** + * "<N> requests, <title>, <paid> of <requested> paid, <status line>", or in the strip, where + * [viewerId] is given, "You owe <share> for <title>, <status line>". + */ @Composable private fun deckLabel(deck: List<PaymentRequest>, now: Instant, viewerId: ID?): String { val front = deck.first() @@ -862,7 +797,6 @@ internal fun ExpandedRequests( pill: Rect?, onCollapse: () -> Unit, onOpen: (PaymentRequest) -> Unit, - introduced: Set<ID> = emptySet(), ) { BackHandler(enabled = expanded, onBack = onCollapse) // The chat behind goes under its backdrop (chatBackdrop, on the scaffold); this only takes the @@ -928,27 +862,16 @@ internal fun ExpandedRequests( .padding(CodeTheme.dimens.grid.x3) val front = index == 0 val detail = if (front) Modifier.expandedOnly(visibility) else Modifier - Box { - 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, - ) - // A request the deck opened itself for, the first time this device saw it owed. - if (request.id in introduced) { - NewChip( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(CodeTheme.dimens.grid.x2) - .then(detail), - ) - } - } + 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, + ) } } } @@ -958,19 +881,6 @@ internal fun ExpandedRequests( DeckCloseButton(expanded = expanded, pill = pill, onCollapse = onCollapse) } -/** The green "NEW" chip on a request the deck opened itself for. */ -@Composable -private fun NewChip(modifier: Modifier = Modifier) { - Text( - text = stringResource(R.string.label_requestNew), - style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.Bold), - color = Color.Black, - modifier = modifier - .background(CodeTheme.colors.success, PAY_SHAPE) - .padding(horizontal = CodeTheme.dimens.grid.x2, vertical = 2.dp), - ) -} - /** * 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. 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 877d4ea7af..b5ca4deb01 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,7 +1,5 @@ package com.flipcash.app.messenger.internal.screens -import com.flipcash.shared.chat.ui.media.rememberReducedMotion -import com.flipcash.shared.chat.ui.media.rememberScreenReaderOn import com.flipcash.services.models.paymentrequest.unpaidShareOf import com.flipcash.app.messenger.internal.request.RequestDeckStrip import com.flipcash.app.messenger.internal.request.deckPlacement @@ -462,18 +460,11 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { val deck by requestsViewModel.deck.collectAsStateWithLifecycle() val deckExpanded by requestsViewModel.expanded.collectAsStateWithLifecycle() val deckNow by requestsViewModel.now.collectAsStateWithLifecycle() - val introduced by requestsViewModel.introduced.collectAsStateWithLifecycle() val drawnDeck = remember(deck) { deck.filter { it.deckTitle != null } } var atNewest by remember { mutableStateOf(true) } val viewerId = requestsViewModel.viewerId val owes = drawnDeck.any { it.unpaidShareOf(viewerId, deckNow) != null } val deckPlacement = deckPlacement(owes = owes, atNewest = atNewest) - val screenReader = rememberScreenReaderOn() - val reducedMotion = rememberReducedMotion() - var settled by remember { mutableStateOf(false) } - LaunchedEffect(settled, owes) { - if (settled && owes) requestsViewModel.introduceOwed(screenReader, reducedMotion) - } 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 -> @@ -649,7 +640,6 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { onOriginsReached = { viewModel.dispatchEvent(ChatViewModel.Event.ReturnOriginsReached(it)) }, onReachedNewest = { viewModel.dispatchEvent(ChatViewModel.Event.ReturnStackCleared) }, onAtNewestChanged = { atNewest = it }, - onSettled = { settled = true }, hazeState = hazeState, ) } @@ -676,7 +666,6 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { top = barHeight, pill = deckPill, onCollapse = { requestsViewModel.setExpanded(false) }, - introduced = introduced, onOpen = openRequest, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 2996878127..07722a0dc2 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -121,7 +121,6 @@ internal fun MessageList( onOriginsReached: (Set<Long>) -> Unit = {}, onReachedNewest: () -> Unit = {}, onAtNewestChanged: (Boolean) -> Unit = {}, - onSettled: () -> Unit = {}, hazeState: HazeState? = null, ) { val keyboard = rememberKeyboardController() @@ -212,15 +211,13 @@ internal fun MessageList( .collect { atNewest -> if (atNewest && currentReturnStack.isNotEmpty()) currentOnReachedNewest() } } // Whether the newest message is on screen, however the list got there, for the request deck - // to sit in the strip or shrink to the app bar. Settled is the list's first position. + // to sit in the strip or shrink to the app bar. val currentOnAtNewestChanged by rememberUpdatedState(onAtNewestChanged) - val currentOnSettled by rememberUpdatedState(onSettled) LaunchedEffect(listState) { snapshotFlow { !hasPositioned || listState.firstVisibleItemIndex == 0 } .distinctUntilChanged() .collect { currentOnAtNewestChanged(it) } } - LaunchedEffect(hasPositioned) { if (hasPositioned) currentOnSettled() } // A tap on the button that found no origin left in the chat goes to the bottom, as one with // an empty stack does. LaunchedEffect(state.newestRequests) { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckPlacementTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckPlacementTest.kt new file mode 100644 index 0000000000..7b005ff4a1 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckPlacementTest.kt @@ -0,0 +1,23 @@ +package com.flipcash.app.messenger.internal.request + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DeckPlacementTest { + + @Test + fun `a viewer who owes sees the strip at the newest messages`() { + assertEquals(DeckPlacement.Strip, deckPlacement(owes = true, atNewest = true)) + } + + @Test + fun `scrolling away shrinks the strip into the grey pill`() { + assertEquals(DeckPlacement.Pill, deckPlacement(owes = true, atNewest = false)) + } + + @Test + fun `a viewer who owes nothing keeps the grey pill`() { + assertEquals(DeckPlacement.Pill, deckPlacement(owes = false, atNewest = true)) + assertEquals(DeckPlacement.Pill, deckPlacement(owes = false, atNewest = false)) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/OwedIntroTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/OwedIntroTest.kt deleted file mode 100644 index d93357decc..0000000000 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/OwedIntroTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.flipcash.app.messenger.internal.request - -import org.junit.Assert.assertEquals -import org.junit.Test - -class OwedIntroTest { - - private val a = listOf<Byte>(1) - private val b = listOf<Byte>(2) - - @Test - fun `a new owed request opens the deck on the first open only`() { - val first = requestsToIntroduce(listOf(a), shown = emptySet(), screenReader = false, reducedMotion = false) - assertEquals(listOf(a), first) - val second = requestsToIntroduce(listOf(a), shown = first.toSet(), screenReader = false, reducedMotion = false) - assertEquals(emptyList<List<Byte>>(), second) - } - - @Test - fun `several new owed requests open it once, in deck order`() { - assertEquals(listOf(b, a), requestsToIntroduce(listOf(b, a), emptySet(), screenReader = false, reducedMotion = false)) - } - - @Test - fun `a request already shown is left out`() { - assertEquals(listOf(b), requestsToIntroduce(listOf(a, b), setOf(a), screenReader = false, reducedMotion = false)) - } - - @Test - fun `nothing opens with a screen reader or reduced motion`() { - assertEquals(emptyList<List<Byte>>(), requestsToIntroduce(listOf(a), emptySet(), screenReader = true, reducedMotion = false)) - assertEquals(emptyList<List<Byte>>(), requestsToIntroduce(listOf(a), emptySet(), screenReader = false, reducedMotion = true)) - } -} 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 5834cee0ec..89036bfbbf 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 @@ -1,7 +1,6 @@ package com.flipcash.shared.chat.ui.media import android.provider.Settings -import android.view.accessibility.AccessibilityManager import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext @@ -14,7 +13,7 @@ import androidx.compose.ui.platform.LocalContext * unreadable setting means animations are on. */ @Composable -fun rememberReducedMotion(): Boolean { +internal fun rememberReducedMotion(): Boolean { val resolver = LocalContext.current.contentResolver return remember(resolver) { runCatching { @@ -22,13 +21,3 @@ fun rememberReducedMotion(): Boolean { }.getOrDefault(false) } } - -/** True while a touch-exploring screen reader, such as TalkBack, is on. */ -@Composable -fun rememberScreenReaderOn(): Boolean { - val context = LocalContext.current - return remember(context) { - val manager = context.getSystemService(AccessibilityManager::class.java) - manager?.isTouchExplorationEnabled == true - } -} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/OwedRequestIntroStore.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/OwedRequestIntroStore.kt deleted file mode 100644 index 8175ef993f..0000000000 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/OwedRequestIntroStore.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.flipcash.shared.chat - -import com.getcode.opencode.model.core.ID - -/** - * The owed requests whose chat has already opened its deck by itself on this device, per signed-in - * account, so each does it once. - */ -interface OwedRequestIntroStore { - /** The current account's shown requests; empty before sign-in. */ - suspend fun shown(): Set<ID> - - suspend fun markShown(ids: Collection<ID>) -} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt index a1b1a1f8fd..fc277f5f8a 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt @@ -7,13 +7,11 @@ import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.HostLobbies import com.flipcash.shared.chat.LobbyRequestStore -import com.flipcash.shared.chat.OwedRequestIntroStore import com.flipcash.shared.chat.LobbyUpdateSink import com.flipcash.shared.chat.GroupRosters import com.flipcash.shared.chat.RosterUpdateSink import com.flipcash.shared.chat.RosterSearchSource import com.flipcash.shared.chat.internal.DataStoreLobbyRequestStore -import com.flipcash.shared.chat.internal.DataStoreOwedRequestIntroStore import com.flipcash.shared.chat.internal.DmOutgoingEncryption import com.flipcash.shared.chat.internal.MentionPoolUpdates import com.flipcash.shared.chat.internal.MentionSuggestionPool @@ -54,11 +52,6 @@ abstract class ChatModule { impl: RealChatArchiveStore ): ChatArchiveStore - @Binds - internal abstract fun bindOwedRequestIntroStore( - impl: DataStoreOwedRequestIntroStore - ): OwedRequestIntroStore - @Binds internal abstract fun bindLobbyRequestStore( impl: DataStoreLobbyRequestStore diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreOwedRequestIntroStore.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreOwedRequestIntroStore.kt deleted file mode 100644 index 2a64aa36e1..0000000000 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreOwedRequestIntroStore.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.flipcash.shared.chat.internal - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringSetPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import com.flipcash.services.user.UserManager -import com.flipcash.shared.chat.OwedRequestIntroStore -import com.getcode.opencode.model.core.ID -import com.getcode.utils.hexEncodedString -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.first -import javax.inject.Inject -import javax.inject.Singleton - -private val Context.owedRequestIntrosDataStore: DataStore<Preferences> by preferencesDataStore(name = "owed_request_intros") - -/** One string set of request ids in hex per account, as [DataStoreLobbyRequestStore] keys its own. */ -@Singleton -internal class DataStoreOwedRequestIntroStore @Inject constructor( - @ApplicationContext context: Context, - private val userManager: UserManager, -) : OwedRequestIntroStore { - - private val dataStore = context.owedRequestIntrosDataStore - - @OptIn(ExperimentalStdlibApi::class) - override suspend fun shown(): Set<ID> { - val owner = userManager.accountId?.hexEncodedString() ?: return emptySet() - return dataStore.data.first()[key(owner)].orEmpty() - .mapNotNull { runCatching { it.hexToByteArray().toList() }.getOrNull() } - .toSet() - } - - @OptIn(ExperimentalStdlibApi::class) - override suspend fun markShown(ids: Collection<ID>) { - val owner = userManager.accountId?.hexEncodedString() ?: return - dataStore.edit { prefs -> - val key = key(owner) - prefs[key] = prefs[key].orEmpty() + ids.map { it.toByteArray().toHexString() } - } - } - - private fun key(owner: String) = stringSetPreferencesKey("shown-$owner") -} From 5672a623d1f107f1310317d9132156416d091546 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 11:27:20 -0400 Subject: [PATCH 38/47] fix(messenger): keep the owed strip in the pill once scrolled away Scrolling away from the newest messages shrinks the owed strip into the grey app-bar pill as the finger moves; a scroll released partway settles to the nearer end. Once it reaches the pill it stays there for the rest of the chat visit, so scrolling back to the newest messages no longer grows it. The strip comes back when the chat is reopened while still owing, or when a new owed request arrives while it is open. The pill, strip and expanded deck now share one seekable transition, so the front card's shared bounds can morph between any two of them. The strip consumes the scroll while it shrinks, so the timeline doesn't jump. DeckRest holds the pinning rule and replaces deckPlacement. --- .../internal/request/DeckHostState.kt | 145 ++++++++++++++++++ .../messenger/internal/request/RequestDeck.kt | 73 +++++---- .../internal/screens/MessengerScreen.kt | 37 +++-- .../screens/components/MessageList.kt | 9 -- .../internal/request/DeckPlacementTest.kt | 23 --- .../internal/request/DeckRestTest.kt | 58 +++++++ 6 files changed, 271 insertions(+), 74 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt delete mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckPlacementTest.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckRestTest.kt 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<ID> = emptySet(), +) { + /** The requests the viewer has an unpaid share in, as of now. */ + fun owing(owed: Set<ID>): 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<ID>) { + 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<ID>, 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<Float>(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<ID>): 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/RequestDeck.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt index b7d718f8fd..f7ceeeb209 100644 --- 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 @@ -8,6 +8,8 @@ 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 @@ -65,6 +67,7 @@ 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 @@ -171,29 +174,27 @@ private fun Modifier.deckShared( /** 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 is scrolled away from the newest messages. */ + /** 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, at the newest messages. */ + /** The full-width card under the app bar: the viewer owes. */ Strip, -} -/** The strip while the viewer owes and the timeline is at its newest messages; the grey pill otherwise. */ -internal fun deckPlacement(owes: Boolean, atNewest: Boolean): DeckPlacement = - if (owes && atNewest) DeckPlacement.Strip else DeckPlacement.Pill + /** 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. Hidden while [expanded], when the front card is over the transcript, - * and while [placement] puts it in the strip, which it moves to and from as one shared element. + * 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<PaymentRequest>, now: Instant, viewerId: ID?, - placement: DeckPlacement, - expanded: Boolean, + transition: Transition<DeckPlacement>, onExpand: () -> Unit, onCycle: () -> Unit, modifier: Modifier = Modifier, @@ -218,7 +219,7 @@ internal fun RequestDeckPill( now = now, viewerId = viewerId, style = DeckPlacement.Pill, - visible = !expanded && placement != DeckPlacement.Strip, + transition = transition, onExpand = onExpand, onCycle = onCycle, onPay = {}, @@ -236,24 +237,30 @@ internal fun RequestDeckStrip( deck: List<PaymentRequest>, now: Instant, viewerId: ID?, - placement: DeckPlacement, - expanded: Boolean, + transition: Transition<DeckPlacement>, + 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 - val inStrip = placement == DeckPlacement.Strip && deck.isNotEmpty() - AnimatedVisibility( - visible = inStrip, - enter = expandVertically(spring(stiffness = Spring.StiffnessMedium), expandFrom = Alignment.Top), - exit = shrinkVertically(spring(stiffness = Spring.StiffnessMedium), shrinkTowards = Alignment.Top), + // 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) @@ -264,7 +271,7 @@ internal fun RequestDeckStrip( now = now, viewerId = viewerId, style = DeckPlacement.Strip, - visible = !expanded && inStrip, + transition = transition, onExpand = onExpand, onCycle = onCycle, onPay = onPay, @@ -291,8 +298,8 @@ private val DeckPlacement.deckStyle: DeckStyle /** * The front request with up to two cards peeking beneath it, in one of the deck's placements. - * Swipe down cycles; tap expands. [visible] fades it in and out, and is the scope its front card - * moves between placements in. + * Swipe down cycles; tap expands. Shown while [transition] is at [style], whose scope its front + * card moves between placements in. */ @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -301,15 +308,15 @@ private fun DeckStack( now: Instant, viewerId: ID?, style: DeckPlacement, - visible: Boolean, + transition: Transition<DeckPlacement>, onExpand: () -> Unit, onCycle: () -> Unit, onPay: (PaymentRequest) -> Unit, ) { - AnimatedVisibility( - visible = visible, - enter = fadeIn(), - exit = fadeOut(), + 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 @@ -494,7 +501,7 @@ private fun DeckStack( private fun peekColor(style: DeckPlacement, depth: Float): Color { val back = (depth - 1f).coerceIn(0f, 1f) return when (style) { - DeckPlacement.Pill -> lerp(CodeTheme.colors.surfaceVariant, CodeTheme.colors.surface, back) + 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) } } @@ -512,7 +519,7 @@ private fun DeckCardFront( bodyModifier: Modifier = Modifier, ) { when (style) { - DeckPlacement.Pill -> DeckFront(request = request, now = now, visibility = visibility, modifier = modifier) + DeckPlacement.Pill, DeckPlacement.Expanded -> DeckFront(request = request, now = now, visibility = visibility, modifier = modifier) DeckPlacement.Strip -> StripFront(request, now, viewerId, visibility, onPay, modifier, bodyModifier) } } @@ -793,6 +800,7 @@ internal fun ExpandedRequests( deck: List<PaymentRequest>, now: Instant, expanded: Boolean, + transition: Transition<DeckPlacement>, top: Dp, pill: Rect?, onCollapse: () -> Unit, @@ -802,15 +810,15 @@ internal fun ExpandedRequests( // 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. - AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { + transition.AnimatedVisibility(visible = { it == DeckPlacement.Expanded }, enter = fadeIn(), exit = fadeOut()) { Box( modifier = Modifier .fillMaxSize() .pointerInput(Unit) { detectTapGestures { onCollapse() } }, ) } - AnimatedVisibility( - visible = expanded && deck.isNotEmpty(), + transition.AnimatedVisibility( + visible = { it == DeckPlacement.Expanded && deck.isNotEmpty() }, enter = fadeIn(), exit = fadeOut(), modifier = Modifier.padding(top = top), @@ -937,6 +945,9 @@ 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 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 b5ca4deb01..692fb1479c 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 @@ -2,7 +2,7 @@ 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.deckPlacement +import com.flipcash.app.messenger.internal.request.rememberDeckHostState import android.os.Parcelable import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility @@ -16,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 @@ -29,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.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -38,6 +40,7 @@ import androidx.compose.ui.Modifier 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 @@ -461,10 +464,21 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { val deckExpanded by requestsViewModel.expanded.collectAsStateWithLifecycle() val deckNow by requestsViewModel.now.collectAsStateWithLifecycle() val drawnDeck = remember(deck) { deck.filter { it.deckTitle != null } } - var atNewest by remember { mutableStateOf(true) } val viewerId = requestsViewModel.viewerId - val owes = drawnDeck.any { it.unpaidShareOf(viewerId, deckNow) != null } - val deckPlacement = deckPlacement(owes = owes, atNewest = atNewest) + // 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 -> @@ -502,9 +516,8 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { deck = drawnDeck, now = deckNow, viewerId = viewerId, - placement = deckPlacement, - expanded = deckExpanded, - onExpand = { keyboard.hideIfVisible { requestsViewModel.setExpanded(true) } }, + transition = deckTransition, + onExpand = expandDeck, onCycle = requestsViewModel::cycle, modifier = Modifier.onGloballyPositioned { // Kept from the last frame it had a size; it empties while expanded. @@ -519,9 +532,9 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { deck = drawnDeck, now = deckNow, viewerId = viewerId, - placement = deckPlacement, - expanded = deckExpanded, - onExpand = { keyboard.hideIfVisible { requestsViewModel.setExpanded(true) } }, + transition = deckTransition, + rest = deckHosts.rest.host, + onExpand = expandDeck, onCycle = requestsViewModel::cycle, onPay = { request -> state.chatId?.let { chatId -> @@ -530,6 +543,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } }, + onHeightChange = { deckHosts.stripPx = it.toFloat() }, ) } // Hold the last list through the exit, so the card doesn't empty as it slides away. @@ -616,6 +630,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 @@ -639,7 +654,6 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { onReturnToOrigin = { viewModel.dispatchEvent(ChatViewModel.Event.ReturnToOrigin) }, onOriginsReached = { viewModel.dispatchEvent(ChatViewModel.Event.ReturnOriginsReached(it)) }, onReachedNewest = { viewModel.dispatchEvent(ChatViewModel.Event.ReturnStackCleared) }, - onAtNewestChanged = { atNewest = it }, hazeState = hazeState, ) } @@ -663,6 +677,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { deck = drawnDeck, now = deckNow, expanded = deckExpanded, + transition = deckTransition, top = barHeight, pill = deckPill, onCollapse = { requestsViewModel.setExpanded(false) }, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 07722a0dc2..af6f409f39 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -120,7 +120,6 @@ internal fun MessageList( onReturnToOrigin: () -> Unit = {}, onOriginsReached: (Set<Long>) -> Unit = {}, onReachedNewest: () -> Unit = {}, - onAtNewestChanged: (Boolean) -> Unit = {}, hazeState: HazeState? = null, ) { val keyboard = rememberKeyboardController() @@ -210,14 +209,6 @@ internal fun MessageList( .distinctUntilChanged() .collect { atNewest -> if (atNewest && currentReturnStack.isNotEmpty()) currentOnReachedNewest() } } - // Whether the newest message is on screen, however the list got there, for the request deck - // to sit in the strip or shrink to the app bar. - val currentOnAtNewestChanged by rememberUpdatedState(onAtNewestChanged) - LaunchedEffect(listState) { - snapshotFlow { !hasPositioned || listState.firstVisibleItemIndex == 0 } - .distinctUntilChanged() - .collect { currentOnAtNewestChanged(it) } - } // A tap on the button that found no origin left in the chat goes to the bottom, as one with // an empty stack does. LaunchedEffect(state.newestRequests) { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckPlacementTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckPlacementTest.kt deleted file mode 100644 index 7b005ff4a1..0000000000 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckPlacementTest.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.flipcash.app.messenger.internal.request - -import org.junit.Assert.assertEquals -import org.junit.Test - -class DeckPlacementTest { - - @Test - fun `a viewer who owes sees the strip at the newest messages`() { - assertEquals(DeckPlacement.Strip, deckPlacement(owes = true, atNewest = true)) - } - - @Test - fun `scrolling away shrinks the strip into the grey pill`() { - assertEquals(DeckPlacement.Pill, deckPlacement(owes = true, atNewest = false)) - } - - @Test - fun `a viewer who owes nothing keeps the grey pill`() { - assertEquals(DeckPlacement.Pill, deckPlacement(owes = false, atNewest = true)) - assertEquals(DeckPlacement.Pill, deckPlacement(owes = false, atNewest = false)) - } -} 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<Byte>(1) + private val sunday = listOf<Byte>(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) + } +} From 329ed3aeeb3cbfb47bf653d1161a120008c71bde Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 11:41:54 -0400 Subject: [PATCH 39/47] feat(messenger): show the viewer's standing on deck cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each deck card now says where the viewer stands on it, so a member can tell which requests they've paid without opening each one (spec "Viewer status", Figma row G). - Paid share: a success badge with a black check at the glyph's bottom-right, ringed in the card surface, and "✓ You paid $5". - Owed share: "You owe $5" in green. Creator: "Your request" in grey. No share: nothing extra. - Pill and strip back cards: the standing leads the line and the " · $15 of $45" totals drop when the line doesn't fit. - Expanded cards keep "$15 paid, $45 requested" on the amounts line and put the standing at the footer's trailing end. - The deck's screen reader label adds the standing after the title. ViewerStanding is resolved in the request model; tests 29-32 cover paid, owed, creator and no share. --- .../core/src/main/res/values/strings.xml | 7 + .../messenger/internal/request/RequestDeck.kt | 96 ++++++++----- .../internal/screens/MessengerScreen.kt | 1 + .../chat/ui/request/PaymentRequestUi.kt | 39 ++++- .../shared/chat/ui/request/ViewerStatus.kt | 136 ++++++++++++++++++ .../models/paymentrequest/PaymentRequest.kt | 23 +++ .../paymentrequest/PaymentRequestTest.kt | 29 ++++ 7 files changed, 287 insertions(+), 44 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/ViewerStatus.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index a71faa4b96..1022ebe477 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1067,6 +1067,11 @@ </plurals> <!-- The owed deck: "You owe $20", then "You owe $20 · 2 days left". --> <string name="label_requestYouOwe">You owe %1$s</string> + <string name="label_requestYouPaid">✓ You paid %1$s</string> + <string name="label_requestYourRequest">Your request</string> + <string name="cd_requestYouPaid">you paid %1$s</string> + <string name="cd_requestYouOwe">you owe %1$s</string> + <string name="cd_requestYourRequest">your request</string> <string name="action_requestPay">Pay</string> <string name="cd_requestPayShare">Pay %1$s</string> <string name="cd_requestOwed">You owe %1$s for %2$s, %3$s</string> @@ -1074,6 +1079,8 @@ <string name="label_requestDeckProgress">%1$s of %2$s</string> <!-- Screen reader label for the deck: title, "<paid> of <requested> paid", status line. --> <string name="cd_requestDeck">%1$s, %2$s of %3$s paid, %4$s</string> + <!-- As cd_requestDeck, with the viewer's standing ("you paid $5") after the title. --> + <string name="cd_requestDeckViewer">%1$s, %2$s, %3$s of %4$s paid, %5$s</string> <plurals name="cd_requestDeckCount"> <item quantity="one">%1$d request</item> <item quantity="other">%1$d requests</item> 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 index f7ceeeb209..2d8b3144b2 100644 --- 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 @@ -83,13 +83,17 @@ 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.AnnotatedString 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 @@ -335,7 +339,7 @@ private fun DeckStack( val canCycle = deck.size > 1 val pillWidth = remember(scope) { PillWidth(scope) } - val label = deckLabel(deck, now, viewerId.takeIf { strip }) + 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 { @@ -519,14 +523,14 @@ private fun DeckCardFront( bodyModifier: Modifier = Modifier, ) { when (style) { - DeckPlacement.Pill, DeckPlacement.Expanded -> DeckFront(request = request, now = now, visibility = visibility, modifier = modifier) + 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 <share> · <time left>" - * and Pay; or "<paid> of <requested>" and no Pay on a card the viewer doesn't owe on. + * and Pay; or the viewer's status line and no Pay on a card the viewer doesn't owe on. */ @Composable private fun StripFront( @@ -540,6 +544,7 @@ private fun StripFront( ) { 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 @@ -561,6 +566,7 @@ private fun StripFront( 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)) { @@ -572,25 +578,31 @@ private fun StripFront( overflow = TextOverflow.Ellipsis, modifier = Modifier.deckShared(request.id, "title", visibility), ) - val line = if (share != null) { + 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 - buildAnnotatedString { - withStyle(SpanStyle(color = success)) { append(owe) } - if (timeLeft.isNotEmpty()) append(" · $timeLeft") - } + 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 { - AnnotatedString(request.progressLabel()) + ViewerStatusLine( + request = request, + standing = standing, + style = lineStyle, + color = CodeTheme.colors.textSecondary, + modifier = Modifier.deckShared(request.id, "amounts", visibility), + ) } - Text( - text = line, - style = CodeTheme.typography.textSmall.copy(fontWeight = FontWeight.Medium), - color = CodeTheme.colors.textSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.deckShared(request.id, "amounts", visibility), - ) } } if (share != null) { @@ -617,13 +629,6 @@ private fun StripFront( } } -/** "<paid> of <requested>". */ -@Composable -private fun PaymentRequest.progressLabel(): String { - val split = split ?: return "" - return stringResource(R.string.label_requestDeckProgress, split.paid.requestText(), split.requested.requestText()) -} - /** The status line without "to pay" ("2 days left"); the ended lines as they are. */ @Composable private fun StatusLine.timeLeftText(): String = when (this) { @@ -637,11 +642,13 @@ private fun StatusLine.timeLeftText(): String = when (this) { 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) @@ -662,6 +669,7 @@ private fun DeckFront( request = request, now = now, size = RING_SIZE, + paid = standing is ViewerStanding.Paid, modifier = Modifier .deckShared(request.id, "glyph", visibility, CircleShape), ) @@ -680,15 +688,11 @@ private fun DeckFront( .deckShared(request.id, "title", visibility), ) if (split != null) { - Text( - text = stringResource( - R.string.label_requestDeckProgress, - split.paid.requestText(), - split.requested.requestText(), - ), + ViewerStatusLine( + request = request, + standing = standing, style = CodeTheme.typography.caption, color = CodeTheme.colors.textSecondary, - maxLines = 1, modifier = Modifier .measuredUpTo(textMax) .deckShared(request.id, "amounts", visibility), @@ -738,7 +742,13 @@ private fun Modifier.measuredUpTo(max: Dp): Modifier = layout { measurable, cons * 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) { +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 @@ -761,23 +771,33 @@ internal fun RequestRing(request: PaymentRequest, now: Instant, size: Dp, modifi style = if (size > 30.dp) CodeTheme.typography.textMedium else CodeTheme.typography.caption, color = Color.Black, ) + if (paid) PaidBadge(glyph = size, ended = ended) } } /** - * "<N> requests, <title>, <paid> of <requested> paid, <status line>", or in the strip, where - * [viewerId] is given, "You owe <share> for <title>, <status line>". + * "<N> requests, <title>, <viewer status>, <paid> of <requested> paid, <status line>", the viewer + * status left out with no share; or in the [strip], on a share still owed, "You owe <share> for + * <title>, <status line>". */ @Composable -private fun deckLabel(deck: List<PaymentRequest>, now: Instant, viewerId: ID?): String { +private fun deckLabel(deck: List<PaymentRequest>, now: Instant, viewerId: ID?, strip: Boolean): String { val front = deck.first() val split = front.split - val share = front.unpaidShareOf(viewerId, now) + 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(), @@ -799,6 +819,7 @@ private fun deckLabel(deck: List<PaymentRequest>, now: Instant, viewerId: ID?): internal fun ExpandedRequests( deck: List<PaymentRequest>, now: Instant, + viewerId: ID?, expanded: Boolean, transition: Transition<DeckPlacement>, top: Dp, @@ -879,6 +900,7 @@ internal fun ExpandedRequests( 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), ) } } 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 692fb1479c..bc41cab3af 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 @@ -676,6 +676,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { ExpandedRequests( deck = drawnDeck, now = deckNow, + viewerId = viewerId, expanded = deckExpanded, transition = deckTransition, top = barHeight, 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 index a9d1b2b92f..5462c69d26 100644 --- 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 @@ -7,6 +7,7 @@ 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 @@ -32,6 +33,7 @@ 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 @@ -105,7 +107,8 @@ fun RequestProgressBar(fraction: Float, ended: Boolean, modifier: Modifier = Mod * 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. + * 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( @@ -117,6 +120,7 @@ fun PaymentRequestCardContent( 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) @@ -135,6 +139,7 @@ fun PaymentRequestCardContent( 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( @@ -160,11 +165,31 @@ fun PaymentRequestCardContent( modifier = if (loading) Modifier.width(160.dp).shimmer(RoundedCornerShape(4.dp)) else amountsModifier, ) RequestProgressBar(fraction = request?.split?.paidFraction ?: 0f, ended = ended, modifier = detailModifier) - 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, - ) + 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 " · <paid> of <requested>". 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/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 index 06ddb442a1..50d2ef3caf 100644 --- 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 @@ -198,6 +198,29 @@ 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 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 index a9fc52a2c0..eaa8a9c4a0 100644 --- 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 @@ -193,4 +193,33 @@ class PaymentRequestTest { 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), + ) } From b8f7c984f0aea01cc26e014328ce3949c22564aa Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 12:39:49 -0400 Subject: [PATCH 40/47] feat(messenger): redesign the Time To Pay sheet Replaces the six-row deadline list and the separate date-and-time picker with one sheet: preset chips from 1 Day to 4 Weeks, a grid of today through today + 28 days, and a Time row that appears for a custom day. The custom time defaults to 6:00 PM, is clamped to now + 1 h through now + 28 d - 5 min, and opens Material 3's time picker dialog. Spec tests 34-39 are in TimeToPayTest. --- .../core/src/main/res/values/strings.xml | 14 + .../internal/request/RequestMoneyScreen.kt | 214 +------- .../messenger/internal/request/TimeToPay.kt | 114 +++++ .../internal/request/TimeToPaySheet.kt | 455 ++++++++++++++++++ .../internal/request/TimeToPayTest.kt | 111 +++++ 5 files changed, 695 insertions(+), 213 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPay.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPaySheet.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/TimeToPayTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 1022ebe477..0119126eb3 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1121,6 +1121,20 @@ <string name="option_timeToPay_twoWeeks">2 Weeks</string> <string name="option_timeToPay_fourWeeks">4 Weeks</string> <string name="option_timeToPay_custom">Custom</string> + <plurals name="description_timeToPayPreset"> + <item quantity="one">Closes %1$d day after you send · around %2$s</item> + <item quantity="other">Closes %1$d days after you send · around %2$s</item> + </plurals> + <plurals name="description_timeToPayCustom"> + <item quantity="one">Custom · %1$d day from now</item> + <item quantity="other">Custom · %1$d days from now</item> + </plurals> + <string name="description_timeToPayCustomToday">Custom · today</string> + <string name="label_timeToPayTime">Time</string> + <string name="title_timeToPaySelectTime">Select time</string> + <string name="cd_timeToPayKeyboard">Type a time</string> + <string name="cd_timeToPayDial">Pick a time on the dial</string> + <string name="action_timeToPayOk">OK</string> <string name="title_sendRequestConfirm">Send Request?</string> <string name="description_sendRequestConfirm">You will receive and hold the money collected</string> <string name="action_sendRequest">Send Request</string> 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 index e499035023..6bd898ab5d 100644 --- 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 @@ -11,7 +11,6 @@ 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.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -21,56 +20,35 @@ 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.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.SelectableDates import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TimePicker -import androidx.compose.material3.Typography -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material3.rememberTimePickerState 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester 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.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.core.ui.DisplayTextInput 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 @@ -89,13 +67,10 @@ 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.ZonedDateTime import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import kotlin.time.Clock -import kotlin.time.Instant import kotlin.time.toJavaInstant -import kotlin.time.toKotlinInstant /** * Request Money, frames 03–06 and 25 of the group requests spec: history, then the create flow @@ -232,7 +207,7 @@ private fun RequestSheets(state: RequestMoneyViewModel.State, viewModel: Request when (state.sheet) { RequestMoneyViewModel.Sheet.None -> Unit RequestMoneyViewModel.Sheet.Deadline -> state.draft?.let { draft -> - DeadlineSheet( + TimeToPaySheet( current = draft.deadline, onChoose = viewModel::chooseDeadline, onDismiss = viewModel::dismissSheet, @@ -506,193 +481,6 @@ private fun RequestNameContent(title: String, onBack: () -> Unit, onDone: (Strin ) } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun DeadlineSheet( - current: NewRequestDraft.Deadline, - onChoose: (NewRequestDraft.Deadline) -> Unit, - onDismiss: () -> Unit, -) { - var pickingCustom by rememberSaveable { mutableStateOf(false) } - if (pickingCustom) { - CustomDeadlinePicker( - initial = (current as? NewRequestDraft.Deadline.Custom)?.at, - onPicked = { pickingCustom = false; onChoose(NewRequestDraft.Deadline.Custom(it)) }, - onDismiss = { pickingCustom = false }, - ) - return - } - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - containerColor = CodeTheme.colors.background, - ) { - Column(modifier = Modifier.navigationBarsPadding().padding(bottom = CodeTheme.dimens.grid.x4)) { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = CodeTheme.dimens.grid.x3), - text = stringResource(R.string.title_timeToPay), - style = CodeTheme.typography.screenTitle, - color = CodeTheme.colors.textMain, - textAlign = TextAlign.Center, - ) - val presets = listOf( - NewRequestDraft.Deadline.Preset.Day to R.string.option_timeToPay_day, - NewRequestDraft.Deadline.Preset.ThreeDays to R.string.option_timeToPay_threeDays, - NewRequestDraft.Deadline.Preset.Week to R.string.option_timeToPay_week, - NewRequestDraft.Deadline.Preset.TwoWeeks to R.string.option_timeToPay_twoWeeks, - NewRequestDraft.Deadline.Preset.FourWeeks to R.string.option_timeToPay_fourWeeks, - ) - presets.forEach { (preset, label) -> - DeadlineRow(stringResource(label), checked = current == preset) { onChoose(preset) } - } - DeadlineRow( - stringResource(R.string.option_timeToPay_custom), - checked = current is NewRequestDraft.Deadline.Custom, - ) { pickingCustom = true } - } - } -} - -@Composable -private fun DeadlineRow(label: String, checked: Boolean, onClick: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x3), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = Modifier.weight(1f), - text = label, - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - ) - if (checked) Image(painter = painterResource(R.drawable.ic_checked), contentDescription = null) - } -} - -/** - * The platform date picker, then its time picker, bounded to the spec's custom range. Not in the - * design; the spec asks for the standard pickers. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun CustomDeadlinePicker(initial: Instant?, onPicked: (Instant) -> Unit, onDismiss: () -> Unit) = PickerTheme { - CustomDeadlinePickerContent(initial, onPicked, onDismiss) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun CustomDeadlinePickerContent(initial: Instant?, onPicked: (Instant) -> Unit, onDismiss: () -> Unit) { - val range = remember { NewRequestDraft.Deadline.customRange(Clock.System.now()) } - val zone = ZoneId.systemDefault() - val start = remember { (initial ?: range.start).toJavaInstant().atZone(zone) } - var pickedDate by remember { mutableStateOf<java.time.LocalDate?>(null) } - - val date = pickedDate - if (date == null) { - val earliest = range.start.toJavaInstant().atZone(zone).toLocalDate() - val latest = range.endInclusive.toJavaInstant().atZone(zone).toLocalDate() - val dateState = rememberDatePickerState( - initialSelectedDateMillis = start.toLocalDate().atStartOfDay(ZoneId.of("UTC")).toInstant().toEpochMilli(), - selectableDates = object : SelectableDates { - override fun isSelectableDate(utcTimeMillis: Long): Boolean { - val day = java.time.Instant.ofEpochMilli(utcTimeMillis).atZone(ZoneId.of("UTC")).toLocalDate() - return !day.isBefore(earliest) && !day.isAfter(latest) - } - }, - ) - DatePickerDialog( - onDismissRequest = onDismiss, - confirmButton = { - TextButton(onClick = { - dateState.selectedDateMillis?.let { - pickedDate = java.time.Instant.ofEpochMilli(it).atZone(ZoneId.of("UTC")).toLocalDate() - } - }) { Text(stringResource(R.string.action_next)) } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }, - ) { DatePicker(state = dateState) } - return - } - - val timeState = rememberTimePickerState(initialHour = start.hour, initialMinute = start.minute) - Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = true)) { - Column( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceContainerHigh, CodeTheme.shapes.large) - .padding(CodeTheme.dimens.inset), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - TimePicker(state = timeState) - Row(horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2)) { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } - TextButton(onClick = { - val at = ZonedDateTime.of(date, java.time.LocalTime.of(timeState.hour, timeState.minute), zone) - .toInstant() - .toKotlinInstant() - onPicked(at.coerceIn(range.start, range.endInclusive)) - }) { Text(stringResource(R.string.action_done)) } - } - } - } -} - -/** - * 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.surfaceVariant, - onPrimaryContainer = colors.textMain, - secondaryContainer = colors.surfaceVariant, - onSecondaryContainer = colors.textMain, - tertiaryContainer = colors.surfaceVariant, - onTertiaryContainer = colors.textMain, - background = colors.background, - onBackground = colors.textMain, - surface = colors.background, - onSurface = colors.textMain, - surfaceVariant = colors.surfaceVariant, - onSurfaceVariant = colors.textSecondary, - surfaceContainer = colors.background, - surfaceContainerLow = colors.background, - surfaceContainerHigh = colors.background, - surfaceContainerHighest = colors.surfaceVariant, - 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, - ) -} - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ConfirmSendSheet(sending: Boolean, onSend: () -> Unit, onDismiss: () -> Unit) { 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..c6e9f976b6 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPay.kt @@ -0,0 +1,114 @@ +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. + */ +internal data class TimeToPay( + val now: Instant, + val zone: ZoneId, + val choice: Choice, +) { + 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() } + + /** 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 custom time or 6:00 PM, moved inside the + * custom bounds. + */ + fun chooseDay(day: LocalDate): TimeToPay { + if (!enabled(day)) return this + val time = (choice as? Choice.Custom)?.time ?: DEFAULT_TIME + return copy(choice = clamped(day, time)) + } + + /** The Time row's new time for the custom day, moved inside the custom bounds. */ + fun chooseTime(time: LocalTime): TimeToPay { + val custom = choice as? Choice.Custom ?: return this + return copy(choice = clamped(custom.day, time)) + } + + /** 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) + } + + /** The grid's 35 days, from the locale's first weekday of the week that holds [today]. */ + fun grid(today: LocalDate, firstDayOfWeek: DayOfWeek): List<LocalDate> { + 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..8e59df3c5b --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPaySheet.kt @@ -0,0 +1,455 @@ +package com.flipcash.app.messenger.internal.request + +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.aspectRatio +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.runtime.Composable +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.draw.clip +import androidx.compose.ui.graphics.Color +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.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +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 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: a readout, the preset chips, a grid of the days a request can run to, a Time row + * for a custom day and Done. 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) } + + 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, + ) + Readout(sheet) + PresetChips( + selected = (sheet.choice as? TimeToPay.Choice.Preset)?.preset, + onChoose = { sheet = sheet.choosePreset(it) }, + ) + DayGrid(sheet, onChoose = { sheet = sheet.chooseDay(it) }) + (sheet.choice as? TimeToPay.Choice.Custom)?.let { custom -> + TimeRow(time = custom.time, onClick = { pickingTime = true }) + } + CodeButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.action_done), + onClick = { onChoose(sheet.toDeadline()) }, + ) + } + } + + val custom = sheet.choice as? TimeToPay.Choice.Custom + if (pickingTime && custom != null) { + TimeDialog( + initial = custom.time, + onPicked = { pickingTime = false; sheet = sheet.chooseTime(it) }, + onDismiss = { pickingTime = false }, + ) + } +} + +@Composable +private fun Readout(sheet: TimeToPay) { + val (large, small) = when (val choice = sheet.choice) { + is TimeToPay.Choice.Preset -> { + val around = sheet.selectedDay.format(DateTimeFormatter.ofPattern(bestPattern("EEEMMMd"))) + stringResource(choice.preset.label) to pluralStringResource( + R.plurals.description_timeToPayPreset, + choice.preset.daysToPay, + choice.preset.daysToPay, + around, + ) + } + is TimeToPay.Choice.Custom -> { + val days = sheet.daysFromNow.toInt() + untilText(sheet.customAt!!) to + if (days == 0) stringResource(R.string.description_timeToPayCustomToday) + else pluralStringResource(R.plurals.description_timeToPayCustom, days, days) + } + } + Column( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { liveRegion = LiveRegionMode.Polite }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Text(text = large, style = CodeTheme.typography.displaySmall, color = CodeTheme.colors.textMain, maxLines = 1) + Text( + text = small, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun PresetChips(selected: Deadline.Preset?, onChoose: (Deadline.Preset) -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + ) { + Deadline.Preset.entries.forEach { preset -> + val isSelected = preset == selected + Text( + modifier = Modifier + .clip(CircleShape) + .background(if (isSelected) White else White10) + .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 = if (isSelected) Color.Black else CodeTheme.colors.textMain, + maxLines = 1, + ) + } + } +} + +@Composable +private fun DayGrid(sheet: TimeToPay, onChoose: (LocalDate) -> Unit) { + val locale = Locale.getDefault() + val days = remember(sheet.today) { TimeToPay.grid(sheet.today, WeekFields.of(locale).firstDayOfWeek) } + val selected = sheet.selectedDay + val monthLabel = remember { DateTimeFormatter.ofPattern("MMM", locale) } + val fullDate = remember { DateTimeFormatter.ofPattern(bestPattern("EEEEMMMMd"), locale) } + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(White05) + .padding(CodeTheme.dimens.grid.x2), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + 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, + ) + } + } + days.chunked(7).forEach { week -> + Row(modifier = Modifier.fillMaxWidth()) { + week.forEach { day -> + DayCell( + modifier = Modifier.weight(1f), + day = day, + enabled = sheet.enabled(day), + today = day == sheet.today, + selected = day == selected, + between = day.isAfter(sheet.today) && day.isBefore(selected), + month = if (day.dayOfMonth == 1) day.format(monthLabel).uppercase(locale) else null, + label = day.format(fullDate), + onClick = { onChoose(day) }, + ) + } + } + } + } +} + +@Composable +private fun DayCell( + day: LocalDate, + enabled: Boolean, + today: Boolean, + selected: Boolean, + between: Boolean, + month: String?, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .aspectRatio(1f) + .padding(2.dp) + .then(if (between) Modifier.clip(RoundedCornerShape(6.dp)).background(White10) else Modifier) + .then( + if (enabled) { + Modifier + .clickable(role = Role.Button, onClick = onClick) + .clearAndSetSemantics { + contentDescription = label + role = Role.Button + this.selected = selected + } + } else { + Modifier.clearAndSetSemantics { invisibleToUser() } + } + ), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(36.dp) + .then( + when { + selected -> Modifier.clip(CircleShape).background(White) + today -> Modifier.border(1.dp, CodeTheme.colors.textSecondary, CircleShape) + else -> Modifier + } + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = day.dayOfMonth.toString(), + style = CodeTheme.typography.textMedium, + color = when { + selected -> Color.Black + enabled -> CodeTheme.colors.textMain + else -> CodeTheme.colors.textSecondary.copy(alpha = 0.5f) + }, + ) + } + if (month != null && !selected) { + Text( + modifier = Modifier.align(Alignment.TopCenter).padding(top = 1.dp), + text = month, + style = CodeTheme.typography.caption.copy(fontSize = CodeTheme.typography.caption.fontSize * 0.75f), + color = CodeTheme.colors.textSecondary, + ) + } + } +} + +@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/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..dee86ba462 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/TimeToPayTest.kt @@ -0,0 +1,111 @@ +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 kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.toKotlinInstant + +/** Spec "Deadline sheet", tests 34–39. 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) + } + + @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) + } + + @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()) + } + + @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 `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()) + } +} From a62768960e6f31255e3f229ba3ba6a5bc2047fd1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 12:56:13 -0400 Subject: [PATCH 41/47] feat(messenger): show the Time To Pay deadline as a range The readout above the calendar becomes two fields: Starts reads "Today, on send", and Closes shows the deadline with a white outline and is announced live. A summary line under them names the preset or the custom day count. The calendar marks today with a grey circle and the closing day with a white one, joined by a 12% white band that breaks at each week's edge. Picking today shows the white circle alone. The Closes value shrinks to fit instead of cutting off a custom day and time on a phone-width field. --- .../core/src/main/res/values/strings.xml | 12 +- .../messenger/internal/request/TimeToPay.kt | 23 +++ .../internal/request/TimeToPaySheet.kt | 179 +++++++++++++----- .../internal/request/TimeToPayTest.kt | 24 ++- 4 files changed, 187 insertions(+), 51 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 0119126eb3..e1a41abfc1 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1122,14 +1122,18 @@ <string name="option_timeToPay_fourWeeks">4 Weeks</string> <string name="option_timeToPay_custom">Custom</string> <plurals name="description_timeToPayPreset"> - <item quantity="one">Closes %1$d day after you send · around %2$s</item> - <item quantity="other">Closes %1$d days after you send · around %2$s</item> + <item quantity="one">%2$s · closes %1$d day after you send</item> + <item quantity="other">%2$s · closes %1$d days after you send</item> </plurals> <plurals name="description_timeToPayCustom"> - <item quantity="one">Custom · %1$d day from now</item> - <item quantity="other">Custom · %1$d days from now</item> + <item quantity="one">Custom · %1$d day</item> + <item quantity="other">Custom · %1$d days</item> </plurals> <string name="description_timeToPayCustomToday">Custom · today</string> + <string name="label_timeToPayStarts">Starts</string> + <string name="value_timeToPayStarts">Today, on send</string> + <string name="label_timeToPayCloses">Closes</string> + <string name="value_timeToPayClosesAround">≈ %1$s</string> <string name="label_timeToPayTime">Time</string> <string name="title_timeToPaySelectTime">Select time</string> <string name="cd_timeToPayKeyboard">Type a time</string> 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 index c6e9f976b6..3da2f0730b 100644 --- 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 @@ -46,6 +46,29 @@ internal data class TimeToPay( 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 + } + } + + /** + * 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) + } + } + /** Calendar days from today to the custom day. */ val daysFromNow: Long get() = ChronoUnit.DAYS.between(today, selectedDay) 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 index 8e59df3c5b..08b2b4f828 100644 --- 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 @@ -41,6 +41,11 @@ 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.RoundRect +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -53,8 +58,13 @@ 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.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 @@ -75,9 +85,9 @@ import kotlin.time.Clock import kotlin.time.toJavaInstant /** - * Time To Pay: a readout, the preset chips, a grid of the days a request can run to, a Time row - * for a custom day and Done. Figma row H (nodes 12704:67, 12704:204, 12708:236). Done applies the - * choice; any other dismissal keeps [current]. + * 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, a Time row for a custom day and Done. Figma row H (nodes + * 12704:67, 12704:204, 12708:236). Done applies the choice; any other dismissal keeps [current]. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -109,7 +119,7 @@ internal fun TimeToPaySheet( color = CodeTheme.colors.textMain, textAlign = TextAlign.Center, ) - Readout(sheet) + RangeFields(sheet) PresetChips( selected = (sheet.choice as? TimeToPay.Choice.Preset)?.preset, onChoose = { sheet = sheet.choosePreset(it) }, @@ -137,41 +147,84 @@ internal fun TimeToPaySheet( } @Composable -private fun Readout(sheet: TimeToPay) { - val (large, small) = when (val choice = sheet.choice) { +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"))) - stringResource(choice.preset.label) to pluralStringResource( + closes = stringResource(R.string.value_timeToPayClosesAround, around) + summary = pluralStringResource( R.plurals.description_timeToPayPreset, choice.preset.daysToPay, choice.preset.daysToPay, - around, + 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() - untilText(sheet.customAt!!) to - if (days == 0) stringResource(R.string.description_timeToPayCustomToday) - else pluralStringResource(R.plurals.description_timeToPayCustom, days, days) + summary = if (days == 0) stringResource(R.string.description_timeToPayCustomToday) + else pluralStringResource(R.plurals.description_timeToPayCustom, days, days) } } - Column( - modifier = Modifier - .fillMaxWidth() - .semantics(mergeDescendants = true) { liveRegion = LiveRegionMode.Polite }, - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), - ) { - Text(text = large, style = CodeTheme.typography.displaySmall, color = CodeTheme.colors.textMain, maxLines = 1) + 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( - text = small, - style = CodeTheme.typography.textSmall, + 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, + ) + } +} + @Composable private fun PresetChips(selected: Deadline.Preset?, onChoose: (Deadline.Preset) -> Unit) { Row( @@ -200,7 +253,6 @@ private fun PresetChips(selected: Deadline.Preset?, onChoose: (Deadline.Preset) private fun DayGrid(sheet: TimeToPay, onChoose: (LocalDate) -> Unit) { val locale = Locale.getDefault() val days = remember(sheet.today) { TimeToPay.grid(sheet.today, WeekFields.of(locale).firstDayOfWeek) } - val selected = sheet.selectedDay val monthLabel = remember { DateTimeFormatter.ofPattern("MMM", locale) } val fullDate = remember { DateTimeFormatter.ofPattern(bestPattern("EEEEMMMMd"), locale) } Column( @@ -224,14 +276,14 @@ private fun DayGrid(sheet: TimeToPay, onChoose: (LocalDate) -> Unit) { } days.chunked(7).forEach { week -> Row(modifier = Modifier.fillMaxWidth()) { - week.forEach { day -> + week.forEachIndexed { index, day -> DayCell( modifier = Modifier.weight(1f), day = day, enabled = sheet.enabled(day), - today = day == sheet.today, - selected = day == selected, - between = day.isAfter(sheet.today) && day.isBefore(selected), + range = sheet.range(day), + firstInRow = index == 0, + lastInRow = index == week.lastIndex, month = if (day.dayOfMonth == 1) day.format(monthLabel).uppercase(locale) else null, label = day.format(fullDate), onClick = { onChoose(day) }, @@ -246,19 +298,19 @@ private fun DayGrid(sheet: TimeToPay, onChoose: (LocalDate) -> Unit) { private fun DayCell( day: LocalDate, enabled: Boolean, - today: Boolean, - selected: Boolean, - between: Boolean, + range: TimeToPay.RangeCell, + firstInRow: Boolean, + lastInRow: Boolean, month: String?, label: String, onClick: () -> Unit, modifier: Modifier = Modifier, ) { + val selected = range.mark == TimeToPay.RangeCell.Mark.End Box( modifier = modifier .aspectRatio(1f) - .padding(2.dp) - .then(if (between) Modifier.clip(RoundedCornerShape(6.dp)).background(White10) else Modifier) + .drawBehind { rangeBand(range, firstInRow, lastInRow) } .then( if (enabled) { Modifier @@ -274,29 +326,25 @@ private fun DayCell( ), contentAlignment = Alignment.Center, ) { + val (fill, text) = when (range.mark) { + TimeToPay.RangeCell.Mark.End -> White to Color.Black + TimeToPay.RangeCell.Mark.Start -> RangeStart to White + TimeToPay.RangeCell.Mark.None -> Color.Transparent to + if (enabled) CodeTheme.colors.textMain else CodeTheme.colors.textSecondary.copy(alpha = 0.5f) + } Box( - modifier = Modifier - .size(36.dp) - .then( - when { - selected -> Modifier.clip(CircleShape).background(White) - today -> Modifier.border(1.dp, CodeTheme.colors.textSecondary, CircleShape) - else -> Modifier - } - ), + modifier = Modifier.size(RangeHeight).clip(CircleShape).background(fill), contentAlignment = Alignment.Center, ) { Text( text = day.dayOfMonth.toString(), - style = CodeTheme.typography.textMedium, - color = when { - selected -> Color.Black - enabled -> CodeTheme.colors.textMain - else -> CodeTheme.colors.textSecondary.copy(alpha = 0.5f) + style = CodeTheme.typography.textMedium.let { + if (range.mark == TimeToPay.RangeCell.Mark.None) it else it.copy(fontWeight = FontWeight.Bold) }, + color = text, ) } - if (month != null && !selected) { + if (month != null && range.mark == TimeToPay.RangeCell.Mark.None) { Text( modifier = Modifier.align(Alignment.TopCenter).padding(top = 1.dp), text = month, @@ -307,6 +355,45 @@ private fun DayCell( } } +/** + * The cell's piece of the range band: from its centre to whichever edges the range crosses. At a + * row's edge the band ends rounded, so a range that wraps reads as one band across both rows. + */ +private fun DrawScope.rangeBand(range: TimeToPay.RangeCell, firstInRow: Boolean, lastInRow: Boolean) { + if (!range.leading && !range.trailing) return + val height = RangeHeight.toPx() + val top = (size.height - height) / 2 + val centre = size.width / 2 + val rtl = layoutDirection == LayoutDirection.Rtl + // In RTL the leading edge is on the right. + val startEdge = if (range.leading) (if (rtl) size.width else 0f) else centre + val endEdge = if (range.trailing) (if (rtl) 0f else size.width) else centre + val round = CornerRadius(height / 2) + val roundLeading = range.leading && firstInRow + val roundTrailing = range.trailing && lastInRow + val roundLeft = if (rtl) roundTrailing else roundLeading + val roundRight = if (rtl) roundLeading else roundTrailing + val path = Path().apply { + addRoundRect( + RoundRect( + left = minOf(startEdge, endEdge), + top = top, + right = maxOf(startEdge, endEdge), + bottom = top + height, + topLeftCornerRadius = if (roundLeft) round else CornerRadius.Zero, + bottomLeftCornerRadius = if (roundLeft) round else CornerRadius.Zero, + topRightCornerRadius = if (roundRight) round else CornerRadius.Zero, + bottomRightCornerRadius = if (roundRight) round else CornerRadius.Zero, + ) + ) + } + drawPath(path, RangeBand) +} + +private val RangeHeight = 34.dp +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)) 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 index dee86ba462..214f99f125 100644 --- 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 @@ -8,13 +8,15 @@ import java.time.LocalDateTime import java.time.LocalTime import java.time.ZoneOffset import kotlin.test.Test +import com.flipcash.app.messenger.internal.request.TimeToPay.RangeCell +import com.flipcash.app.messenger.internal.request.TimeToPay.RangeCell.Mark 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–39. UTC so 28 days never cross a clock change. */ +/** Spec "Deadline sheet", tests 34–40. UTC so 28 days never cross a clock change. */ class TimeToPayTest { private val zone = ZoneOffset.UTC @@ -41,6 +43,13 @@ class TimeToPayTest { assertEquals(Choice.Preset(Deadline.Preset.Week), sheet.choice) assertEquals(date(11, 2), sheet.selectedDay) assertNull(sheet.customAt) + 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 @@ -49,6 +58,8 @@ class TimeToPayTest { 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 @@ -72,6 +83,17 @@ class TimeToPayTest { assertEquals(Choice.Preset(Deadline.Preset.TwoWeeks), sheet.choice) assertEquals(date(11, 9), sheet.selectedDay) assertEquals(Deadline.Preset.TwoWeeks, sheet.toDeadline()) + 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 From 4a27b2092d24c3c430a3eb8ebf5940daa5b39404 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 14:18:44 -0400 Subject: [PATCH 42/47] feat(messenger): show the Time row for presets and slide the deadline selection Time To Pay now always shows the Time row. A preset reads the current time of day; changing it makes the preset's closing day custom at that time, clamped as before. A tapped day keeps the last time set. The chip highlight, the closing-day circle and the range band now move to a new choice. The band draws as one span per week row so it has no seams while it moves. With animations off they jump straight there. Spec tests 34 and 38 now expect the Time row; 41-45 are new. --- .../messenger/internal/request/TimeToPay.kt | 53 +++- .../internal/request/TimeToPaySheet.kt | 242 ++++++++++++------ .../internal/request/TimeToPayTest.kt | 52 +++- .../shared/chat/ui/media/ReducedMotion.kt | 2 +- 4 files changed, 252 insertions(+), 97 deletions(-) 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 index 3da2f0730b..191e4f2610 100644 --- 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 @@ -17,11 +17,13 @@ import kotlin.time.toKotlinInstant * 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 @@ -61,6 +63,9 @@ internal data class TimeToPay( * 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. */ + /** One week row's part of the range band, from [from] to [to] in columns from the row's start. */ + data class BandSpan(val row: Int, val from: Float, val to: Float) + data class RangeCell(val mark: Mark, val leading: Boolean, val trailing: Boolean) { enum class Mark { None, Start, End } @@ -69,26 +74,36 @@ internal data class TimeToPay( } } + /** + * 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 custom time or 6:00 PM, moved inside the - * custom bounds. + * 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 - val time = (choice as? Choice.Custom)?.time ?: DEFAULT_TIME - return copy(choice = clamped(day, time)) + return copy(choice = clamped(day, setTime ?: DEFAULT_TIME)) } - /** The Time row's new time for the custom day, moved inside the custom bounds. */ - fun chooseTime(time: LocalTime): TimeToPay { - val custom = choice as? Choice.Custom ?: return this - return copy(choice = clamped(custom.day, 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) { @@ -125,10 +140,28 @@ internal data class TimeToPay( Choice.Custom(it.toLocalDate(), it.toLocalTime().truncatedTo(ChronoUnit.MINUTES)) } } - return TimeToPay(now, zone, choice) + 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<BandSpan> { + 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, start - rowStart, end - rowStart) else null + } + } + fun grid(today: LocalDate, firstDayOfWeek: DayOfWeek): List<LocalDate> { 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 index 08b2b4f828..4b0dad5feb 100644 --- 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 @@ -1,5 +1,12 @@ 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 @@ -34,7 +41,9 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTimePickerState 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 @@ -43,8 +52,12 @@ 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.RoundRect -import androidx.compose.ui.graphics.Path +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +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.res.pluralStringResource @@ -73,6 +86,9 @@ 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.abs import java.time.LocalDate import java.time.LocalTime import java.time.ZoneId @@ -86,7 +102,8 @@ 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, a Time row for a custom day and Done. Figma row H (nodes + * 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) @@ -99,6 +116,7 @@ internal fun TimeToPaySheet( 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, @@ -122,12 +140,11 @@ internal fun TimeToPaySheet( RangeFields(sheet) PresetChips( selected = (sheet.choice as? TimeToPay.Choice.Preset)?.preset, + reducedMotion = reducedMotion, onChoose = { sheet = sheet.choosePreset(it) }, ) - DayGrid(sheet, onChoose = { sheet = sheet.chooseDay(it) }) - (sheet.choice as? TimeToPay.Choice.Custom)?.let { custom -> - TimeRow(time = custom.time, onClick = { pickingTime = true }) - } + 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), @@ -136,10 +153,9 @@ internal fun TimeToPaySheet( } } - val custom = sheet.choice as? TimeToPay.Choice.Custom - if (pickingTime && custom != null) { + if (pickingTime) { TimeDialog( - initial = custom.time, + initial = sheet.time, onPicked = { pickingTime = false; sheet = sheet.chooseTime(it) }, onDismiss = { pickingTime = false }, ) @@ -225,43 +241,97 @@ private fun RangeField(label: String, value: String, outlined: Boolean, modifier } } +/** + * 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?, onChoose: (Deadline.Preset) -> Unit) { +private fun PresetChips(selected: Deadline.Preset?, reducedMotion: Boolean, onChoose: (Deadline.Preset) -> Unit) { + val bounds = remember { mutableStateMapOf<Deadline.Preset, Rect>() } + 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<Float>(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()), + 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) - .background(if (isSelected) White else White10) .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 = if (isSelected) Color.Black else CodeTheme.colors.textMain, + 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, onChoose: (LocalDate) -> Unit) { +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(CodeTheme.dimens.grid.x1), + verticalArrangement = Arrangement.spacedBy(gap), ) { Row(modifier = Modifier.fillMaxWidth().semantics { invisibleToUser() }) { days.take(7).forEach { day -> @@ -274,43 +344,79 @@ private fun DayGrid(sheet: TimeToPay, onChoose: (LocalDate) -> Unit) { ) } } - days.chunked(7).forEach { week -> - Row(modifier = Modifier.fillMaxWidth()) { - week.forEachIndexed { index, day -> - DayCell( - modifier = Modifier.weight(1f), - day = day, - enabled = sheet.enabled(day), - range = sheet.range(day), - firstInRow = index == 0, - lastInRow = index == week.lastIndex, - month = if (day.dayOfMonth == 1) day.format(monthLabel).uppercase(locale) else null, - label = day.format(fullDate), - onClick = { onChoose(day) }, - ) + Column( + modifier = Modifier + .fillMaxWidth() + .drawBehind { rangeLayer(startIndex, endIndex, bandEnd.value, circle.value, gap.toPx()) }, + verticalArrangement = Arrangement.spacedBy(gap), + ) { + days.chunked(7).forEachIndexed { row, week -> + Row(modifier = Modifier.fillMaxWidth()) { + week.forEachIndexed { column, day -> + val index = row * 7 + column + // The day the moving circle is over reads black on it. + val underCircle = abs(circle.value.x - column) < 0.5f && abs(circle.value.y - row) < 0.5f + DayCell( + modifier = Modifier.weight(1f), + day = day, + enabled = sheet.enabled(day), + mark = when { + underCircle -> TimeToPay.RangeCell.Mark.End + index == startIndex && endIndex != startIndex -> TimeToPay.RangeCell.Mark.Start + else -> TimeToPay.RangeCell.Mark.None + }, + selected = index == endIndex, + month = if (day.dayOfMonth == 1) day.format(monthLabel).uppercase(locale) else null, + label = day.format(fullDate), + onClick = { onChoose(day) }, + ) + } } } } } } +private fun cellOf(index: Int) = Offset((index % 7).toFloat(), (index / 7).toFloat()) + +/** 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, gap: Float) { + val cell = size.width / 7 + val height = RangeHeight.toPx() + val rtl = layoutDirection == LayoutDirection.Rtl + fun x(column: Float) = if (rtl) size.width - column * cell else column * cell + fun rowTop(row: Float) = row * (cell + 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)) + drawRoundRect( + color = RangeBand, + topLeft = Offset(left, rowTop(span.row.toFloat()) + (cell - height) / 2), + size = Size(right - left, height), + cornerRadius = CornerRadius(height / 2), + ) + } + if (endIndex != startIndex) { + val start = cellOf(startIndex) + drawCircle(RangeStart, height / 2, Offset(x(start.x + 0.5f), rowTop(start.y) + cell / 2)) + } + drawCircle(White, height / 2, Offset(x(circle.x + 0.5f), rowTop(circle.y) + cell / 2)) +} + @Composable private fun DayCell( day: LocalDate, enabled: Boolean, - range: TimeToPay.RangeCell, - firstInRow: Boolean, - lastInRow: Boolean, + mark: TimeToPay.RangeCell.Mark, + selected: Boolean, month: String?, label: String, onClick: () -> Unit, modifier: Modifier = Modifier, ) { - val selected = range.mark == TimeToPay.RangeCell.Mark.End Box( modifier = modifier .aspectRatio(1f) - .drawBehind { rangeBand(range, firstInRow, lastInRow) } .then( if (enabled) { Modifier @@ -326,25 +432,20 @@ private fun DayCell( ), contentAlignment = Alignment.Center, ) { - val (fill, text) = when (range.mark) { - TimeToPay.RangeCell.Mark.End -> White to Color.Black - TimeToPay.RangeCell.Mark.Start -> RangeStart to White - TimeToPay.RangeCell.Mark.None -> Color.Transparent to + val text = when (mark) { + TimeToPay.RangeCell.Mark.End -> Color.Black + TimeToPay.RangeCell.Mark.Start -> White + TimeToPay.RangeCell.Mark.None -> if (enabled) CodeTheme.colors.textMain else CodeTheme.colors.textSecondary.copy(alpha = 0.5f) } - Box( - modifier = Modifier.size(RangeHeight).clip(CircleShape).background(fill), - contentAlignment = Alignment.Center, - ) { - Text( - text = day.dayOfMonth.toString(), - style = CodeTheme.typography.textMedium.let { - if (range.mark == TimeToPay.RangeCell.Mark.None) it else it.copy(fontWeight = FontWeight.Bold) - }, - color = text, - ) - } - if (month != null && range.mark == TimeToPay.RangeCell.Mark.None) { + Text( + text = day.dayOfMonth.toString(), + style = CodeTheme.typography.textMedium.let { + if (mark == TimeToPay.RangeCell.Mark.None) it else it.copy(fontWeight = FontWeight.Bold) + }, + color = text, + ) + if (month != null && mark == TimeToPay.RangeCell.Mark.None) { Text( modifier = Modifier.align(Alignment.TopCenter).padding(top = 1.dp), text = month, @@ -355,39 +456,10 @@ private fun DayCell( } } -/** - * The cell's piece of the range band: from its centre to whichever edges the range crosses. At a - * row's edge the band ends rounded, so a range that wraps reads as one band across both rows. - */ -private fun DrawScope.rangeBand(range: TimeToPay.RangeCell, firstInRow: Boolean, lastInRow: Boolean) { - if (!range.leading && !range.trailing) return - val height = RangeHeight.toPx() - val top = (size.height - height) / 2 - val centre = size.width / 2 - val rtl = layoutDirection == LayoutDirection.Rtl - // In RTL the leading edge is on the right. - val startEdge = if (range.leading) (if (rtl) size.width else 0f) else centre - val endEdge = if (range.trailing) (if (rtl) 0f else size.width) else centre - val round = CornerRadius(height / 2) - val roundLeading = range.leading && firstInRow - val roundTrailing = range.trailing && lastInRow - val roundLeft = if (rtl) roundTrailing else roundLeading - val roundRight = if (rtl) roundLeading else roundTrailing - val path = Path().apply { - addRoundRect( - RoundRect( - left = minOf(startEdge, endEdge), - top = top, - right = maxOf(startEdge, endEdge), - bottom = top + height, - topLeftCornerRadius = if (roundLeft) round else CornerRadius.Zero, - bottomLeftCornerRadius = if (roundLeft) round else CornerRadius.Zero, - topRightCornerRadius = if (roundRight) round else CornerRadius.Zero, - bottomRightCornerRadius = if (roundRight) round else CornerRadius.Zero, - ) - ) - } - drawPath(path, RangeBand) +/** How the sheet's selection moves: a spring, or straight to the new place with reduce motion on. */ +internal object TimeToPayMotion { + fun <T> spec(reducedMotion: Boolean): AnimationSpec<T> = + if (reducedMotion) snap() else spring(Spring.DampingRatioNoBouncy, Spring.StiffnessMediumLow) } private val RangeHeight = 34.dp 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 index 214f99f125..382d86051b 100644 --- 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 @@ -8,6 +8,7 @@ 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.RangeCell.Mark import kotlin.test.assertEquals @@ -16,7 +17,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.toKotlinInstant -/** Spec "Deadline sheet", tests 34–40. UTC so 28 days never cross a clock change. */ +/** Spec "Deadline sheet", tests 34–45. UTC so 28 days never cross a clock change. */ class TimeToPayTest { private val zone = ZoneOffset.UTC @@ -43,6 +44,8 @@ class TimeToPayTest { 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))) @@ -83,6 +86,7 @@ class TimeToPayTest { 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))) } @@ -115,6 +119,52 @@ class TimeToPayTest { 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), TimeToPay.BandSpan(1, 0f, 1.5f)), + 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 44 - reduce motion moves the selection without animating`() { + assertTrue(TimeToPayMotion.spec<Float>(reducedMotion = true) is SnapSpec) + assertFalse(TimeToPayMotion.spec<Float>(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 `a disabled day is ignored`() { val sheet = openedAt(10, 26, 11, 39) 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 { From be0b08f3b943e551bcd6f4d8ed490993b4d0387f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 14:29:40 -0400 Subject: [PATCH 43/47] fix(messenger): turn a day black only as the sliding circle settles on it While the closing-day circle slid across the grid, each day it passed turned black for a frame before the circle reached it. The text colour now blends from white to black over the last quarter cell of the circle's approach. A band span that had just started growing into a row drew as a thin capsule at the row's edge. Spans narrower than the band is tall now fade in with their width. --- .../internal/request/TimeToPaySheet.kt | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) 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 index 4b0dad5feb..a47b18495c 100644 --- 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 @@ -60,6 +60,7 @@ 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.lerp import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.LiveRegionMode @@ -88,7 +89,7 @@ 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.abs +import kotlin.math.hypot import java.time.LocalDate import java.time.LocalTime import java.time.ZoneId @@ -354,18 +355,19 @@ private fun DayGrid(sheet: TimeToPay, reducedMotion: Boolean, onChoose: (LocalDa Row(modifier = Modifier.fillMaxWidth()) { week.forEachIndexed { column, day -> val index = row * 7 + column - // The day the moving circle is over reads black on it. - val underCircle = abs(circle.value.x - column) < 0.5f && abs(circle.value.y - row) < 0.5f + // 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 { - underCircle -> TimeToPay.RangeCell.Mark.End + 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, label = day.format(fullDate), onClick = { onChoose(day) }, @@ -379,6 +381,12 @@ private fun DayGrid(sheet: TimeToPay, reducedMotion: Boolean, onChoose: (LocalDa 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, gap: Float) { val cell = size.width / 7 @@ -389,8 +397,10 @@ private fun DrawScope.rangeLayer(startIndex: Int, endIndex: Int, bandEnd: Float, 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. drawRoundRect( color = RangeBand, + alpha = ((right - left) / height).coerceIn(0f, 1f), topLeft = Offset(left, rowTop(span.row.toFloat()) + (cell - height) / 2), size = Size(right - left, height), cornerRadius = CornerRadius(height / 2), @@ -409,6 +419,7 @@ private fun DayCell( enabled: Boolean, mark: TimeToPay.RangeCell.Mark, selected: Boolean, + cover: Float, month: String?, label: String, onClick: () -> Unit, @@ -433,7 +444,7 @@ private fun DayCell( contentAlignment = Alignment.Center, ) { val text = when (mark) { - TimeToPay.RangeCell.Mark.End -> Color.Black + 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) From 2d6b37a2a1c04f03c700c5a354002b74511c7d33 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 14:36:26 -0400 Subject: [PATCH 44/47] feat(messenger): put the month label inside the day cell on the 1st The Time To Pay grid stacks the abbreviated month (8sp semibold, uppercased) above the number on the 1st, centred as one group in the 34dp circle. The label colour follows the cell's state: dimmed, inside the band, closing day, or start circle (tests 46-47). --- .../messenger/internal/request/TimeToPay.kt | 20 ++++- .../internal/request/TimeToPaySheet.kt | 88 +++++++++++++------ .../internal/request/TimeToPayTest.kt | 26 +++++- 3 files changed, 105 insertions(+), 29 deletions(-) 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 index 191e4f2610..f7e8d41e07 100644 --- 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 @@ -59,13 +59,27 @@ internal data class TimeToPay( } } + /** 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. */ + data class BandSpan(val row: Int, val from: Float, val to: Float) + + /** 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. */ - /** One week row's part of the range band, from [from] to [to] in columns from the row's start. */ - data class BandSpan(val row: Int, val from: Float, val to: Float) - data class RangeCell(val mark: Mark, val leading: Boolean, val trailing: Boolean) { enum class Mark { None, Start, End } 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 index a47b18495c..95067c00fb 100644 --- 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 @@ -16,7 +16,7 @@ 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.aspectRatio +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding @@ -74,6 +74,7 @@ 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 @@ -348,8 +349,8 @@ private fun DayGrid(sheet: TimeToPay, reducedMotion: Boolean, onChoose: (LocalDa Column( modifier = Modifier .fillMaxWidth() - .drawBehind { rangeLayer(startIndex, endIndex, bandEnd.value, circle.value, gap.toPx()) }, - verticalArrangement = Arrangement.spacedBy(gap), + .drawBehind { rangeLayer(startIndex, endIndex, bandEnd.value, circle.value) }, + verticalArrangement = Arrangement.spacedBy(TimeToPayGrid.RowGap), ) { days.chunked(7).forEachIndexed { row, week -> Row(modifier = Modifier.fillMaxWidth()) { @@ -369,6 +370,7 @@ private fun DayGrid(sheet: TimeToPay, reducedMotion: Boolean, onChoose: (LocalDa 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) }, ) @@ -388,12 +390,14 @@ private fun coverOf(circle: Offset, column: Int, row: Int): Float { } /** 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, gap: Float) { +private fun DrawScope.rangeLayer(startIndex: Int, endIndex: Int, bandEnd: Float, circle: Offset) { val cell = size.width / 7 - val height = RangeHeight.toPx() + 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 * (cell + gap) + 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)) @@ -401,16 +405,16 @@ private fun DrawScope.rangeLayer(startIndex: Int, endIndex: Int, bandEnd: Float, drawRoundRect( color = RangeBand, alpha = ((right - left) / height).coerceIn(0f, 1f), - topLeft = Offset(left, rowTop(span.row.toFloat()) + (cell - height) / 2), + topLeft = Offset(left, rowTop(span.row.toFloat()) + (cellHeight - height) / 2), size = Size(right - left, height), cornerRadius = CornerRadius(height / 2), ) } if (endIndex != startIndex) { val start = cellOf(startIndex) - drawCircle(RangeStart, height / 2, Offset(x(start.x + 0.5f), rowTop(start.y) + cell / 2)) + 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) + cell / 2)) + drawCircle(White, height / 2, Offset(x(circle.x + 0.5f), rowTop(circle.y) + cellHeight / 2)) } @Composable @@ -421,13 +425,14 @@ private fun DayCell( selected: Boolean, cover: Float, month: String?, + monthTone: TimeToPay.MonthTone, label: String, onClick: () -> Unit, modifier: Modifier = Modifier, ) { Box( modifier = modifier - .aspectRatio(1f) + .height(TimeToPayGrid.CellHeight) .then( if (enabled) { Modifier @@ -449,31 +454,64 @@ private fun DayCell( TimeToPay.RangeCell.Mark.None -> if (enabled) CodeTheme.colors.textMain else CodeTheme.colors.textSecondary.copy(alpha = 0.5f) } - Text( - text = day.dayOfMonth.toString(), - style = CodeTheme.typography.textMedium.let { - if (mark == TimeToPay.RangeCell.Mark.None) it else it.copy(fontWeight = FontWeight.Bold) - }, - color = text, - ) - if (month != null && mark == TimeToPay.RangeCell.Mark.None) { - Text( - modifier = Modifier.align(Alignment.TopCenter).padding(top = 1.dp), - text = month, - style = CodeTheme.typography.caption.copy(fontSize = CodeTheme.typography.caption.fontSize * 0.75f), - color = CodeTheme.colors.textSecondary, - ) + 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 +} + +/** 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 <T> spec(reducedMotion: Boolean): AnimationSpec<T> = if (reducedMotion) snap() else spring(Spring.DampingRatioNoBouncy, Spring.StiffnessMediumLow) } -private val RangeHeight = 34.dp private val RangeStart = Color(0xFF6B6B6E) private val RangeBand = Color.White.copy(alpha = 0.12f) 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 index 382d86051b..e699c11dce 100644 --- 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 @@ -10,14 +10,16 @@ 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–45. UTC so 28 days never cross a clock change. */ +/** Spec "Deadline sheet", tests 34–47. UTC so 28 days never cross a clock change. */ class TimeToPayTest { private val zone = ZoneOffset.UTC @@ -165,6 +167,28 @@ class TimeToPayTest { 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) From 382e0159316525efddf84d5b448938d43365b228 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 14:43:07 -0400 Subject: [PATCH 45/47] fix(messenger): keep the day ripple inside the selection circle A press on a Time To Pay day lit the whole 38 dp cell, a square that ran into the neighbouring cells. The ripple is now a 34 dp circle centred on the cell, the size of the selected circle; the whole cell still takes the tap. --- .../app/messenger/internal/request/TimeToPaySheet.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 index 95067c00fb..c73fff965d 100644 --- 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 @@ -40,6 +40,7 @@ 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 @@ -436,7 +437,12 @@ private fun DayCell( .then( if (enabled) { Modifier - .clickable(role = Role.Button, onClick = onClick) + .clickable( + interactionSource = null, + indication = DayRipple, + role = Role.Button, + onClick = onClick, + ) .clearAndSetSemantics { contentDescription = label role = Role.Button @@ -492,6 +498,9 @@ private fun monthColor(tone: TimeToPay.MonthTone, cover: Float, enabled: Boolean 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 From c97836e2115bf94f9cea42333236df3c322b528c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 15:13:22 -0400 Subject: [PATCH 46/47] fix(messenger): run the deadline band square into the start and end circles Each band span drew with a full rounded cap, so where it met the start or closing-day circle the cap pinched inward under the circle's edge. A span now rounds only the ends where the range carries on past the row edge, and stays square where it starts or ends at a circle, including under the moving circle while it slides. Test 48 covers the per-end rounding. --- .../messenger/internal/request/TimeToPay.kt | 26 ++++++++++++++++--- .../internal/request/TimeToPaySheet.kt | 26 +++++++++++++++---- .../internal/request/TimeToPayTest.kt | 16 +++++++++++- 3 files changed, 59 insertions(+), 9 deletions(-) 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 index f7e8d41e07..d2387b2d05 100644 --- 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 @@ -70,8 +70,18 @@ internal data class TimeToPay( } } - /** One week row's part of the range band, from [from] to [to] in columns from the row's start. */ - data class BandSpan(val row: Int, val from: Float, val to: Float) + /** + * 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 } @@ -172,7 +182,17 @@ internal data class TimeToPay( val rowStart = row * 7f val start = maxOf(from, rowStart) val end = minOf(to, rowStart + 7) - if (end > start) BandSpan(row, start - rowStart, end - rowStart) else null + if (end > start) { + BandSpan( + row = row, + from = start - rowStart, + to = end - rowStart, + roundFrom = from < rowStart, + roundTo = to > rowStart + 7, + ) + } else { + null + } } } 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 index c73fff965d..42b2a5f2b4 100644 --- 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 @@ -55,12 +55,13 @@ 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.Size +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 @@ -403,12 +404,27 @@ private fun DrawScope.rangeLayer(startIndex: Int, endIndex: Int, bandEnd: Float, 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. - drawRoundRect( + 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), - topLeft = Offset(left, rowTop(span.row.toFloat()) + (cellHeight - height) / 2), - size = Size(right - left, height), - cornerRadius = CornerRadius(height / 2), ) } if (endIndex != startIndex) { 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 index e699c11dce..28b40d2253 100644 --- 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 @@ -142,7 +142,10 @@ class TimeToPayTest { 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), TimeToPay.BandSpan(1, 0f, 1.5f)), + 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. @@ -151,6 +154,17 @@ class TimeToPayTest { 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<Float>(reducedMotion = true) is SnapSpec) From bce5e16d4fe37fcec1d8c6f0165bb924b8510dcc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Mon, 26 Oct 2026 15:29:33 -0400 Subject: [PATCH 47/47] fix(messenger): animate Request Money's first push and show history as cards The flow host got the current stage's back stack as its initial stack on every recomposition. Until the user has moved off the start, the host treats a new initial stack as a correction and swaps it in without a transition, so History -> amount appeared instantly while later pushes slid. The initial stack is now remembered once. The history list now uses iOS's card rows: 12dp corners on a 6% white fill, 16dp padding, title and date on one baseline, amounts below, and a chevron. The list gets a 20dp inset and 12dp between items. --- .../internal/request/RequestMoneyScreen.kt | 71 +++++++++++++------ 1 file changed, 50 insertions(+), 21 deletions(-) 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 index 6bd898ab5d..e928d2aabb 100644 --- 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 @@ -22,11 +22,15 @@ 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.HorizontalDivider import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState @@ -98,9 +102,13 @@ internal fun RequestMoneyScreen( } 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<RequestStep, Parcelable>( - initialStack = target, + initialStack = initialStack, resultStateRegistry = resultStateRegistry, onExit = { _, _ -> onClose() }, entryProvider = entryProvider { @@ -251,14 +259,14 @@ private fun RequestHistoryContent( modifier = Modifier .fillMaxSize() .padding(padding), - contentPadding = PaddingValues(bottom = CodeTheme.dimens.grid.x4), + contentPadding = PaddingValues(CodeTheme.dimens.grid.x5), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), ) { item { CodeButton( modifier = Modifier .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset) - .padding(vertical = CodeTheme.dimens.grid.x2) + .padding(bottom = CodeTheme.dimens.grid.x3) .testTag("request_money_new"), text = stringResource(R.string.action_newRequest), onClick = onNewRequest, @@ -269,7 +277,12 @@ private fun RequestHistoryContent( items(open, key = { it.id }) { RequestHistoryRow(it, onClick = { onOpen(it) }) } } if (previous.isNotEmpty()) { - item { SectionHeader(stringResource(R.string.header_previousRequests)) } + 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) { @@ -281,17 +294,16 @@ private fun RequestHistoryContent( } @Composable -private fun SectionHeader(text: String) { +private fun SectionHeader(text: String, modifier: Modifier = Modifier) { Text( - modifier = Modifier - .padding(horizontal = CodeTheme.dimens.inset) - .padding(top = CodeTheme.dimens.grid.x4, bottom = CodeTheme.dimens.grid.x1), + modifier = modifier, text = text, - style = CodeTheme.typography.caption, + 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 @@ -305,30 +317,47 @@ private fun RequestHistoryRow(request: PaymentRequest, onClick: () -> Unit) { } else { stringResource(R.string.label_requestProgress, split.paid.text(), split.requested.text()) } - Column( + val shape = RoundedCornerShape(12.dp) + Row( modifier = Modifier .fillMaxWidth() + .clip(shape) + .background(Color.White.copy(alpha = 0.06f), shape) .clickable(onClick = onClick) - .padding(horizontal = CodeTheme.dimens.inset), + .padding(CodeTheme.dimens.grid.x4) + .semantics(mergeDescendants = true) {}, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), ) { - Row( - modifier = Modifier.padding(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), ) { - Column(modifier = Modifier.weight(1f)) { + Row(horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2)) { Text( + modifier = Modifier.weight(1f, fill = false).alignByBaseline(), text = title, - style = CodeTheme.typography.textMedium, + style = CodeTheme.typography.textLarge, color = CodeTheme.colors.textMain, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Text(text = date, style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary) + 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) } - HorizontalDivider(color = CodeTheme.colors.divider) + Icon( + modifier = Modifier.size(14.dp), + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) } }