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 7ebef6fdd..476f96497 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 @@ -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 @@ -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() @@ -74,6 +78,8 @@ internal fun Scanner() { @SuppressLint("LocalContextGetResourceValueCall") BillContainer( isPaused = isPaused, + isPinching = isPinching, + zoomRatio = zoomRatio, onAction = { when (it) { ScannerDecorItem.Give -> { @@ -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 diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/BillContainerView.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/BillContainerView.kt index 9e48d890a..891845dae 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/BillContainerView.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/BillContainerView.kt @@ -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 ) { @@ -199,6 +201,8 @@ internal fun BillContainer( state = updatedState, billState = updatedBillState, isPaused = isPaused, + isPinching = isPinching, + zoomRatio = zoomRatio, onAction = onAction ) } diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/DecorView.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/DecorView.kt index 858dec0e0..25690a041 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/DecorView.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/DecorView.kt @@ -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 @@ -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 @@ -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 @@ -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 @@ -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, ) { @@ -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() diff --git a/ui/scanner/src/main/kotlin/com/getcode/ui/scanner/CodeScanner.kt b/ui/scanner/src/main/kotlin/com/getcode/ui/scanner/CodeScanner.kt index ce31d8a40..851ec436c 100644 --- a/ui/scanner/src/main/kotlin/com/getcode/ui/scanner/CodeScanner.kt +++ b/ui/scanner/src/main/kotlin/com/getcode/ui/scanner/CodeScanner.kt @@ -11,7 +11,6 @@ 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 @@ -19,8 +18,10 @@ 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 @@ -28,7 +29,6 @@ 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 @@ -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 = { }, @@ -98,6 +98,8 @@ fun CodeScanner( var camera by remember { mutableStateOf(null) } var autoFocusPoint by remember { mutableStateOf(Offset.Unspecified) } var gestureController by remember { mutableStateOf(null) } + var isPinching by remember { mutableStateOf(false) } + var zoomRatio by remember { mutableFloatStateOf(1f) } val codeAnalyzer = rememberMultiCodeAnalyzer( onCodeScanned = onCodeScanned, @@ -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) @@ -281,4 +283,4 @@ suspend fun bindWithRetry( throw NoCamerasAvailableException() } -class NoCamerasAvailableException: Throwable() \ No newline at end of file +class NoCamerasAvailableException : Throwable() \ No newline at end of file diff --git a/ui/scanner/src/main/kotlin/com/getcode/ui/scanner/internal/CameraGestureController.kt b/ui/scanner/src/main/kotlin/com/getcode/ui/scanner/internal/CameraGestureController.kt index 259511b0d..1422b5cff 100644 --- a/ui/scanner/src/main/kotlin/com/getcode/ui/scanner/internal/CameraGestureController.kt +++ b/ui/scanner/src/main/kotlin/com/getcode/ui/scanner/internal/CameraGestureController.kt @@ -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 @@ -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 } @@ -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) {} @@ -129,9 +110,7 @@ internal class CameraGestureController( e2: MotionEvent, velocityX: Float, velocityY: Float - ): Boolean { - return false - } + ): Boolean = false } ) @@ -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 + } } } }