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 = {
|
||||
"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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user