feat(android-terminal): Phase B — setup screen with QR scanner
Some checks failed
Some checks failed
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:
@@ -144,7 +144,9 @@ class TerminalDeviceService:
|
|||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"api_url": settings.app_base_url,
|
"api_url": settings.app_base_url,
|
||||||
|
"store_id": store.id,
|
||||||
"store_code": store.store_code,
|
"store_code": store.store_code,
|
||||||
|
"store_name": store.name,
|
||||||
"auth_token": token,
|
"auth_token": token,
|
||||||
}
|
}
|
||||||
qr_png_base64 = self._render_qr_png_base64(json.dumps(payload))
|
qr_png_base64 = self._render_qr_png_base64(json.dumps(payload))
|
||||||
|
|||||||
@@ -190,6 +190,24 @@ data class PinItem(
|
|||||||
val is_locked: Boolean,
|
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 ──────────────────────────────────────────────────────────
|
// ── Categories ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
|
|||||||
@@ -104,7 +104,9 @@ class DeviceConfigRepository @Inject constructor(
|
|||||||
private val KEY_STORE_ID = intPreferencesKey("store_id")
|
private val KEY_STORE_ID = intPreferencesKey("store_id")
|
||||||
private val KEY_STORE_CODE = stringPreferencesKey("store_code")
|
private val KEY_STORE_CODE = stringPreferencesKey("store_code")
|
||||||
private val KEY_STORE_NAME = stringPreferencesKey("store_name")
|
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_PROGRAM_JSON = stringPreferencesKey("program_json")
|
||||||
private val KEY_STAFF_PINS_JSON = stringPreferencesKey("staff_pins_json")
|
private val KEY_STAFF_PINS_JSON = stringPreferencesKey("staff_pins_json")
|
||||||
private val KEY_CATEGORIES_JSON = stringPreferencesKey("categories_json")
|
private val KEY_CATEGORIES_JSON = stringPreferencesKey("categories_json")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +1,237 @@
|
|||||||
package lu.rewardflow.terminal.ui.setup
|
package lu.rewardflow.terminal.ui.setup
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.material3.*
|
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.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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
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:
|
* Left half: live QR scanner. Decoded value is fed to [SetupViewModel.pairFromQr]
|
||||||
* 1. Show "Scan QR Code" prompt
|
* which verifies with the server and persists the configuration.
|
||||||
* 2. Merchant owner scans QR from web settings page
|
*
|
||||||
* 3. QR contains: API URL + store auth token + store_id + store_code
|
* Right half: manual entry form (dev fallback). Same downstream flow.
|
||||||
* 4. App downloads store config and enters kiosk mode
|
*
|
||||||
|
* 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
|
@Composable
|
||||||
fun SetupScreen(
|
fun SetupScreen(
|
||||||
onSetupComplete: () -> Unit,
|
onSetupComplete: () -> Unit,
|
||||||
|
viewModel: SetupViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
Box(
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center,
|
LaunchedEffect(state) {
|
||||||
) {
|
if (state is SetupState.Success) onSetupComplete()
|
||||||
Column(
|
}
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier = Modifier.padding(48.dp),
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// ── Left half: QR scanner
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(Color.Black),
|
||||||
) {
|
) {
|
||||||
Text(
|
QrScannerView(
|
||||||
text = "RewardFlow Terminal",
|
onQrScanned = { raw -> viewModel.pairFromQr(raw) },
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
Text(
|
// Status overlay on top of the camera preview
|
||||||
text = "Scan the setup QR code from your store settings page to configure this device.",
|
StatusOverlay(state = state, onDismissError = viewModel::resetError)
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
}
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
// ── Right half: manual entry + instruction
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
ManualEntryPanel(
|
||||||
// TODO: CameraX QR scanner for setup code
|
modifier = Modifier
|
||||||
Button(onClick = onSetupComplete) {
|
.weight(1f)
|
||||||
Text("Setup Complete (placeholder)")
|
.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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,131 @@
|
|||||||
package lu.rewardflow.terminal.ui.setup
|
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.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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.Flow
|
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
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SetupViewModel @Inject constructor(
|
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() {
|
) : ViewModel() {
|
||||||
|
|
||||||
companion object {
|
/** Observed by [RewardFlowNavHost] to pick the start destination. */
|
||||||
val IS_SET_UP = booleanPreferencesKey("is_device_set_up")
|
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 ->
|
/** Manual entry path — same flow as [pairFromQr] but with a hand-typed payload.
|
||||||
prefs[IS_SET_UP] ?: false
|
*
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user