Skip to content

Commit 3850659

Browse files
authored
Merge pull request #530 from code-payments/feat/camera-ux-controls
feat: add AF and pinch-to-zoom on Camera
2 parents c67a120 + d81cbf9 commit 3850659

12 files changed

Lines changed: 316 additions & 62 deletions

File tree

api/src/main/java/com/getcode/model/Feature.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,14 @@ data class RequestKinFeature(
4141
data class BalanceCurrencyFeature(
4242
override val enabled: Boolean = BetaOptions.Defaults.balanceCurrencySelectionEnabled,
4343
override val available: Boolean = true, // always available
44+
): Feature
45+
46+
data class CameraAFFeature(
47+
override val enabled: Boolean = BetaOptions.Defaults.cameraAFEnabled,
48+
override val available: Boolean = true, // always available
49+
): Feature
50+
51+
data class CameraZoomFeature(
52+
override val enabled: Boolean = BetaOptions.Defaults.cameraPinchZoomEnabled,
53+
override val available: Boolean = true, // always available
4454
): Feature

api/src/main/java/com/getcode/model/PrefBool.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ sealed class PrefsBool(val value: String) {
6060
data object KADO_WEBVIEW_ENABLED : PrefsBool("kado_inapp_enabled"), BetaFlag
6161
data object SHARE_TWEET_TO_TIP : PrefsBool("share_tweet_to_tip"), BetaFlag, Immutable
6262
data object TIP_CARD_ON_HOMESCREEN: PrefsBool("tip_card_on_home_screen"), BetaFlag, Immutable
63+
64+
data object CAMERA_AF_ENABLED: PrefsBool("camera_af_enabled"), BetaFlag
65+
data object CAMERA_PINCH_ZOOM: PrefsBool("camera_pinch_zoom_enabled"), BetaFlag
6366
}
6467

6568
val APP_SETTINGS: List<AppSetting> = listOf(PrefsBool.CAMERA_START_BY_DEFAULT, PrefsBool.REQUIRE_BIOMETRICS)

api/src/main/java/com/getcode/network/TipController.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,11 +164,15 @@ class TipController @Inject constructor(
164164
userMetadata = null
165165
}
166166

167-
fun seenTipCardBanner() {
167+
fun onSeenTipCardBanner() {
168168
prefRepository.set(PrefsBool.DISMISSED_TIP_CARD_BANNER, true)
169169
endVerification()
170170
}
171171

172+
suspend fun hasSeenTipCard(): Boolean {
173+
return prefRepository.get(PrefsBool.SEEN_TIP_CARD, false)
174+
}
175+
172176
fun clearTwitterSplat() {
173177
prefRepository.set(PrefsBool.SEEN_TIP_CARD, true)
174178
}

api/src/main/java/com/getcode/network/repository/BetaFlagsRepository.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ data class BetaOptions(
2121
val kadoWebViewEnabled: Boolean,
2222
val shareTweetToTip: Boolean,
2323
val tipCardOnHomeScreen: Boolean,
24+
val cameraAFEnabled: Boolean,
25+
val cameraPinchZoomEnabled: Boolean,
2426
) {
2527
companion object {
2628
// Default states for various beta flags in app.
@@ -40,6 +42,8 @@ data class BetaOptions(
4042
kadoWebViewEnabled = false,
4143
shareTweetToTip = true,
4244
tipCardOnHomeScreen = true,
45+
cameraAFEnabled = true,
46+
cameraPinchZoomEnabled = true,
4347
)
4448
}
4549
}
@@ -76,7 +80,9 @@ class BetaFlagsRepository @Inject constructor(
7680
observeBetaFlag(PrefsBool.DISPLAY_ERRORS, default = defaults.displayErrors),
7781
observeBetaFlag(PrefsBool.KADO_WEBVIEW_ENABLED, default = defaults.kadoWebViewEnabled),
7882
observeBetaFlag(PrefsBool.SHARE_TWEET_TO_TIP, default = defaults.shareTweetToTip),
79-
observeBetaFlag(PrefsBool.TIP_CARD_ON_HOMESCREEN, defaults.tipCardOnHomeScreen)
83+
observeBetaFlag(PrefsBool.TIP_CARD_ON_HOMESCREEN, defaults.tipCardOnHomeScreen),
84+
observeBetaFlag(PrefsBool.CAMERA_AF_ENABLED, defaults.cameraAFEnabled),
85+
observeBetaFlag(PrefsBool.CAMERA_PINCH_ZOOM, defaults.cameraPinchZoomEnabled),
8086
) {
8187
BetaOptions(
8288
showNetworkDropOff = it[0],
@@ -93,7 +99,9 @@ class BetaFlagsRepository @Inject constructor(
9399
displayErrors = it[11],
94100
kadoWebViewEnabled = it[12],
95101
shareTweetToTip = it[13],
96-
tipCardOnHomeScreen = it[14]
102+
tipCardOnHomeScreen = it[14],
103+
cameraAFEnabled = it[15],
104+
cameraPinchZoomEnabled = it[16]
97105
)
98106
}
99107
}

api/src/main/java/com/getcode/network/repository/FeatureRepository.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.getcode.network.repository
22

33
import com.getcode.model.BalanceCurrencyFeature
44
import com.getcode.model.BuyModuleFeature
5+
import com.getcode.model.CameraAFFeature
56
import com.getcode.model.PrefsBool
67
import com.getcode.model.RequestKinFeature
78
import com.getcode.model.TipCardFeature
@@ -29,6 +30,9 @@ class FeatureRepository @Inject constructor(
2930
val conversations = betaFlags.observe().map { ConversationsFeature(it.conversationsEnabled) }
3031
val conversationsCash = betaFlags.observe().map { ConversationCashFeature(it.conversationCashEnabled) }
3132

33+
val cameraAutoFocus = betaFlags.observe().map { CameraAFFeature(it.cameraAFEnabled) }
34+
val cameraPinchZoom = betaFlags.observe().map { CameraAFFeature(it.cameraPinchZoomEnabled) }
35+
3236
val requestKin = betaFlags.observe().map { RequestKinFeature(it.giveRequestsEnabled) }
3337

3438
val balanceCurrencySelection = betaFlags.observe().map { BalanceCurrencyFeature(it.balanceCurrencySelectionEnabled) }

app/src/main/java/com/getcode/view/main/account/BetaFlagsScreen.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,18 @@ fun BetaFlagsScreen(
120120
stringResource(id = R.string.beta_share_tweet_tip_description),
121121
state.shareTweetToTip,
122122
),
123+
BetaFeature(
124+
PrefsBool.CAMERA_AF_ENABLED,
125+
R.string.beta_camera_af,
126+
stringResource(id = R.string.beta_camera_af_description),
127+
state.cameraAFEnabled,
128+
),
129+
BetaFeature(
130+
PrefsBool.CAMERA_PINCH_ZOOM,
131+
R.string.beta_camera_pinch_zoom,
132+
stringResource(id = R.string.beta_camera_pinch_zoom),
133+
state.cameraPinchZoomEnabled,
134+
),
123135
BetaFeature(
124136
PrefsBool.DISPLAY_ERRORS,
125137
R.string.beta_display_errors,

app/src/main/java/com/getcode/view/main/account/BetaFlagsViewModel.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.getcode.view.main.account
22

3+
import androidx.compose.ui.res.stringResource
34
import androidx.lifecycle.viewModelScope
5+
import com.getcode.R
46
import com.getcode.model.PrefsBool
57
import com.getcode.network.repository.BetaFlagsRepository
68
import com.getcode.network.repository.BetaOptions

app/src/main/java/com/getcode/view/main/home/components/CodeScanner.kt renamed to app/src/main/java/com/getcode/view/main/camera/CodeScanner.kt

Lines changed: 109 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1-
package com.getcode.view.main.home.components
1+
package com.getcode.view.main.camera
22

33
import android.content.Context
4+
import android.view.GestureDetector
5+
import android.view.MotionEvent
6+
import android.view.ScaleGestureDetector
7+
import androidx.camera.core.Camera
8+
import androidx.camera.core.CameraControl
9+
import androidx.camera.core.CameraInfo
410
import androidx.camera.core.CameraSelector
11+
import androidx.camera.core.FocusMeteringAction
512
import androidx.camera.core.ImageAnalysis
613
import androidx.camera.core.Preview
714
import androidx.camera.lifecycle.ProcessCameraProvider
@@ -15,14 +22,14 @@ import androidx.compose.foundation.background
1522
import androidx.compose.foundation.layout.Box
1623
import androidx.compose.foundation.layout.fillMaxSize
1724
import androidx.compose.runtime.Composable
18-
import androidx.compose.runtime.DisposableEffect
1925
import androidx.compose.runtime.LaunchedEffect
2026
import androidx.compose.runtime.getValue
2127
import androidx.compose.runtime.mutableStateOf
2228
import androidx.compose.runtime.remember
2329
import androidx.compose.runtime.rememberCoroutineScope
2430
import androidx.compose.runtime.setValue
2531
import androidx.compose.ui.Modifier
32+
import androidx.compose.ui.geometry.Offset
2633
import androidx.compose.ui.platform.LocalContext
2734
import androidx.compose.ui.platform.LocalLifecycleOwner
2835
import androidx.compose.ui.viewinterop.AndroidView
@@ -45,10 +52,13 @@ import kotlinx.coroutines.launch
4552
import kotlinx.coroutines.withContext
4653
import timber.log.Timber
4754
import java.util.concurrent.Executors
55+
import java.util.concurrent.TimeUnit
4856

4957
@Composable
5058
fun CodeScanner(
5159
scanningEnabled: Boolean,
60+
cameraAFEnabled: Boolean,
61+
cameraPinchZoomEnabled: Boolean,
5262
onPreviewStateChanged: (Boolean) -> Unit,
5363
onCodeScanned: (ScannableKikCode) -> Unit
5464
) {
@@ -68,7 +78,9 @@ fun CodeScanner(
6878

6979
val cameraSelector = remember {
7080
val lensFacing = CameraSelector.LENS_FACING_BACK
71-
CameraSelector.Builder().requireLensFacing(lensFacing).build()
81+
CameraSelector.Builder()
82+
.requireLensFacing(lensFacing)
83+
.build()
7284
}
7385

7486
val imageAnalysis = remember {
@@ -84,20 +96,23 @@ fun CodeScanner(
8496
KikCodeAnalyzer(scanner, onCodeScanned)
8597
}
8698

87-
var bound by remember {
88-
mutableStateOf(false)
89-
}
90-
9199
val biometricsState = LocalBiometricsState.current
92100

101+
var camera by remember { mutableStateOf<Camera?>(null) }
102+
var autoFocusPoint by remember { mutableStateOf(Offset.Unspecified) }
103+
93104
val scope = rememberCoroutineScope()
94105
LaunchedEffect(scanner, biometricsState.isAwaitingAuthentication, Biometrics.promptActive) {
95106
val active = Biometrics.promptActive || biometricsState.isAwaitingAuthentication
96107
val cameraProvider = context.getCameraProvider()
97108
if (!active) {
98109
cameraProvider.unbindAll()
99-
cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
100-
bound = true
110+
camera = cameraProvider.bindToLifecycle(
111+
lifecycleOwner,
112+
cameraSelector,
113+
preview,
114+
imageAnalysis
115+
)
101116
} else {
102117
cameraProvider.unbindAll()
103118
}
@@ -108,26 +123,41 @@ fun CodeScanner(
108123
scope.launch {
109124
val cameraProvider = context.getCameraProvider()
110125
cameraProvider.unbindAll()
111-
bound = false
126+
camera = null
112127
}
113128
} else if (event == Lifecycle.Event.ON_RESUME) {
114129
scope.launch {
115-
if (!bound) {
130+
if (camera == null) {
116131
if (!biometricsState.isAwaitingAuthentication) {
117132
val cameraProvider = context.getCameraProvider()
118133
cameraProvider.unbindAll()
119-
cameraProvider.bindToLifecycle(
134+
camera = cameraProvider.bindToLifecycle(
120135
lifecycleOwner,
121136
cameraSelector,
122137
preview,
123138
imageAnalysis
124139
)
125-
bound = true
126140
}
127141
}
128142
}
129143
}
144+
}
145+
146+
LaunchedEffect(camera, cameraAFEnabled, cameraPinchZoomEnabled) {
147+
camera?.let {
148+
val cameraControl = it.cameraControl
149+
val cameraInfo = it.cameraInfo
130150

151+
setupInteractionControls(
152+
previewView,
153+
cameraControl,
154+
cameraInfo,
155+
cameraAFEnabled,
156+
cameraPinchZoomEnabled
157+
) { point ->
158+
autoFocusPoint = point
159+
}
160+
}
131161
}
132162

133163
var streamState by remember(previewView) {
@@ -161,6 +191,10 @@ fun CodeScanner(
161191

162192
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
163193

194+
FocusIndicator(autoFocusPoint) {
195+
autoFocusPoint = Offset.Unspecified
196+
}
197+
164198
AnimatedVisibility(
165199
modifier = Modifier.fillMaxSize(),
166200
visible = streamState != PreviewView.StreamState.STREAMING,
@@ -179,4 +213,66 @@ private suspend fun Context.getCameraProvider(): ProcessCameraProvider {
179213
return withContext(Dispatchers.IO) {
180214
ProcessCameraProvider.getInstance(this@getCameraProvider).get()
181215
}
216+
}
217+
218+
private fun setupInteractionControls(
219+
previewView: PreviewView,
220+
cameraControl: CameraControl,
221+
cameraInfo: CameraInfo,
222+
autoFocusEnabled: Boolean,
223+
pinchZoomEnabled: Boolean,
224+
onTap: (Offset) -> Unit,
225+
) {
226+
val scaleGestureDetector = ScaleGestureDetector(
227+
previewView.context,
228+
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
229+
override fun onScale(detector: ScaleGestureDetector): Boolean {
230+
val currentZoomRatio = cameraInfo.zoomState.value?.zoomRatio ?: 1f
231+
val delta = detector.scaleFactor
232+
val newZoomRatio = currentZoomRatio * delta
233+
234+
// Clamp the new zoom ratio between the minimum and maximum zoom ratio
235+
val clampedZoomRatio = newZoomRatio.coerceIn(
236+
cameraInfo.zoomState.value?.minZoomRatio ?: 1f,
237+
cameraInfo.zoomState.value?.maxZoomRatio ?: currentZoomRatio
238+
)
239+
240+
// Apply the zoom to the camera control
241+
cameraControl.setZoomRatio(clampedZoomRatio)
242+
return true
243+
}
244+
})
245+
246+
// Create a gesture detector to detect tap gestures
247+
val gestureDetector =
248+
GestureDetector(previewView.context, object : GestureDetector.SimpleOnGestureListener() {
249+
override fun onSingleTapUp(event: MotionEvent): Boolean {
250+
// Get the tap location
251+
val point = previewView.meteringPointFactory.createPoint(event.x, event.y)
252+
onTap(Offset(event.x, event.y))
253+
254+
// Prepare focus action
255+
val action = FocusMeteringAction.Builder(point, FocusMeteringAction.FLAG_AF)
256+
.setAutoCancelDuration(
257+
5,
258+
TimeUnit.SECONDS
259+
) // Optional: Auto-cancel after 5 seconds
260+
.build()
261+
262+
// Trigger focus and metering at the tapped location
263+
cameraControl.startFocusAndMetering(action)
264+
265+
return true
266+
}
267+
})
268+
269+
previewView.setOnTouchListener { _, event ->
270+
if (pinchZoomEnabled) {
271+
scaleGestureDetector.onTouchEvent(event)
272+
}
273+
if (autoFocusEnabled) {
274+
gestureDetector.onTouchEvent(event)
275+
}
276+
true
277+
}
182278
}

0 commit comments

Comments
 (0)