feat(android-terminal): Phase B — setup screen with QR scanner
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

End-to-end pairing flow:

1. SetupScreen renders a CameraX preview on the left, a manual-entry
   form on the right (dev fallback). Camera permission is requested
   in-place — no accompanist dep.
2. QrScannerView uses ML Kit's barcode scanner (QR format only),
   single-shot fires the decoded JSON to the ViewModel and stops
   analysing.
3. SetupViewModel.pairFromQr decodes via Moshi, persists the pairing
   in DataStore, then verifies by hitting /api/v1/store/loyalty/program
   through the AuthInterceptor (which now sees the new url + token).
   On 200 it warms the staff PIN and category caches and emits Success;
   on failure it rolls back via DeviceConfigRepository.resetDevice() so
   the user is back at a clean Setup with an error.
4. The NavHost watches is_device_set_up and forwards to PIN once Success
   fires. The DataStore key was aligned to "is_device_set_up" so this
   reactive switch keeps working.

Backend: the QR payload generated by POST /merchants/loyalty/devices
now includes store_id and store_name in addition to api_url, store_code
and auth_token, so the tablet doesn't have to resolve them later via a
separate call. Old QRs (which only had three fields) won't decode — the
merchant has to revoke and re-pair, which is the same flow they'd run
anyway after losing a tablet.

Files:
- ui/scanner/QrScannerView.kt    (new) — CameraX + ML Kit composable
- ui/setup/SetupViewModel.kt     (rewrite) — pair flow + state machine
- ui/setup/SetupScreen.kt        (rewrite) — two-pane layout, status overlay
- data/model/ApiModels.kt        — SetupPayload model
- data/repository/DeviceConfigRepository.kt — IS_SET_UP key alignment
- app/modules/loyalty/services/terminal_device_service.py — richer QR payload

Verified by ./gradlew assembleDebug — clean build, all warnings address
in this commit (LocalLifecycleOwner moved to lifecycle.compose, OptIn on
ExperimentalGetImage removed since it's no longer @RequiresOptIn).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 22:32:25 +02:00
parent 3531ab8405
commit a0e3461c48
6 changed files with 539 additions and 38 deletions

View File

@@ -144,7 +144,9 @@ class TerminalDeviceService:
payload = {
"api_url": settings.app_base_url,
"store_id": store.id,
"store_code": store.store_code,
"store_name": store.name,
"auth_token": token,
}
qr_png_base64 = self._render_qr_png_base64(json.dumps(payload))

View File

@@ -190,6 +190,24 @@ data class PinItem(
val is_locked: Boolean,
)
// ── Pairing ─────────────────────────────────────────────────────────────
/**
* JSON payload encoded in the pairing QR generated by
* ``POST /api/v1/merchants/loyalty/devices`` on the backend.
*
* The tablet decodes this once at the setup screen, persists the three
* fields via DeviceConfigRepository, and then never sees the QR again.
*/
@JsonClass(generateAdapter = true)
data class SetupPayload(
val api_url: String,
val store_id: Int,
val store_code: String,
val store_name: String? = null,
val auth_token: String,
)
// ── Categories ──────────────────────────────────────────────────────────
@JsonClass(generateAdapter = true)

View File

@@ -104,7 +104,9 @@ class DeviceConfigRepository @Inject constructor(
private val KEY_STORE_ID = intPreferencesKey("store_id")
private val KEY_STORE_CODE = stringPreferencesKey("store_code")
private val KEY_STORE_NAME = stringPreferencesKey("store_name")
private val KEY_IS_SET_UP = booleanPreferencesKey("IS_SET_UP")
// Matches the key the existing SetupViewModel observed; keep this
// exact string so the NavHost's start-destination switch works.
private val KEY_IS_SET_UP = booleanPreferencesKey("is_device_set_up")
private val KEY_PROGRAM_JSON = stringPreferencesKey("program_json")
private val KEY_STAFF_PINS_JSON = stringPreferencesKey("staff_pins_json")
private val KEY_CATEGORIES_JSON = stringPreferencesKey("categories_json")

View File

@@ -0,0 +1,184 @@
package lu.rewardflow.terminal.ui.scanner
import android.Manifest
import android.content.pm.PackageManager
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.mlkit.vision.barcode.BarcodeScanner
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import lu.rewardflow.terminal.R
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
/**
* CameraX preview that scans QR codes via ML Kit.
*
* Calls [onQrScanned] exactly once with the decoded raw value, then stops
* analysing. Re-mount the composable (or have the caller key it on a state)
* to scan again.
*
* Camera permission is requested in-place — when the user has not granted
* it, the preview area is replaced by a permission prompt. Manifest already
* declares `android.permission.CAMERA` as optional.
*/
@Composable
fun QrScannerView(
modifier: Modifier = Modifier,
onQrScanned: (String) -> Unit,
) {
val context = LocalContext.current
var hasCameraPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(
context, Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
)
}
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
hasCameraPermission = granted
}
Box(modifier = modifier.fillMaxSize()) {
if (hasCameraPermission) {
CameraXPreview(onQrScanned = onQrScanned)
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.setup_camera_permission_required),
style = MaterialTheme.typography.bodyLarge,
)
Button(
modifier = Modifier.padding(top = 16.dp),
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
) {
Text(text = stringResource(R.string.setup_connect))
}
}
}
}
// Trigger the permission prompt the first time the composable is shown.
DisposableEffect(Unit) {
if (!hasCameraPermission) {
permissionLauncher.launch(Manifest.permission.CAMERA)
}
onDispose { }
}
}
@Composable
private fun CameraXPreview(
onQrScanned: (String) -> Unit,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val executor = remember { Executors.newSingleThreadExecutor() }
val alreadyScanned = remember { AtomicBoolean(false) }
val barcodeScanner: BarcodeScanner = remember {
BarcodeScanning.getClient(
BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build()
)
}
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
val previewView = PreviewView(ctx)
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val analysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
analysis.setAnalyzer(executor) { imageProxy ->
val mediaImage = imageProxy.image
if (mediaImage == null || alreadyScanned.get()) {
imageProxy.close()
return@setAnalyzer
}
val image = InputImage.fromMediaImage(
mediaImage,
imageProxy.imageInfo.rotationDegrees,
)
barcodeScanner.process(image)
.addOnSuccessListener { barcodes ->
val raw = barcodes.firstOrNull()?.rawValue
if (!raw.isNullOrBlank() && alreadyScanned.compareAndSet(false, true)) {
onQrScanned(raw)
}
}
.addOnFailureListener { e ->
Log.w("QrScannerView", "Barcode scan failed", e)
}
.addOnCompleteListener { imageProxy.close() }
}
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analysis,
)
}, ContextCompat.getMainExecutor(ctx))
previewView
},
)
DisposableEffect(Unit) {
onDispose {
executor.shutdown()
barcodeScanner.close()
}
}
}

View File

@@ -1,49 +1,237 @@
package lu.rewardflow.terminal.ui.setup
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import lu.rewardflow.terminal.R
import lu.rewardflow.terminal.ui.scanner.QrScannerView
/**
* First-time device setup screen.
* First-time pairing screen.
*
* Flow:
* 1. Show "Scan QR Code" prompt
* 2. Merchant owner scans QR from web settings page
* 3. QR contains: API URL + store auth token + store_id + store_code
* 4. App downloads store config and enters kiosk mode
* Left half: live QR scanner. Decoded value is fed to [SetupViewModel.pairFromQr]
* which verifies with the server and persists the configuration.
*
* Right half: manual entry form (dev fallback). Same downstream flow.
*
* On successful pair, [onSetupComplete] is invoked once — the NavHost reacts
* to the persisted ``is_device_set_up`` flag and forwards to the PIN screen.
*/
@Composable
fun SetupScreen(
onSetupComplete: () -> Unit,
viewModel: SetupViewModel = hiltViewModel(),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(48.dp),
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(state) {
if (state is SetupState.Success) onSetupComplete()
}
Row(modifier = Modifier.fillMaxSize()) {
// ── Left half: QR scanner
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(Color.Black),
) {
Text(
text = "RewardFlow Terminal",
style = MaterialTheme.typography.headlineLarge,
QrScannerView(
onQrScanned = { raw -> viewModel.pairFromQr(raw) },
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Scan the setup QR code from your store settings page to configure this device.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(32.dp))
// TODO: CameraX QR scanner for setup code
Button(onClick = onSetupComplete) {
Text("Setup Complete (placeholder)")
// Status overlay on top of the camera preview
StatusOverlay(state = state, onDismissError = viewModel::resetError)
}
// ── Right half: manual entry + instruction
ManualEntryPanel(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(32.dp),
isConnecting = state is SetupState.Connecting,
onConnect = { apiUrl, storeId, storeCode, token ->
viewModel.pairManually(apiUrl, storeId, storeCode, token)
},
)
}
}
@Composable
private fun StatusOverlay(
state: SetupState,
onDismissError: () -> Unit,
) {
when (state) {
is SetupState.Connecting -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.55f)),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = Color.White)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.setup_connecting),
color = Color.White,
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
is SetupState.Error -> {
val message = when (val e = state.reason) {
SetupError.InvalidQr -> stringResource(R.string.setup_invalid_qr)
is SetupError.ConnectionFailed ->
e.detail ?: stringResource(R.string.setup_connection_failed)
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
contentAlignment = Alignment.BottomCenter,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.errorContainer)
.padding(16.dp),
) {
Text(
text = message,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
Button(onClick = onDismissError) {
Text(stringResource(R.string.error_try_again))
}
}
}
}
else -> Unit
}
}
@Composable
private fun ManualEntryPanel(
modifier: Modifier = Modifier,
isConnecting: Boolean,
onConnect: (apiUrl: String, storeId: Int, storeCode: String, authToken: String) -> Unit,
) {
var apiUrl by rememberSaveable { mutableStateOf("") }
var storeIdText by rememberSaveable { mutableStateOf("") }
var storeCode by rememberSaveable { mutableStateOf("") }
var authToken by rememberSaveable { mutableStateOf("") }
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(R.string.setup_title),
style = MaterialTheme.typography.headlineMedium,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.setup_instruction),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(24.dp))
Text(
text = stringResource(R.string.setup_or_manual),
style = MaterialTheme.typography.labelLarge,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = apiUrl,
onValueChange = { apiUrl = it },
label = { Text(stringResource(R.string.setup_api_url)) },
singleLine = true,
placeholder = { Text("http://10.0.2.2:8000") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = storeCode,
onValueChange = { storeCode = it },
label = { Text(stringResource(R.string.setup_store_code)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = storeIdText,
onValueChange = { storeIdText = it.filter(Char::isDigit) },
label = { Text("Store ID") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = authToken,
onValueChange = { authToken = it },
label = { Text(stringResource(R.string.setup_auth_token)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
Button(
enabled = !isConnecting,
onClick = {
onConnect(
apiUrl.trim(),
storeIdText.toIntOrNull() ?: 0,
storeCode.trim(),
authToken.trim(),
)
},
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(vertical = 12.dp),
) {
Text(stringResource(R.string.setup_connect))
}
}
}

View File

@@ -1,24 +1,131 @@
package lu.rewardflow.terminal.ui.setup
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.Moshi
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import lu.rewardflow.terminal.data.api.LoyaltyApi
import lu.rewardflow.terminal.data.model.ProgramResponse
import lu.rewardflow.terminal.data.model.SetupPayload
import lu.rewardflow.terminal.data.repository.CategoryRepository
import lu.rewardflow.terminal.data.repository.DeviceConfigRepository
import lu.rewardflow.terminal.data.repository.StaffPinRepository
import javax.inject.Inject
@HiltViewModel
class SetupViewModel @Inject constructor(
private val dataStore: DataStore<Preferences>,
private val configRepository: DeviceConfigRepository,
private val api: LoyaltyApi,
private val staffPinRepository: StaffPinRepository,
private val categoryRepository: CategoryRepository,
moshi: Moshi,
) : ViewModel() {
companion object {
val IS_SET_UP = booleanPreferencesKey("is_device_set_up")
/** Observed by [RewardFlowNavHost] to pick the start destination. */
val isDeviceSetUp: Flow<Boolean> = configRepository.isDeviceSetUp
private val _state = MutableStateFlow<SetupState>(SetupState.Idle)
val state: StateFlow<SetupState> = _state.asStateFlow()
private val payloadAdapter: JsonAdapter<SetupPayload> =
moshi.adapter(SetupPayload::class.java)
private val programAdapter: JsonAdapter<ProgramResponse> =
moshi.adapter(ProgramResponse::class.java)
/** Decode the JSON encoded in the pairing QR and run the pair flow. */
fun pairFromQr(qrRawValue: String) {
val payload = try {
payloadAdapter.fromJson(qrRawValue)
} catch (_: JsonDataException) {
null
} catch (_: java.io.IOException) {
null
}
if (payload == null) {
_state.value = SetupState.Error(SetupError.InvalidQr)
return
}
pairWith(payload)
}
val isDeviceSetUp: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[IS_SET_UP] ?: false
/** Manual entry path — same flow as [pairFromQr] but with a hand-typed payload.
*
* Used during dev/testing when there is no QR generator handy. Production
* users always pair via the QR. ``storeId`` is required because the device
* needs the numeric id alongside the code (some endpoints use one, some
* use the other).
*/
fun pairManually(apiUrl: String, storeId: Int, storeCode: String, authToken: String) {
if (apiUrl.isBlank() || storeCode.isBlank() || authToken.isBlank() || storeId <= 0) {
_state.value = SetupState.Error(SetupError.InvalidQr)
return
}
pairWith(
SetupPayload(
api_url = apiUrl,
store_id = storeId,
store_code = storeCode,
store_name = null,
auth_token = authToken,
)
)
}
fun resetError() {
if (_state.value is SetupState.Error) _state.value = SetupState.Idle
}
private fun pairWith(payload: SetupPayload) {
_state.value = SetupState.Connecting
viewModelScope.launch {
// 1. Persist the pairing FIRST so the AuthInterceptor will use the new
// api_url and bearer token on the verification call below.
configRepository.savePairing(
apiUrl = payload.api_url,
authToken = payload.auth_token,
storeId = payload.store_id,
storeCode = payload.store_code,
storeName = payload.store_name,
)
// 2. Verify by hitting /program. Failure → roll back the pairing
// so the user is back at a clean Setup screen with an error.
val program: ProgramResponse = try {
api.getProgram()
} catch (e: Exception) {
configRepository.resetDevice()
_state.value = SetupState.Error(SetupError.ConnectionFailed(e.message))
return@launch
}
configRepository.saveProgram(programAdapter.toJson(program))
// 4. Warm caches. Failures here are non-fatal — the screens will
// refresh on their own when needed.
runCatching { staffPinRepository.refresh() }
runCatching { categoryRepository.refresh() }
_state.value = SetupState.Success
}
}
}
sealed interface SetupState {
data object Idle : SetupState
data object Connecting : SetupState
data object Success : SetupState
data class Error(val reason: SetupError) : SetupState
}
sealed interface SetupError {
data object InvalidQr : SetupError
data class ConnectionFailed(val detail: String?) : SetupError
}