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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions apps/flipcash/core/src/androidMain/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1079,9 +1079,9 @@
<string name="header_previousRequests">Previous Requests</string>
<string name="label_requestProgress">%1$s paid, %2$s requested</string>
<string name="label_requestCompleted">✓ %1$s paid</string>
<string name="title_amountPerPerson">Amount Per Person</string>
<string name="title_totalAmount">Total Amount</string>
<string name="title_newRequest">New Request</string>
<string name="label_perPerson">per person</string>
<string name="label_requestTotal">Total</string>
<string name="label_requestName">Name</string>
<string name="title_requestName">What is the request for?</string>
<string name="label_requestPeople">People</string>
Expand Down Expand Up @@ -1156,10 +1156,11 @@
<!-- %1$s is a medium date and short time in the device locale: "Until Nov 2, 6:00 PM". -->
<string name="value_requestUntil">Until %1$s</string>
<string name="subtitle_requestAutoCloses">Request automatically closes after</string>
<plurals name="action_requestFromPeople">
<item quantity="one">Request %2$s from %1$d Person</item>
<item quantity="other">Request %2$s from %1$d People</item>
</plurals>
<!-- %1$s is the share when the total splits evenly: "Request $20 Each". -->
<string name="action_requestEach">Request %1$s Each</string>
<!-- %1$s is the smallest share when some shares are a cent more: "Request ~$33.33 Each". -->
<string name="action_requestEachApprox">Request ~%1$s Each</string>
<string name="title_splitBetween">Split Between</string>
<string name="label_selectPeople_you">You</string>
<string name="title_requestMaxPeople">100 People Max</string>
<string name="description_requestMaxPeople">A request can include up to 100 people.</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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.NewShare
import com.flipcash.services.models.paymentrequest.NewSplitRequest
import com.flipcash.services.models.paymentrequest.PaymentRequestText
import com.getcode.opencode.model.core.ID
Expand All @@ -14,10 +15,11 @@ 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.
* A payment request a creator is putting together in a private group, from Total Amount 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
* @param total what the shares add up to; see [splitTotal].
* @param members who Split Between 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
Expand All @@ -29,25 +31,37 @@ internal data class NewRequestDraft(
val viewerId: ID,
val members: List<ID>,
val title: String,
val perPerson: Fiat? = null,
val total: Fiat? = null,
val selected: Set<ID> = 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". */
/** Members asked other than the creator. */
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. */
/** Split Between'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()
val canSend: Boolean get() = canContinue && split != null && title.isNotBlank()

/** Who is asked, in [members] order. */
val shares: List<ID> get() = members.filter { it in selected }

/** [total] split between [shares], or null with no total or a total too small to give everyone a unit. */
val split: List<NewShare>? get() = total?.let { splitTotal(it, shares, viewerId) }

/** The largest share. */
val largestShare: Fiat? get() = split?.maxByOrNull { it.amount.quarks }?.amount

/** The smallest share: the amount in "Request ~$33.33 Each". */
val smallestShare: Fiat? get() = split?.minByOrNull { it.amount.quarks }?.amount

/** Some shares are a unit more than others, so the button's amount is shown with "~". */
val isUnevenSplit: Boolean get() = largestShare != smallestShare

val people: People
get() {
val others = members.count { it != viewerId }
Expand All @@ -69,15 +83,14 @@ internal data class NewRequestDraft(

/** 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
val amount = total ?: return null
if (!canContinue) return null
return NewSplitRequest(
chatId = chatId,
text = text,
currency = amount.currencyCode,
endsAt = deadline.endsAt(now),
shares = shares,
perPerson = amount,
shares = split ?: return null,
)
}

Expand Down Expand Up @@ -129,3 +142,27 @@ internal data class NewRequestDraft(
listOf(viewerId) + roster.map { it.userId }.filter { it != viewerId }.distinct()
}
}

/**
* [total] split equally between [shares], in their order: each gets the total over the share
* count, rounded down to the currency's smallest unit, and the units left over go one each to the
* first shares that are not [creator]'s. The creator's share is marked paid from creation, so the
* leftover lands on members who actually pay. The amounts always sum to [total].
*
* Null when there are no shares, or the total is fewer units than shares, since every share must
* be at least one unit.
*/
internal fun splitTotal(total: Fiat, shares: List<ID>, creator: ID): List<NewShare>? {
if (shares.isEmpty()) return null
val unit = total.smallestUnit.quarks.coerceAtLeast(1L)
val units = total.quarks / unit
val count = shares.size.toLong()
if (units < count) return null
val base = units / count
val remainder = (units % count).toInt()
val takesExtra = (shares.filter { it != creator } + shares.filter { it == creator }).take(remainder).toSet()
return shares.map { id ->
val shareUnits = if (id in takesExtra) base + 1 else base
NewShare(id, Fiat(quarks = shareUnits * unit, currencyCode = total.currencyCode))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ internal fun peopleValue(people: NewRequestDraft.People, resources: ResourceHelp
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 summary's button, "Request <share> Each", [share] being the smallest share formatted. When
* [uneven], some shares are a unit more, so it reads "Request ~<share> Each".
*/
internal fun requestButtonText(share: String, uneven: Boolean, resources: ResourceHelper): String =
resources.getString(if (uneven) R.string.action_requestEachApprox else R.string.action_requestEach, share)

/** The sheet for checking a 101st share: title and body. */
internal fun maxPeopleNotice(resources: ResourceHelper): Pair<String, String> =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ internal fun RequestMoneyScreen(
largeHeader = true,
appBar = {
AppBarWithTitle(
title = stringResource(R.string.title_amountPerPerson),
title = stringResource(R.string.title_totalAmount),
titleAlignment = Alignment.CenterHorizontally,
onBackIconClicked = back,
)
Expand Down Expand Up @@ -166,6 +166,7 @@ internal fun RequestMoneyScreen(
onBack = back,
onToggle = { viewModel.toggle(it.userId) },
onSend = back,
title = stringResource(R.string.title_splitBetween),
buttonText = stringResource(R.string.action_done),
buttonEnabled = draft.canContinue,
viewerId = viewModel.viewerId,
Expand Down Expand Up @@ -371,7 +372,8 @@ private fun RequestSummaryContent(
onTiming: () -> Unit,
onRequest: () -> Unit,
) {
val amount = draft.perPerson?.text().orEmpty()
val amount = draft.total?.text().orEmpty()
val share = (draft.smallestShare ?: draft.total)?.text().orEmpty()
val resources = LocalResources.current
CodeScaffold(
topBar = {
Expand All @@ -389,7 +391,7 @@ private fun RequestSummaryContent(
.padding(horizontal = CodeTheme.dimens.inset)
.padding(bottom = CodeTheme.dimens.grid.x2)
.testTag("request_summary_send"),
text = requestButtonText(draft, amount, resources),
text = requestButtonText(share, draft.isUnevenSplit, resources),
enabled = draft.canSend,
isLoading = sending,
onClick = onRequest,
Expand All @@ -409,7 +411,7 @@ private fun RequestSummaryContent(
color = CodeTheme.colors.textMain,
)
Text(
text = stringResource(R.string.label_perPerson),
text = stringResource(R.string.label_requestTotal),
style = CodeTheme.typography.textMedium,
color = CodeTheme.colors.textSecondary,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,20 +161,20 @@ internal class RequestMoneyViewModel @Inject constructor(
_state.update { it.copy(stage = Stage.Amount, draft = null) }
}

/** Amount Per Person's Next. Zero stays put. */
/** Total Amount's Next. Zero stays put. */
fun amountEntered() {
val entered = amountDelegate.state.value.enteredAmount
if (entered <= 0.0) return
val perPerson = Fiat(entered, currency)
val total = 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(
val draft = s.draft?.copy(total = total) ?: NewRequestDraft(
viewerId = viewerId,
members = NewRequestDraft.members(viewerId, roster),
title = defaultTitle(),
perPerson = perPerson,
total = total,
)
s.copy(stage = Stage.Summary, draft = draft)
}
Expand Down Expand Up @@ -262,7 +262,7 @@ internal class RequestMoneyViewModel @Inject constructor(
val created = requests.create(split, key).getOrElse { return@launch failed(it, chatId, draft) }
// Once per confirm: a retry reuses createdRequestId and never reaches here.
analytics.track(
PaymentRequestEvents.created(draft.requestedCount, AnalyticsState.SUCCESS, draft.perPerson?.analytics, null),
PaymentRequestEvents.created(draft.requestedCount, AnalyticsState.SUCCESS, draft.total?.analytics, null),
)
created.id
}
Expand Down Expand Up @@ -298,7 +298,7 @@ internal class RequestMoneyViewModel @Inject constructor(
PaymentRequestEvents.created(
draft.requestedCount,
AnalyticsState.FAILURE,
draft.perPerson?.analytics,
draft.total?.analytics,
error.analytics,
),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ internal fun GroupSelectPeopleContent(
onBack: () -> Unit,
onToggle: (ChatMember) -> Unit,
onSend: () -> Unit,
title: String = stringResource(R.string.title_selectPeople),
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. */
Expand All @@ -84,7 +85,7 @@ internal fun GroupSelectPeopleContent(
CodeScaffold(
topBar = {
AppBarWithTitle(
title = stringResource(R.string.title_selectPeople),
title = title,
titleAlignment = Alignment.CenterHorizontally,
onBackIconClicked = onBack,
)
Expand Down
Loading
Loading