diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index 9b5871e79e..af51b75b70 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -183,6 +183,7 @@ dependencies { implementation(project(":apps:flipcash:core")) implementation(project(":apps:flipcash:shared:accesskey")) + implementation(project(":apps:flipcash:shared:bills")) implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:appsettings")) implementation(project(":apps:flipcash:shared:appupdates")) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt index 8d23945376..6220c494dd 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt @@ -69,6 +69,7 @@ import com.getcode.navigation.results.rememberNavResultStateRegistry import com.getcode.navigation.scenes.ModalBottomSheetSceneStrategy import com.getcode.navigation.scrim.LocalScrimController import com.getcode.navigation.scrim.ScrimController +import com.flipcash.app.bills.BillOverlay import com.getcode.navigation.scrim.ScrimOverlay import com.getcode.theme.CodeTheme import com.getcode.ui.biometrics.LocalBiometricsState @@ -223,6 +224,11 @@ internal fun App( } ScrimOverlay(scrimController) + + // Bills render at the app root (not inside the scanner) so a + // presented bill appears over any screen. Reads the app-scoped + // billState via LocalSessionController. + BillOverlay() } val emailCodeChannel = LocalEmailCodeChannel.current diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt index d3e571a76c..50c5abffe2 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -25,9 +26,12 @@ import com.flipcash.app.core.ui.NavigationBar import com.flipcash.app.core.ui.rememberNavigationBarState import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.LocalFeatureFlags +import com.flipcash.app.session.LocalSessionController import com.getcode.manager.BottomBarManager import com.getcode.navigation.core.CodeNavigator import com.getcode.theme.CodeTheme +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map /** * The hoisted v2 navigation bar — root chrome, not owned by any screen. It renders over whichever @@ -55,13 +59,20 @@ internal fun AppNavigationBar( // while one is showing so it sits below the modal instead of floating over it. val bottomBarMessages by BottomBarManager.messages.collectAsStateWithLifecycle() + // A bill/tip card renders at the app root above everything; hide the bar so it doesn't show + // beneath the presented bill. + val session = LocalSessionController.current + val billUp by remember(session) { + session?.billState?.map { it.bill != null } ?: flowOf(false) + }.collectAsStateWithLifecycle(initialValue = false) + Box( modifier = Modifier .then(modifier), contentAlignment = Alignment.BottomCenter, ) { AnimatedVisibility( - visible = topTab != null && bottomBarMessages.isEmpty(), + visible = topTab != null && bottomBarMessages.isEmpty() && !billUp, enter = slideInVertically { it } + fadeIn(), exit = slideOutVertically { it } + fadeOut(), ) { diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt index 85f26c5096..c652482b0a 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt @@ -1,7 +1,6 @@ package com.flipcash.app.scanner.internal import android.annotation.SuppressLint -import androidx.camera.view.PreviewView import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -64,9 +63,6 @@ internal fun Scanner() { var isPinching by remember { mutableStateOf(false) } var zoomRatio by remember { mutableFloatStateOf(1f) } - // Exposed by CodeScanner so the tip card can snapshot the live feed for its blurred backdrop. - var previewView by remember { mutableStateOf(null) } - LaunchedEffect(biometricsState, previewing) { if (previewing == true) { focusManager.clearFocus() @@ -84,7 +80,6 @@ internal fun Scanner() { isPaused = isPaused, isPinching = isPinching, zoomRatio = zoomRatio, - previewView = previewView, onAction = { when (it) { ScannerDecorItem.Give -> { @@ -116,7 +111,6 @@ internal fun Scanner() { cameraAvailable = true previewing = it }, - onPreviewViewChanged = { previewView = it }, onCodeScanned = { result -> when (result) { is CodeScanResult.QrCode -> { diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt index 00b8e177a5..e7f220220c 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt @@ -1,87 +1,49 @@ package com.flipcash.app.scanner.internal.bills import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.animation.ExitTransition import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import android.app.ActivityManager -import android.os.Build -import androidx.compose.animation.togetherWith -import androidx.camera.view.PreviewView import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.DismissState -import androidx.compose.material.DismissValue -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue -import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag -import androidx.compose.ui.unit.dp -import com.flipcash.app.bills.AnimatedScannable -import com.flipcash.app.bills.components.cards.LocalTipCardBackdrop -import com.flipcash.app.bills.components.cards.LocalTipCardBaseAlpha -import com.flipcash.app.bills.components.cards.LocalTipCardColor -import com.flipcash.app.bills.components.cards.TipCardOpaqueFallback -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.LocalFeatureFlags +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.android.extensions.launchAppSettings -import com.flipcash.app.core.bill.Scannable -import com.flipcash.app.core.tipping.LocalTipCoordinator import com.flipcash.app.scanner.internal.ScannerDecorItem import com.flipcash.app.scanner.internal.ui.components.DecorView -import com.flipcash.app.session.BillDeterminationResult -import com.flipcash.app.session.Grabbed import com.flipcash.app.session.LocalSessionController -import com.flipcash.app.session.PutInWallet import com.flipcash.app.updates.LocalAppUpdater -import com.getcode.ui.components.OnLifecycleEvent -import androidx.lifecycle.Lifecycle -import com.flipcash.app.bills.decor.ScannableDecoratorContext -import com.flipcash.app.bills.decor.ScannableDecorator import com.flipcash.features.scanner.R import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager -import com.getcode.theme.CodeTheme import com.getcode.ui.biometrics.LocalBiometricsState +import com.getcode.ui.components.OnLifecycleEvent import com.getcode.ui.scanner.views.CameraDisabledView import com.getcode.ui.scanner.views.CameraPermissionsMissingView -import com.getcode.ui.utils.AnimationUtils -import com.getcode.ui.utils.ModalAnimationSpeed import com.getcode.util.permissions.PermissionResult import com.getcode.util.permissions.rememberCameraPermission -import kotlinx.coroutines.delay -import kotlin.time.Duration.Companion.milliseconds -@OptIn(ExperimentalMaterialApi::class) +/** + * The scanner surface: the camera preview (via [scannerView]) plus the HUD ([DecorView]). Bills are + * no longer drawn here — a presented bill renders at the app root + * ([com.flipcash.app.bills.BillOverlay]) so it can appear over any screen. This container only reads + * [com.flipcash.app.session.SessionController.billState] to hide its HUD while a bill is up. + */ @Composable internal fun ScannableContainer( modifier: Modifier = Modifier, isPaused: Boolean, isPinching: Boolean = false, zoomRatio: Float = 1f, - previewView: PreviewView? = null, scannerView: @Composable () -> Unit, onAction: (ScannerDecorItem) -> Unit ) { @@ -99,7 +61,6 @@ internal fun ScannableContainer( text = resources.getString(R.string.action_openSettings), style = BottomBarManager.BottomBarButtonStyle.Filled50, onClick = { context.launchAppSettings() } - ) ) ) @@ -115,31 +76,9 @@ internal fun ScannableContainer( val state by session.state.collectAsStateWithLifecycle() val billState by session.billState.collectAsStateWithLifecycle() - // Tip affordability (min-tip balance check) is owned by the tipping coordinator and - // surfaced through the shared selection state, so the scanner reads it without - // depending on the tipping module. - val tipSelection by LocalTipCoordinator.current.selection.collectAsStateWithLifecycle() - val autoStart = state.autoStartCamera == true var cameraStarted by remember { mutableStateOf(autoStart) } - // Window-space origin of the scanner surface, used to align the blurred camera backdrop under - // the tip card wherever it sits. - var containerOriginInWindow by remember { mutableStateOf(Offset.Zero) } - - // The frosted camera backdrop snapshots the feed (a GPU→CPU readback). Gate it to devices that - // can absorb that — API 31+ for the blur, and not low-RAM. Everywhere else the card falls back - // to an opaque approximation of the frosted tone. - val deviceSupportsFrostedTipCard = remember { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && - context.getSystemService(ActivityManager::class.java)?.isLowRamDevice != true - } - // Also gated behind a beta flag (off by default); the opaque fallback is used when it's disabled. - val frostedTipCardFlagEnabled by LocalFeatureFlags.current - .observe(FeatureFlag.FrostedTipCard) - .collectAsStateWithLifecycle() - val frostedTipCardEnabled = deviceSupportsFrostedTipCard && frostedTipCardFlagEnabled - OnLifecycleEvent { _, event -> if (event == Lifecycle.Event.ON_STOP && !autoStart) { cameraStarted = false @@ -150,7 +89,6 @@ internal fun ScannableContainer( modifier = Modifier .fillMaxSize() .then(modifier) - .onGloballyPositioned { containerOriginInWindow = it.positionInWindow() } .testTag("scanner_view") ) { val availableUpdate by LocalAppUpdater.current.availableUpdate.collectAsStateWithLifecycle() @@ -164,7 +102,7 @@ internal fun ScannableContainer( // waiting for update } - else ->{ + else -> { when (cameraPermission.status) { PermissionResult.Denied -> { CameraDisabledView(modifier = Modifier.fillMaxSize()) { @@ -195,167 +133,21 @@ internal fun ScannableContainer( } } - val updatedState by rememberUpdatedState(state) - val updatedBillState by rememberUpdatedState(billState) - - // Not keyed on the bill: it must stay true while the swiped-off card is being removed so - // the outgoing content stays hidden through its exit instead of snapping back to center. - // Reset explicitly when a fresh bill appears. - var dismissed by remember { mutableStateOf(false) } - LaunchedEffect(updatedBillState.bill) { - if (updatedBillState.bill != null) dismissed = false - } - - // bill dismiss state, restarted for every bill - val billDismissState = remember(updatedBillState.bill) { - DismissState( - initialValue = DismissValue.Default, - // Only gate whether the swipe is allowed. Removing the bill here (mid-swipe) would - // recreate this DismissState and snap the card's offset back to center before the - // exit slide — the "reset then dismiss" stutter. Removal happens after the swipe - // settles the card off-screen (see below). - confirmStateChange = { - it == DismissValue.DismissedToEnd && updatedBillState.canSwipeToDismiss - } - ) - } - - // Once the swipe has carried the card off-screen (currentValue leaves Default), hide it and - // remove the bill. The card is already out of view, so the AnimatedContent exit re-shows - // nothing and there's no offset reset. - LaunchedEffect(billDismissState) { - snapshotFlow { billDismissState.currentValue } - .collect { value -> - if (value != DismissValue.Default) { - dismissed = true - session.dismissBill(PutInWallet) - } - } - } - - LaunchedEffect(dismissed) { - if (dismissed) { - delay(500.milliseconds) - dismissed = false - } - } - - // Composable animation for the decor + // Hide the HUD while a bill is presented — the bill now renders at the app root, above this. AnimatedVisibility( - visible = updatedBillState.bill == null || billDismissState.targetValue != DismissValue.Default, + visible = billState.bill == null, enter = fadeIn(), exit = fadeOut(), modifier = Modifier.fillMaxSize() ) { DecorView( - state = updatedState, - billState = updatedBillState, + state = state, + billState = billState, isPaused = isPaused, isPinching = isPinching, zoomRatio = zoomRatio, onAction = onAction ) } - - 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)) - } - } - - // 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-aligned). Otherwise - // the card centers in the region above the (tall) modal and floats high. Both are animated - // so the card slides from centered down to just above the modal as it enters, and back. - val tipModalUp = managementHeight > 0.dp && updatedBillState.bill is Scannable.TipCard - // Drive the card's move with the SAME timing as the tip modal's enter (see - // AnimationUtils.modalEnter → ModalAnimationSpeed.Normal): the card holds centered during the - // modal's start delay, then slides up in lockstep with the modal instead of lagging behind. - val modalSpeed = ModalAnimationSpeed.Normal(updatedBillState.confirmationDelayMillis) - val offset = if (updatedBillState.bill is Scannable.TipCard) CodeTheme.dimens.grid.x8 else CodeTheme.dimens.grid.x2 - val billBottomInset by animateDpAsState( - targetValue = managementHeight + offset, - animationSpec = tween(durationMillis = modalSpeed.duration, delayMillis = modalSpeed.delay), - label = "billBottomInset", - ) - val billVerticalBias by animateFloatAsState( - targetValue = if (tipModalUp) 1f else 0f, - animationSpec = tween(durationMillis = modalSpeed.duration, delayMillis = modalSpeed.delay), - label = "billVerticalBias", - ) - - // Frosted-camera backdrop behind the tip card. Snapshots the feed once (see - // rememberCameraTipCardBackdrop) only while a tip card is up and only on capable devices. - val isTipCard = updatedBillState.bill is Scannable.TipCard - val tipCardBackdrop = rememberCameraTipCardBackdrop( - previewView = previewView, - enabled = isTipCard && frostedTipCardEnabled, - containerOriginInWindow = containerOriginInWindow, - ) - - // Where the frosted backdrop is unavailable (incapable device or the beta flag is off), - // render the tip card as an opaque approximation of the frosted tone instead of a - // translucent panel over the (stutter-prone) live camera. - val useOpaqueFallback = isTipCard && !frostedTipCardEnabled - - CompositionLocalProvider( - LocalTipCardBackdrop provides tipCardBackdrop, - LocalTipCardColor provides if (useOpaqueFallback) TipCardOpaqueFallback else Color.Unspecified, - LocalTipCardBaseAlpha provides if (useOpaqueFallback) 1f else 0.45f, - ) { - AnimatedScannable( - modifier = Modifier.fillMaxSize(), - dismissState = billDismissState, - dismissed = dismissed, - contentPadding = PaddingValues( - start = CodeTheme.dimens.inset, - end = CodeTheme.dimens.inset, - top = CodeTheme.dimens.grid.x2, - bottom = billBottomInset, - ), - scannableAlignment = BiasAlignment(horizontalBias = 0f, verticalBias = billVerticalBias), - bill = updatedBillState.bill, - transitionSpec = { - when (updatedState.billResult) { - BillDeterminationResult.None -> EnterTransition.None - Grabbed -> AnimationUtils.animationBillEnterGrabbed - PutInWallet -> AnimationUtils.animationBillEnterGive - } togetherWith when (updatedState.billResult) { - BillDeterminationResult.None -> ExitTransition.None - Grabbed -> AnimationUtils.animationBillExitGrabbed - PutInWallet -> AnimationUtils.animationBillExitReturned - } - } - ) - } - - // Below-bill content, owned by the scannable type (see `overlays/ScannableOverlays`). - // `displayedScannable` retains the last shown scannable so an overlay can still animate - // OUT as `bill` returns to null on dismiss (the overlay stays mounted; only `visible` flips). - var displayedScannable by remember { mutableStateOf(null) } - LaunchedEffect(updatedBillState.bill) { - updatedBillState.bill?.let { displayedScannable = it } - } - - displayedScannable?.let { ScannableDecorator.forScannable(it) }?.let { overlays -> - val overlayContext = ScannableDecoratorContext( - liveBill = updatedBillState.bill, - billState = updatedBillState, - isRemoteSendLoading = updatedState.isRemoteSendLoading, - showManagementOptions = showManagementOptions, - onManagementHeightMeasured = { managementHeight = it }, - onDismiss = { session.dismissBill(PutInWallet) }, - ) - with(overlays) { Content(overlayContext) } - } } } diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/TipCardCameraBackdrop.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/TipCardCameraBackdrop.kt deleted file mode 100644 index 90e748ba10..0000000000 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/TipCardCameraBackdrop.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.flipcash.app.scanner.internal.bills - -import androidx.camera.view.PreviewView -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.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.blur -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInWindow -import com.flipcash.app.bills.components.cards.TipCardBlurRadius -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive - -// How long to keep trying for a usable frame after the card appears (the stream may not be ready -// on the very first attempt), and the gap between attempts. -private const val CAPTURE_TIMEOUT_MS = 1_000L -private const val CAPTURE_RETRY_MS = 50L - -/** - * Builds the frosted-camera backdrop for a tip card. When [enabled], it takes a SINGLE frozen - * snapshot of the live [previewView] (camera only — the translucent card composited on top is - * never captured, so there is no feedback loop) and returns a composable that draws that blurred - * frame aligned to the card's bounds. Returns null until a frame is captured. - * - * A single snapshot is deliberate: [PreviewView.getBitmap] is a main-thread GPU→CPU readback, and - * doing it continuously stutters the live preview even on flagship hardware. A tip card is a static - * modal, so one frozen frame — captured as the card animates in — is enough. - * - * Haze can't blur the camera (it's an AndroidView outside the Compose layer), so we feed it the - * pixels ourselves via [PreviewView.getBitmap] and blur with [Modifier.blur]. - * - * @param containerOriginInWindow window-space origin of the full-screen scanner surface, used to - * line the camera frame up under the card wherever the card sits. - */ -@Composable -internal fun rememberCameraTipCardBackdrop( - previewView: PreviewView?, - enabled: Boolean, - containerOriginInWindow: Offset, -): (@Composable BoxScope.() -> Unit)? { - var frame by remember(previewView) { mutableStateOf(null) } - - LaunchedEffect(previewView, enabled) { - if (previewView == null || !enabled) { - frame = null - return@LaunchedEffect - } - // Grab one frame, retrying briefly until the stream yields a bitmap, then stop — no ongoing - // readback, so the live preview is never starved. - var waited = 0L - while (isActive && frame == null && waited < CAPTURE_TIMEOUT_MS) { - runCatching { previewView.bitmap }.getOrNull()?.let { frame = it.asImageBitmap() } - if (frame == null) { - delay(CAPTURE_RETRY_MS) - waited += CAPTURE_RETRY_MS - } - } - } - - val current = frame ?: return null - return { - CameraBackdropLayer( - frame = current, - containerOriginInWindow = containerOriginInWindow, - modifier = Modifier.matchParentSize(), - ) - } -} - -@Composable -private fun CameraBackdropLayer( - frame: ImageBitmap, - containerOriginInWindow: Offset, - modifier: Modifier = Modifier, -) { - var selfOriginInWindow by remember { mutableStateOf(Offset.Zero) } - Box( - modifier - .onGloballyPositioned { selfOriginInWindow = it.positionInWindow() } - .blur(TipCardBlurRadius) - .drawBehind { - // The frame fills the scanner surface; translate it so the slice behind the card - // (this layer) lines up with what the camera is actually showing there. - drawImage(frame, topLeft = containerOriginInWindow - selfOriginInWindow) - } - ) -} diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillOverlay.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillOverlay.kt new file mode 100644 index 0000000000..8542ea0d3c --- /dev/null +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillOverlay.kt @@ -0,0 +1,193 @@ +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.tween +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.DismissState +import androidx.compose.material.DismissValue +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.bills.decor.ScannableDecorator +import com.flipcash.app.bills.decor.ScannableDecoratorContext +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 +import com.flipcash.app.session.PutInWallet +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 kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.milliseconds + +/** + * Root-level overlay that renders the active bill (payable / tip card / gold bar) above ALL app + * content, driven by the app-scoped [com.flipcash.app.session.BillOperations] state exposed through + * [LocalSessionController]. Hosting it at the app root — rather than inside the scanner — lets a + * presented bill appear over any screen, and decouples bill presentation from the camera. + * + * Owns the swipe-to-dismiss gesture wiring (the [DismissState], the "settle off-screen then remove" + * latch), the enter/exit animation, and the below-bill decorators. The scanner keeps only its camera + * and HUD; it no longer draws the bill. + */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun BillOverlay(modifier: Modifier = Modifier) { + val session = LocalSessionController.current ?: return + 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() + + Box(modifier = Modifier.fillMaxSize().then(modifier)) { + val updatedState by rememberUpdatedState(state) + val updatedBillState by rememberUpdatedState(billState) + + // Scrim behind the bill when it's presented over app content — dims and blocks the content + // beneath (and tap-to-dismisses) so the bill reads as a focused modal. Driven through the + // shared root [ScrimController] (its ScrimOverlay sits just below this overlay), so it uses + // the theme scrim colour. Skipped over the scanner tab, where the live camera stays visible. + val scrimController = LocalScrimController.current + val overCamera = + (LocalCodeNavigator.current.currentRouteKey as? AppRoute)?.asNavBarTab() == NavBarButton.Scanner + val showScrim = billState.bill != null && !overCamera + LaunchedEffect(showScrim) { + if (showScrim) { + scrimController.show(onDismiss = { session.dismissBill(PutInWallet) }) + } else { + scrimController.hide() + } + } + + // Not keyed on the bill: stays true while the swiped-off card is being removed so the outgoing + // content stays hidden through its exit instead of snapping back to center. Reset when a fresh + // bill appears. + var dismissed by remember { mutableStateOf(false) } + LaunchedEffect(updatedBillState.bill) { + if (updatedBillState.bill != null) dismissed = false + } + + // Bill dismiss state, restarted for every bill. Only gate whether the swipe is allowed — + // removing the bill mid-swipe would recreate this and snap the card back to center before the + // exit slide; removal happens after the swipe settles the card off-screen (below). + val billDismissState = remember(updatedBillState.bill) { + DismissState( + initialValue = DismissValue.Default, + confirmStateChange = { + it == DismissValue.DismissedToEnd && updatedBillState.canSwipeToDismiss + } + ) + } + LaunchedEffect(billDismissState) { + snapshotFlow { billDismissState.currentValue } + .collect { value -> + if (value != DismissValue.Default) { + dismissed = true + session.dismissBill(PutInWallet) + } + } + } + LaunchedEffect(dismissed) { + if (dismissed) { + delay(500.milliseconds) + dismissed = false + } + } + + 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)) + } + } + + // 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 tipModalUp = managementHeight > 0.dp && updatedBillState.bill is Scannable.TipCard + val modalSpeed = ModalAnimationSpeed.Normal(updatedBillState.confirmationDelayMillis) + val offset = if (updatedBillState.bill is Scannable.TipCard) CodeTheme.dimens.grid.x8 else CodeTheme.dimens.grid.x2 + val billBottomInset by animateDpAsState( + targetValue = managementHeight + offset, + animationSpec = tween(durationMillis = modalSpeed.duration, delayMillis = modalSpeed.delay), + 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(), + dismissState = billDismissState, + dismissed = dismissed, + contentPadding = PaddingValues( + start = CodeTheme.dimens.inset, + end = CodeTheme.dimens.inset, + top = CodeTheme.dimens.grid.x2, + bottom = billBottomInset, + ), + scannableAlignment = BiasAlignment(horizontalBias = 0f, verticalBias = billVerticalBias), + bill = updatedBillState.bill, + transitionSpec = { + when (updatedState.billResult) { + BillDeterminationResult.None -> EnterTransition.None + Grabbed -> AnimationUtils.animationBillEnterGrabbed + PutInWallet -> AnimationUtils.animationBillEnterGive + } togetherWith when (updatedState.billResult) { + BillDeterminationResult.None -> ExitTransition.None + Grabbed -> AnimationUtils.animationBillExitGrabbed + PutInWallet -> AnimationUtils.animationBillExitReturned + } + } + ) + + // Below-bill content, owned by the scannable type. `displayedScannable` retains the last shown + // scannable so an overlay can still animate OUT as `bill` returns to null on dismiss. + var displayedScannable by remember { mutableStateOf(null) } + LaunchedEffect(updatedBillState.bill) { + updatedBillState.bill?.let { displayedScannable = it } + } + displayedScannable?.let { ScannableDecorator.forScannable(it) }?.let { overlays -> + val overlayContext = ScannableDecoratorContext( + liveBill = updatedBillState.bill, + billState = updatedBillState, + isRemoteSendLoading = updatedState.isRemoteSendLoading, + showManagementOptions = showManagementOptions, + onManagementHeightMeasured = { managementHeight = it }, + onDismiss = { session.dismissBill(PutInWallet) }, + ) + with(overlays) { Content(overlayContext) } + } + } +} diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/cards/TipCard.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/cards/TipCard.kt index 98df7c4b90..d82b1ce09c 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/cards/TipCard.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/cards/TipCard.kt @@ -41,26 +41,13 @@ private const val TipCardAlpha = 0.82f * uniformly by [TipCard] itself (see [TipCardAlpha]) so the card reads identically everywhere — * the differing per-usage opacities were a bug. */ -val LocalTipCardBaseAlpha = staticCompositionLocalOf { TipCardAlpha } +val LocalTipCardBaseAlpha = staticCompositionLocalOf { 1f } /** - * Optional backdrop rendered as the card's bottom layer, clipped to the card's rounded bounds and - * sitting under the translucent fill. Used to give the card a "frosted glass" look over rich - * content behind it (e.g. the live camera in the scanner). Null means no backdrop — the card is - * just its translucent fill. + * Base tint color for the card. Defaults to the opaque [TipCardOpaqueFallback]; callers (e.g. the + * tip-card screen) may override it. The tip card is always rendered as a solid card. */ -val LocalTipCardBackdrop = staticCompositionLocalOf<(@Composable BoxScope.() -> Unit)?> { null } - - - -/** Blur radius used for the card's backdrop, tuned to read like the design's 40px background blur. */ -val TipCardBlurRadius: Dp = 16.dp - -/** - * Base tint color for the card. Defaults to the theme's tip-card color; overridden on the opaque - * fallback path (see [TipCardOpaqueFallback]). - */ -val LocalTipCardColor = staticCompositionLocalOf { Color.Unspecified } +val LocalTipCardColor = staticCompositionLocalOf { TipCardOpaqueFallback } /** * Opaque stand-in for the frosted-glass tone, used on devices where the live blurred-camera @@ -104,7 +91,6 @@ internal fun TipCard( val fillColor = LocalTipCardColor.current .takeOrElse { CodeTheme.colors.tipCardColor } .copy(alpha = LocalTipCardBaseAlpha.current) - val backdrop = LocalTipCardBackdrop.current BoxWithConstraints( modifier = modifier @@ -123,11 +109,6 @@ internal fun TipCard( .clip(RoundedCornerShape(cornerRadius)), contentAlignment = Alignment.Center, ) { - // Backdrop (blurred content behind the card) sits under the translucent fill. Both are - // clipped to the rounded card bounds by the parent's clip. - if (backdrop != null) { - Box(modifier = Modifier.matchParentSize(), content = backdrop) - } Box(modifier = Modifier.matchParentSize().background(color = fillColor)) Column( diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/scrim/Scrim.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/scrim/Scrim.kt index ba373c3299..fe585ec7d6 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/scrim/Scrim.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/scrim/Scrim.kt @@ -52,11 +52,11 @@ class ScrimController { visible = true } - private fun hide() { + /** Conceal the scrim without invoking [onDismiss] (e.g. when its owner state clears itself). */ + fun hide() { visible = false onDismiss = null overlayContent = null - } fun dismiss() {