diff --git a/app/modules/loyalty/services/terminal_device_service.py b/app/modules/loyalty/services/terminal_device_service.py index e18c397e..4e5799b7 100644 --- a/app/modules/loyalty/services/terminal_device_service.py +++ b/app/modules/loyalty/services/terminal_device_service.py @@ -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)) diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt index 1a67b9da..5342c795 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt @@ -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) diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/repository/DeviceConfigRepository.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/repository/DeviceConfigRepository.kt index 7570e7ce..cbfd149c 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/repository/DeviceConfigRepository.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/repository/DeviceConfigRepository.kt @@ -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") diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/scanner/QrScannerView.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/scanner/QrScannerView.kt new file mode 100644 index 00000000..a3aa3857 --- /dev/null +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/scanner/QrScannerView.kt @@ -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() + } + } +} diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/setup/SetupScreen.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/setup/SetupScreen.kt index 17d1935b..56622468 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/setup/SetupScreen.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/setup/SetupScreen.kt @@ -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)) + } } } diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/setup/SetupViewModel.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/setup/SetupViewModel.kt index abd8ff58..082a1f13 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/setup/SetupViewModel.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/setup/SetupViewModel.kt @@ -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, + 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 = configRepository.isDeviceSetUp + + private val _state = MutableStateFlow(SetupState.Idle) + val state: StateFlow = _state.asStateFlow() + + private val payloadAdapter: JsonAdapter = + moshi.adapter(SetupPayload::class.java) + + private val programAdapter: JsonAdapter = + 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 = 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 +}