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
1 change: 1 addition & 0 deletions apps/flipcash/features/scanner/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ dependencies {
implementation(project(":libs:vibrator:bindings"))
implementation(project(":ui:biometrics"))
implementation(project(":ui:scanner"))
implementation(libs.androidx.camerax.view)
implementation(libs.androidx.foundation.layout)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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
Expand Down Expand Up @@ -63,6 +64,9 @@ 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<PreviewView?>(null) }

LaunchedEffect(biometricsState, previewing) {
if (previewing == true) {
focusManager.clearFocus()
Expand All @@ -80,6 +84,7 @@ internal fun Scanner() {
isPaused = isPaused,
isPinching = isPinching,
zoomRatio = zoomRatio,
previewView = previewView,
onAction = {
when (it) {
ScannerDecorItem.Give -> {
Expand Down Expand Up @@ -111,6 +116,7 @@ internal fun Scanner() {
cameraAvailable = true
previewing = it
},
onPreviewViewChanged = { previewView = it },
onCodeScanned = { result ->
when (result) {
is CodeScanResult.QrCode -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@ 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
Expand All @@ -27,12 +31,19 @@ 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.core.android.extensions.launchAppSettings
import com.flipcash.app.core.bill.Scannable
import com.flipcash.app.core.tipping.LocalTipCoordinator
Expand Down Expand Up @@ -68,6 +79,7 @@ internal fun ScannableContainer(
isPaused: Boolean,
isPinching: Boolean = false,
zoomRatio: Float = 1f,
previewView: PreviewView? = null,
scannerView: @Composable () -> Unit,
onAction: (ScannerDecorItem) -> Unit
) {
Expand Down Expand Up @@ -109,6 +121,18 @@ internal fun ScannableContainer(
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 supportsFrostedTipCard = remember {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
context.getSystemService(ActivityManager::class.java)?.isLowRamDevice != true
}

OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_STOP && !autoStart) {
cameraStarted = false
Expand All @@ -119,6 +143,7 @@ internal fun ScannableContainer(
modifier = Modifier
.fillMaxSize()
.then(modifier)
.onGloballyPositioned { containerOriginInWindow = it.positionInWindow() }
.testTag("scanner_view")
) {
val availableUpdate by LocalAppUpdater.current.availableUpdate.collectAsStateWithLifecycle()
Expand Down Expand Up @@ -262,31 +287,50 @@ internal fun ScannableContainer(
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
}
}
// 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 && supportsFrostedTipCard,
containerOriginInWindow = containerOriginInWindow,
)

// Where the frosted backdrop is unavailable, 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 && !supportsFrostedTipCard

CompositionLocalProvider(
LocalTipCardBackdrop provides tipCardBackdrop,
LocalTipCardColor provides if (useOpaqueFallback) TipCardOpaqueFallback else Color.Unspecified,
LocalTipCardBaseAlpha provides if (useOpaqueFallback) 1f else LocalTipCardBaseAlpha.current,
) {
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).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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<ImageBitmap?>(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)
}
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flipcash.app.bills.ScannableRenderer
import com.flipcash.app.bills.components.cards.LocalTipCardColor
import com.flipcash.app.bills.components.cards.LocalTipCardBaseAlpha
import com.flipcash.app.core.bill.Scannable
import com.flipcash.app.core.tipping.TipResult
import com.flipcash.app.core.tipping.TipStep
Expand Down Expand Up @@ -101,7 +101,7 @@ internal fun TipCardScreen() {
Spacer(Modifier.weight(1f))
state.tipCard?.let {
CompositionLocalProvider(
LocalTipCardColor provides Color(0xFF101011)
LocalTipCardBaseAlpha provides 0.36f
) {
ScannableRenderer(
scannable = it,
Expand Down
1 change: 1 addition & 0 deletions apps/flipcash/shared/bills/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ dependencies {
implementation(project(":apps:flipcash:shared:common-ui"))

implementation(libs.androidx.datastore)
implementation(libs.bundles.haze)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,12 @@ package com.flipcash.app.bills

import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import com.flipcash.app.bills.components.bills.CashBill
import com.flipcash.app.bills.components.bills.GoldBar
import com.flipcash.app.bills.components.cards.LocalTipCardColor
import com.flipcash.app.bills.components.cards.TipCard
import com.flipcash.app.core.bill.Scannable
import com.flipcash.app.theme.FlipcashThemeWrapper
Expand Down
Loading
Loading