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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ package com.flipcash.app.bills
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
Expand All @@ -32,7 +30,6 @@ import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.bill.Scannable
import com.flipcash.app.core.navigation.NavBarButton
import com.flipcash.app.core.navigation.asNavBarTab
import com.flipcash.app.core.tipping.LocalTipCoordinator
import com.flipcash.app.session.BillDeterminationResult
import com.flipcash.app.session.Grabbed
import com.flipcash.app.session.LocalSessionController
Expand All @@ -41,7 +38,6 @@ import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.navigation.scrim.LocalScrimController
import com.getcode.theme.CodeTheme
import com.getcode.ui.utils.AnimationUtils
import com.getcode.ui.utils.ModalAnimationSpeed
import com.getcode.ui.utils.rememberKeyboardController
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
Expand All @@ -63,9 +59,6 @@ fun BillOverlay(modifier: Modifier = Modifier) {
val state by session.state.collectAsStateWithLifecycle()
val billState by session.billState.collectAsStateWithLifecycle()

// Tip affordability (min-tip balance check), surfaced through the shared selection state.
val tipSelection by LocalTipCoordinator.current.selection.collectAsStateWithLifecycle()

// A bill is a focused modal — it must never share the screen with a keyboard. What makes that
// reachable is a tip-card deeplink handled while a chat input still holds focus: App takes the
// keyboard down before routing, and this catches an IME the platform restores afterwards, on
Expand Down Expand Up @@ -136,40 +129,21 @@ fun BillOverlay(modifier: Modifier = Modifier) {
var managementHeight by remember { mutableStateOf(0.dp) }
val showManagementOptions by remember(updatedBillState) {
derivedStateOf {
// The tip card always shows, but its modal only slides up when the viewer can afford
// the minimum tip; otherwise the tip decorator prompts to add money.
billDismissState.targetValue == DismissValue.Default &&
(updatedBillState.valuation != null ||
(updatedBillState.bill is Scannable.TipCard && tipSelection.canTip))
updatedBillState.valuation != null
}
}

// When the tip modal is up, pin the tip card just above it: reserve the modal's height as
// bottom inset AND bottom-align the card (bias 0 = centered, 1 = bottom). Both animated so the
// card slides from centered down to just above the modal, and back, in lockstep with the modal.
val isTipCard = updatedBillState.bill is Scannable.TipCard
val tipModalUp = managementHeight > 0.dp && isTipCard
val modalSpeed = ModalAnimationSpeed.Normal(updatedBillState.confirmationDelayMillis)
val offset = if (isTipCard) CodeTheme.dimens.grid.x8 else CodeTheme.dimens.grid.x2
// Only the tip card's inset is animated. Everywhere else the inset's one and only change is
// 0 -> the management row's measured height, which lands a frame after the bill composes;
// animating it slid the card up from behind the send/cancel pills half a second after it had
// already appeared over them. Snapping puts the card at its final position while the enter
// spring is still carrying it up from off-screen, so there is nothing to see.
// The inset's one and only change is 0 -> the management row's measured height, which lands
// a frame after the bill composes; animating it slid the card up from behind the
// send/cancel pills half a second after it had already appeared over them. Snapping puts
// the card at its final position while the enter spring is still carrying it up from
// off-screen, so there is nothing to see.
val billBottomInset by animateDpAsState(
targetValue = managementHeight + offset,
animationSpec = if (isTipCard) {
tween(durationMillis = modalSpeed.duration, delayMillis = modalSpeed.delay)
} else {
snap()
},
targetValue = managementHeight + CodeTheme.dimens.grid.x2,
animationSpec = snap(),
label = "billBottomInset",
)
val billVerticalBias by animateFloatAsState(
targetValue = if (tipModalUp) 1f else 0f,
animationSpec = tween(durationMillis = modalSpeed.duration, delayMillis = modalSpeed.delay),
label = "billVerticalBias",
)

AnimatedScannable(
modifier = Modifier.fillMaxSize(),
Expand All @@ -181,7 +155,7 @@ fun BillOverlay(modifier: Modifier = Modifier) {
top = CodeTheme.dimens.grid.x2,
bottom = billBottomInset,
),
scannableAlignment = BiasAlignment(horizontalBias = 0f, verticalBias = billVerticalBias),
scannableAlignment = BiasAlignment(horizontalBias = 0f, verticalBias = 0f),
bill = updatedBillState.bill,
transitionSpec = {
when (updatedState.billResult) {
Expand Down Expand Up @@ -209,7 +183,7 @@ fun BillOverlay(modifier: Modifier = Modifier) {
isRemoteSendLoading = updatedState.isRemoteSendLoading,
showManagementOptions = showManagementOptions,
onManagementHeightMeasured = { managementHeight = it },
onDismiss = { session.dismissBill(PutInWallet) },
onDismiss = { result -> session.dismissBill(result) },
)
with(overlays) { Content(overlayContext) }
}
Expand All @@ -222,8 +196,8 @@ fun BillOverlay(modifier: Modifier = Modifier) {
* Deliberately not a plain `LaunchedEffect(presented)`. [BillOverlay] is hosted per navigation
* entry, so a screen opened *while* a bill is up composes a fresh copy with the bill already
* present, and an unguarded effect would fire on that first pass. For the keyboard that would stomp
* the post-tip hand-off, which opens the chat with the keyboard up on purpose (see
* TipCardDecorator's LaunchChat). Seeding `wasPresented` from the value at first composition makes
* a hand-off that opens the chat with the keyboard up on purpose. Seeding `wasPresented` from the
* value at first composition makes
* an already-presented bill a no-op, so only a bill that appears *while this copy is watching*
* counts as a presentation.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,12 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.flipcash.app.bills.components.ScannableCode
import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.handle
import com.flipcash.shared.bills.R
import com.flipcash.shared.common.ui.ContactAvatar
import com.getcode.theme.CodeTheme

Expand Down Expand Up @@ -150,7 +148,9 @@ internal fun TipCard(
verticalArrangement = Arrangement.spacedBy(width * TipCardHandleGapFraction),
) {
Text(
text = stringResource(R.string.label_tipUser, user.displayName),
// The name alone, not "Tip <name>": scanning a card reaches the person,
// and what you do once you are there is the chat's business.
text = user.displayName,
style = CodeTheme.typography.textMedium.copy(
fontSize = nameFontSize,
lineHeight = nameFontSize * TipCardNameLineHeightRatio,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import com.flipcash.app.core.extensions.navigateAll
import com.flipcash.app.bills.BillManagementOptions
import com.flipcash.app.bills.modals.ReceivedFundsConfirmation
import com.flipcash.app.session.LocalSessionController
import com.flipcash.app.session.PutInWallet
import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.ui.core.measured
import com.getcode.ui.utils.AnimationUtils
Expand Down Expand Up @@ -75,7 +76,7 @@ internal data class PayableDecorator(private val bill: Scannable.Payable) : Scan
}

BackHandler(canCancel) {
context.onDismiss()
context.onDismiss(PutInWallet)
}
}

Expand All @@ -97,7 +98,7 @@ internal data class PayableDecorator(private val bill: Scannable.Payable) : Scan
// grab time. A cash link is claimed from a link rather than from the scanner,
// so it has no snapshot to reveal and stays where it is.
onClaim = {
if (session == null) context.onDismiss()
if (session == null) context.onDismiss(PutInWallet)
else if (session.claimReceivedFunds()) {
navigator.navigateAll(listOf(AppRoute.Sheets.Wallet))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.flipcash.app.core.bill.BillState
import com.flipcash.app.core.bill.Scannable
import com.flipcash.app.session.BillDeterminationResult
import com.flipcash.app.session.Grabbed
import com.flipcash.app.session.PutInWallet

/**
* Below-bill decor content owned by a [Scannable] type rather than decided by
Expand Down Expand Up @@ -82,13 +85,16 @@ internal data object NoOpScannableDecorator : ScannableDecorator {
* @param showManagementOptions whether the management options row should be shown.
* @param onManagementHeightMeasured reports the measured management-row height back to the
* container so the card floats above it.
* @param onDismiss dismisses the current bill (equivalent to `session.dismissBill(PutInWallet)`).
* @param onDismiss dismisses the current bill. The [BillDeterminationResult] passed in picks the
* card's exit animation as well as recording the outcome — [PutInWallet] slides it back down to
* the wallet, [Grabbed] pops it (fade + scale). A decorator that hands off to another screen
* wants the pop, because the slide takes longer than the handoff leaves it on screen for.
*/
data class ScannableDecoratorContext(
val liveBill: Scannable?,
val billState: BillState,
val isRemoteSendLoading: Boolean,
val showManagementOptions: Boolean,
val onManagementHeightMeasured: (Dp) -> Unit,
val onDismiss: () -> Unit,
val onDismiss: (BillDeterminationResult) -> Unit,
)
Original file line number Diff line number Diff line change
@@ -1,119 +1,91 @@
package com.flipcash.app.bills.decor

import androidx.compose.animation.EnterExitState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.Companion.BottomCenter
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.bill.Scannable
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.app.core.extensions.navigateAll
import com.flipcash.app.core.extensions.openAsSheet
import com.flipcash.app.core.tipping.LocalTipCoordinator
import com.flipcash.app.core.tipping.TipEvent
import com.flipcash.app.bills.modals.TipUserModal
import com.flipcash.app.session.LocalSessionController
import com.flipcash.app.session.Grabbed
import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.ui.core.measured
import com.getcode.ui.utils.AnimationUtils
import com.getcode.ui.utils.ModalAnimationSpeed
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext

/**
* Decorator owned by a [Scannable.TipCard]. Placeholder home for the tip card's bottom modal
* (analogous to the payable "received funds" confirmation). Wire it here, gating `visible` on
* `context.liveBill is Scannable.TipCard` so it animates out on dismiss, and use the retained
* [tipCard] for content so it persists through the exit:
* Decorator owned by a [Scannable.TipCard] (node 10074:18893). Scanning a card is a way of reaching
* a person, so it ends where reaching them by `@handle` ends: in their chat, started or not. What
* used to happen here — a modal over the card taking a tip amount, sending it, then handing off to
* the chat — is now the chat's own job, and the fee it charges is the same one either route pays.
*
* ```
* AnimatedVisibility(
* modifier = Modifier.align(BottomCenter),
* visible = context.liveBill is Scannable.TipCard,
* enter = AnimationUtils.modalEnter(0),
* exit = AnimationUtils.modalExit,
* ) {
* TipCardModal(tipCard = tipCard, onDone = { context.onDismiss() })
* }
* ```
* The card still gets a beat on screen before the chat takes over. It is the same 450ms the modal
* used to take sliding up ([ModalAnimationSpeed.Normal] at the tip card's zero confirmation delay),
* so the scan still reads as "I found this person" rather than as a screen that flashed past. Then
* the card's pop overlaps the push into the chat, so the hand-off reads as one movement.
*/
internal data class TipCardDecorator(private val tipCard: Scannable.TipCard) : ScannableDecorator {
@Composable
override fun BoxScope.Content(context: ScannableDecoratorContext) {
val billState = context.billState
val session = LocalSessionController.current
val navigator = LocalCodeNavigator.current
val tipCoordinator = LocalTipCoordinator.current
val selection by tipCoordinator.selection.collectAsState()

val tipPresented = context.liveBill is Scannable.TipCard
// Can't afford the minimum tip → the modal stays hidden (gated by
// showManagementOptions); prompt to add money instead. The navigator lives here in
// the UI, so we route directly rather than plumbing a callback through the session.
LaunchedEffect(tipPresented, selection.canTip) {
if (tipPresented && !selection.canTip) {
session?.presentDepositOptions(
// No giveable balance to tip with → steer to add-money / discover, and dismiss the
// tip card so we don't leave it stranded behind the prompt.
onDismiss = { context.onDismiss() }
) { route ->
navigator.openAsSheet(route)
}
// The id is server-provided, so a card that resolved to a locally-constructed profile has
// no chat to open. Leave it up rather than dismissing into nothing.
val userId = tipCard.user.userId

LaunchedEffect(tipPresented, userId) {
if (!tipPresented || userId == null) return@LaunchedEffect
delay(ModalAnimationSpeed.Normal(billState.confirmationDelayMillis).delay.toLong())
// Dismiss with [Grabbed] rather than [PutInWallet]: the card leaves as a 100ms
// fade-and-scale instead of a 600ms slide back to the wallet, which the entry sliding
// out from under it would swallow whole. [PopLeadMillis] then gives that pop a head
// start, so it is already running when the push takes the entry away — dismissed and
// pushed in the same frame, the card is removed rather than seen to leave.
//
// Uncancellable because dismissing sets the bill to null, which flips `tipPresented`
// and restarts this effect; a plain delay here would take the push down with it.
withContext(NonCancellable) {
context.onDismiss(Grabbed)
delay(PopLeadMillis)
navigator.push(
AppRoute.Messaging.Chat(ChatIdentifier.ByUser(userId, tipCard.user)),
)
}
}

// Handle the coordinator's one-shot UI events (e.g. open the add money flow after the
// insufficient-balance prompt's "Add Money"). Collected here, not in the modal, so it
// fires whether the tip modal is on screen or not.
// The coordinator's one-shot UI events. Collected here, not in a modal, so it fires
// regardless of what is on screen.
LaunchedEffect(tipCoordinator) {
tipCoordinator.events.collect { event ->
when (event) {
is TipEvent.OpenRoute -> {
navigator.openAsSheet(event.route)
}
// Open the completed tip's chat with the tips list beneath it, so back returns
// to the list: navigateAll switches to the Chats tab and pushes the chat onto it.
is TipEvent.LaunchChat -> {
navigator.navigateAll(
listOf(
AppRoute.Sheets.Tips(),
AppRoute.Messaging.Chat(event.identifier, openKeyboard = true),
),
)
context.onDismiss()
context.onDismiss(Grabbed)
}
}
}
}

AnimatedScannableDecorator(
visible = tipPresented && context.showManagementOptions,
enter = AnimationUtils.modalEnter(billState.confirmationDelayMillis),
exit = AnimationUtils.modalExit,
modifier = Modifier.align(BottomCenter),
) {
// Report the modal's height as soon as it starts entering (target Visible), not only
// once it settles — this lets the container move the tip card in sync with the modal's
// slide (both share the same enter timing). Reports 0 on exit so the card re-centers.
var modalHeight by remember { mutableStateOf(0.dp) }
val entering = transition.targetState == EnterExitState.Visible
LaunchedEffect(modalHeight, entering) {
context.onManagementHeightMeasured(if (entering) modalHeight else 0.dp)
}

Box(
modifier = Modifier.measured { modalHeight = it.height },
contentAlignment = BottomCenter,
) {
TipUserModal(
card = tipCard,
)
}
}
}
}

/**
* How long the card's exit gets to itself before the chat is pushed. Long enough for the
* fade-and-scale to be under way when the navigation transition starts, short enough that the two
* still read as one movement.
*/
private const val PopLeadMillis = 50L
Loading
Loading