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 @@ -6,6 +6,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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
Expand Down Expand Up @@ -59,6 +60,9 @@ internal fun Scanner() {

val vibrator = LocalVibrator.current

var isPinching by remember { mutableStateOf(false) }
var zoomRatio by remember { mutableFloatStateOf(1f) }

LaunchedEffect(biometricsState, previewing) {
if (previewing == true) {
focusManager.clearFocus()
Expand All @@ -74,6 +78,8 @@ internal fun Scanner() {
@SuppressLint("LocalContextGetResourceValueCall")
BillContainer(
isPaused = isPaused,
isPinching = isPinching,
zoomRatio = zoomRatio,
onAction = {
when (it) {
ScannerDecorItem.Give -> {
Expand All @@ -95,7 +101,10 @@ internal fun Scanner() {
CodeScanner(
scanningEnabled = previewing == true,
cameraGesturesEnabled = true,
invertedDragZoomEnabled = true,
onPinchStateChanged = { pinching, zoom ->
isPinching = pinching
zoomRatio = zoom
},
onPreviewStateChanged = {
cameraAvailable = true
previewing = it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ import kotlinx.coroutines.delay
internal fun BillContainer(
modifier: Modifier = Modifier,
isPaused: Boolean,
isPinching: Boolean = false,
zoomRatio: Float = 1f,
scannerView: @Composable () -> Unit,
onAction: (ScannerDecorItem) -> Unit
) {
Expand Down Expand Up @@ -199,6 +201,8 @@ internal fun BillContainer(
state = updatedState,
billState = updatedBillState,
isPaused = isPaused,
isPinching = isPinching,
zoomRatio = zoomRatio,
onAction = onAction
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
Expand All @@ -21,6 +22,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
Expand All @@ -35,7 +37,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flipcash.app.bill.customization.LocalBillPlaygroundController
import com.flipcash.app.core.bill.BillState
Expand All @@ -44,6 +48,7 @@ import com.flipcash.app.session.SessionState
import com.flipcash.features.scanner.R
import com.getcode.theme.CodeTheme
import com.getcode.theme.xxl
import com.getcode.ui.components.Pill
import com.getcode.ui.core.rememberedClickable
import com.getcode.ui.core.unboundedClickable
import com.getcode.utils.network.LocalNetworkObserver
Expand All @@ -53,6 +58,8 @@ internal fun DecorView(
state: SessionState,
billState: BillState,
isPaused: Boolean,
isPinching: Boolean = false,
zoomRatio: Float = 1f,
modifier: Modifier = Modifier,
onAction: (ScannerDecorItem) -> Unit,
) {
Expand Down Expand Up @@ -111,6 +118,29 @@ internal fun DecorView(
)
}

AnimatedVisibility(
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(top = CodeTheme.dimens.grid.x9),
visible = isPinching,
enter = fadeIn(tween(150)),
exit = fadeOut(tween(250)),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
Pill(
text = "${"%.1f".format(zoomRatio)}x",
textStyle = CodeTheme.typography.textSmall.copy(
fontWeight = FontWeight.Bold
),
shape = CodeTheme.shapes.xxl,
)
}
}

Column(modifier = Modifier.align(Alignment.BottomCenter)) {
val networkState by LocalNetworkObserver.current.state.collectAsState()

Expand Down
14 changes: 8 additions & 6 deletions ui/scanner/src/main/kotlin/com/getcode/ui/scanner/CodeScanner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,24 @@ import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.runtime.DisposableEffect
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
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 androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
Expand Down Expand Up @@ -59,8 +59,8 @@ import java.util.concurrent.Executors
fun CodeScanner(
scanningEnabled: Boolean,
cameraGesturesEnabled: Boolean,
invertedDragZoomEnabled: Boolean,
modifier: Modifier = Modifier,
onPinchStateChanged: (Boolean, Float) -> Unit,
onPreviewStateChanged: (Boolean) -> Unit,
onCodeScanned: (CodeScanResult) -> Unit,
onError: (Throwable) -> Unit = { },
Expand Down Expand Up @@ -98,6 +98,8 @@ fun CodeScanner(
var camera by remember { mutableStateOf<Camera?>(null) }
var autoFocusPoint by remember { mutableStateOf(Offset.Unspecified) }
var gestureController by remember { mutableStateOf<CameraGestureController?>(null) }
var isPinching by remember { mutableStateOf(false) }
var zoomRatio by remember { mutableFloatStateOf(1f) }

val codeAnalyzer = rememberMultiCodeAnalyzer(
onCodeScanned = onCodeScanned,
Expand Down Expand Up @@ -164,14 +166,14 @@ fun CodeScanner(
}
}

LaunchedEffect(camera, cameraGesturesEnabled, invertedDragZoomEnabled) {
LaunchedEffect(camera, cameraGesturesEnabled) {
camera?.let {
gestureController = CameraGestureController(
context = context,
cameraControl = it.cameraControl,
cameraInfo = it.cameraInfo,
gesturesEnabled = cameraGesturesEnabled,
invertedDragEnabled = invertedDragZoomEnabled
onPinchStateChanged = onPinchStateChanged,
) { touchedAt ->
autoFocusPoint = touchedAt
previewView.meteringPointFactory.createPoint(touchedAt.x, touchedAt.y)
Expand Down Expand Up @@ -281,4 +283,4 @@ suspend fun bindWithRetry(
throw NoCamerasAvailableException()
}

class NoCamerasAvailableException: Throwable()
class NoCamerasAvailableException : Throwable()
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,24 @@ import androidx.camera.core.CameraInfo
import androidx.camera.core.FocusMeteringAction
import androidx.camera.core.MeteringPoint
import androidx.compose.ui.geometry.Offset
import com.getcode.ui.utils.AnimationUtils
import java.util.concurrent.TimeUnit
import kotlin.math.pow

internal class CameraGestureController(
context: Context,
invertedDragEnabled: Boolean,
private val gesturesEnabled: Boolean,
private val cameraControl: CameraControl,
private val cameraInfo: CameraInfo,
private val onPinchStateChanged: (isPinching: Boolean, zoomRatio: Float) -> Unit = { _, _ -> },
onTap: (Offset) -> MeteringPoint,
) {
private val handler = Handler(Looper.getMainLooper())
private var shouldIgnoreScroll = false
private var resetIgnore: Runnable? = null
private var initialZoomRatio = 0f
private var initialZoomLevel = -1f
private var accumulatedDelta = 0f
private var gestureZoomFactor = 1f
private var cumulativeScale = 1f
private var appliedZoom = 1f
private var isPinching = false

private val maxZoom: Float
get() = maxZoomOrNull ?: 1f
Expand All @@ -48,40 +49,40 @@ internal class CameraGestureController(
context,
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
shouldIgnoreScroll = true
resetIgnore?.let { handler.removeCallbacks(it) }
isPinching = true
gestureZoomFactor = currentZoom
appliedZoom = currentZoom
cumulativeScale = 1f
onPinchStateChanged(true, currentZoom)
return true
}

override fun onScale(detector: ScaleGestureDetector): Boolean {
val delta = detector.scaleFactor
val newZoomRatio = currentZoom * delta

// Clamp the new zoom ratio between the minimum and maximum zoom ratio
val clampedZoomRatio = newZoomRatio.coerceIn(
cumulativeScale *= detector.scaleFactor
val amplified = cumulativeScale.toDouble().pow(1.3).toFloat()
val targetZoom = (gestureZoomFactor * amplified).coerceIn(
minZoom,
maxZoomOrNull ?: currentZoom
minOf(maxZoom, 20f)
)

// Apply the zoom to the camera control
cameraControl.setZoomRatio(clampedZoomRatio)
// Lerp toward target to smooth lens-switch transitions
appliedZoom += (targetZoom - appliedZoom) * 0.4f
cameraControl.setZoomRatio(appliedZoom)
onPinchStateChanged(true, appliedZoom)
return true
}

override fun onScaleEnd(detector: ScaleGestureDetector) {
initialZoomRatio = currentZoom
resetIgnore = Runnable { shouldIgnoreScroll = false }
resetIgnore?.let { handler.postDelayed(it, 500) }
cumulativeScale = 1f
}
})

// Gesture detector for tap and drag-to-zoom
// Gesture detector for tap-to-focus
private val gestureDetector = GestureDetector(
context,
object : GestureDetector.OnGestureListener {
override fun onDown(e: MotionEvent): Boolean {
initialZoomRatio = currentZoom
accumulatedDelta = 0f
return true
}

Expand All @@ -100,27 +101,7 @@ internal class CameraGestureController(
e2: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
if (!shouldIgnoreScroll) {
accumulatedDelta = if (invertedDragEnabled) {
accumulatedDelta + distanceY * 0.5f
} else {
accumulatedDelta - distanceY * 0.5f
}

val zoomDelta = AnimationUtils.ease(
value = accumulatedDelta,
fromRange = 0f..250f,
toRange = 0f..10f,
easeIn = true,
easeOut = false
)

val newZoom = (initialZoomRatio + zoomDelta).coerceIn(minZoom, maxZoom)
cameraControl.setZoomRatio(newZoom)
}
return true
}
): Boolean = false

override fun onShowPress(e: MotionEvent) {}
override fun onLongPress(e: MotionEvent) {}
Expand All @@ -129,9 +110,7 @@ internal class CameraGestureController(
e2: MotionEvent,
velocityX: Float,
velocityY: Float
): Boolean {
return false
}
): Boolean = false
}
)

Expand All @@ -145,8 +124,12 @@ internal class CameraGestureController(
gestureDetector.onTouchEvent(event)

if (event.action == MotionEvent.ACTION_UP) {
animateZoomReset(cameraInfo, cameraControl)
initialZoomRatio = currentZoom
if (isPinching) {
onPinchStateChanged(false, currentZoom)
animateZoomReset(cameraInfo, cameraControl)
initialZoomRatio = currentZoom
isPinching = false
}
}
}
}
Expand Down
Loading