From 01a12dcef4068fd532d42231787a4e68d5234212 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Wed, 6 May 2026 21:28:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(android-terminal):=20Phase=20D.4=20?= =?UTF-8?q?=E2=80=94=20enrollment=20dialog=20+=20QR=20scanner=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both left-pane buttons now functional. Cashier can find a customer by scanning their loyalty QR or enroll a brand-new customer from the tablet — closing the last gap in the everyday POS flow. - TerminalViewModel: scannerOpen / enrollDialogOpen / enrolling / enrollError state. submitEnroll posts to /cards/enroll, then re-fetches the lookup shape so the customer pane renders fully (rewards, cooldown). On lookup-after-enroll failure (rare) the new card_number is pre-filled in the search field as a fallback. - EnrollDialog.kt (new): AlertDialog with name + email (required), phone + birthday (optional; birthday is plain YYYY-MM-DD text — date picker is a polish task). Inline error surface for backend rejections. - QrScannerOverlay.kt (new): fullscreen overlay reusing QrScannerView from Phase B. Cancel button top-right. Decoded value is treated as a card_number and feeds the lookup flow. - TerminalScreen: scan/enroll buttons are no longer disabled; the two new composables render conditionally on top of the main layout. The tablet now supports every everyday flow: lookup, scan, enroll, stamp, earn points, redeem stamps, redeem reward, recent feed. Verified by ./gradlew assembleDebug — clean build. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../terminal/ui/terminal/EnrollDialog.kt | 123 ++++++++++++++++++ .../terminal/ui/terminal/QrScannerOverlay.kt | 46 +++++++ .../terminal/ui/terminal/TerminalScreen.kt | 27 +++- .../terminal/ui/terminal/TerminalViewModel.kt | 101 ++++++++++++++ 4 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/EnrollDialog.kt create mode 100644 clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/QrScannerOverlay.kt diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/EnrollDialog.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/EnrollDialog.kt new file mode 100644 index 00000000..0bd6446e --- /dev/null +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/EnrollDialog.kt @@ -0,0 +1,123 @@ +package lu.rewardflow.terminal.ui.terminal + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import lu.rewardflow.terminal.R + +/** + * Bottom-sheet-style enrollment dialog fired from the left-pane + * "Enroll Customer" button. + * + * Required: name + email. Phone + birthday are optional. Birthday + * format mirrors what the backend expects (``YYYY-MM-DD`` string) — + * the field is plain text for now; a date picker is a polish task. + */ +@Composable +fun EnrollDialog( + inProgress: Boolean, + error: String?, + onSubmit: (name: String, email: String, phone: String?, birthday: String?) -> Unit, + onDismiss: () -> Unit, +) { + var name by remember { mutableStateOf("") } + var email by remember { mutableStateOf("") } + var phone by remember { mutableStateOf("") } + var birthday by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.enroll_title)) }, + text = { + Column { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(R.string.enroll_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text(stringResource(R.string.enroll_email)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = phone, + onValueChange = { phone = it }, + label = { Text(stringResource(R.string.enroll_phone)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = birthday, + onValueChange = { input -> birthday = input.filter { it.isDigit() || it == '-' } }, + label = { Text(stringResource(R.string.enroll_birthday)) }, + placeholder = { Text("YYYY-MM-DD") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + if (error != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + onSubmit( + name, + email, + phone.takeIf { it.isNotBlank() }, + birthday.takeIf { it.isNotBlank() }, + ) + }, + enabled = !inProgress && name.isNotBlank() && email.isNotBlank(), + ) { + if (inProgress) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.size(8.dp)) + } + Text(stringResource(R.string.enroll_submit)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !inProgress) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/QrScannerOverlay.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/QrScannerOverlay.kt new file mode 100644 index 00000000..e96a27ef --- /dev/null +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/QrScannerOverlay.kt @@ -0,0 +1,46 @@ +package lu.rewardflow.terminal.ui.terminal + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import lu.rewardflow.terminal.R +import lu.rewardflow.terminal.ui.scanner.QrScannerView + +/** + * Fullscreen QR scanner overlay used to look up a customer card by + * scanning the QR printed on it / on their phone. + * + * Reuses [QrScannerView] from Phase B — same camera permission UX, + * same one-shot fire semantics. The Cancel button bails without a scan. + */ +@Composable +fun QrScannerOverlay( + onScanned: (String) -> Unit, + onCancel: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + ) { + QrScannerView(onQrScanned = onScanned) + + FilledTonalButton( + onClick = onCancel, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(16.dp), + ) { + Text(stringResource(R.string.action_cancel)) + } + } +} diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt index 32f56834..b16c770c 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt @@ -84,6 +84,8 @@ fun TerminalScreen( state = state, onSearchChanged = viewModel::onSearchChanged, onSearchSubmit = viewModel::onSearchSubmit, + onScanQrClicked = viewModel::openScanner, + onEnrollClicked = viewModel::openEnrollDialog, recentTransactions = state.recentTransactions, ) RightPane( @@ -110,6 +112,22 @@ fun TerminalScreen( onSubmitRedeemReward = viewModel::submitRedeemReward, ) } + + if (state.enrollDialogOpen) { + EnrollDialog( + inProgress = state.enrolling, + error = state.enrollError, + onSubmit = viewModel::submitEnroll, + onDismiss = viewModel::dismissEnrollDialog, + ) + } + + if (state.scannerOpen) { + QrScannerOverlay( + onScanned = viewModel::onCardQrScanned, + onCancel = viewModel::dismissScanner, + ) + } } @Composable @@ -167,6 +185,8 @@ private fun LeftPane( state: TerminalUiState, onSearchChanged: (String) -> Unit, onSearchSubmit: () -> Unit, + onScanQrClicked: () -> Unit, + onEnrollClicked: () -> Unit, recentTransactions: List, ) { Surface( @@ -212,10 +232,8 @@ private fun LeftPane( Spacer(Modifier.height(12.dp)) - // Phase D.4 will replace these with real handlers. OutlinedButton( - onClick = { /* QR scan — D.4 */ }, - enabled = false, + onClick = onScanQrClicked, modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(vertical = 12.dp), ) { @@ -223,8 +241,7 @@ private fun LeftPane( } Spacer(Modifier.height(8.dp)) OutlinedButton( - onClick = { /* Enroll — D.4 */ }, - enabled = false, + onClick = onEnrollClicked, modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(vertical = 12.dp), ) { diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalViewModel.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalViewModel.kt index 95530286..a67d3f03 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalViewModel.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.launch import lu.rewardflow.terminal.data.api.LoyaltyApi import lu.rewardflow.terminal.data.model.CardLookupResponse import lu.rewardflow.terminal.data.model.CategoryItem +import lu.rewardflow.terminal.data.model.EnrollRequest import lu.rewardflow.terminal.data.model.PointsEarnRequest import lu.rewardflow.terminal.data.model.PointsRedeemRequest import lu.rewardflow.terminal.data.model.ProgramResponse @@ -102,6 +103,102 @@ class TerminalViewModel @Inject constructor( onSearchSubmit() } + // ── Scanner overlay ────────────────────────────────────────────── + + fun openScanner() { + _state.value = _state.value.copy(scannerOpen = true, errorMessage = null) + } + + fun dismissScanner() { + _state.value = _state.value.copy(scannerOpen = false) + } + + /** Decoded raw value from the QR scanner overlay. Loyalty card QRs + * encode the card_number, so we hand it straight to the lookup. */ + fun onCardQrScanned(rawValue: String) { + _state.value = _state.value.copy(scannerOpen = false) + lookupByCardNumber(rawValue) + } + + // ── Enrollment dialog ──────────────────────────────────────────── + + fun openEnrollDialog() { + _state.value = _state.value.copy( + enrollDialogOpen = true, + enrolling = false, + enrollError = null, + ) + } + + fun dismissEnrollDialog() { + _state.value = _state.value.copy( + enrollDialogOpen = false, + enrolling = false, + enrollError = null, + ) + } + + /** Submit the enrollment form. ``birthday`` is expected as an ISO + * ``YYYY-MM-DD`` string or null. The new card is looked up after + * enrollment so the customer pane renders with the full lookup + * shape (rewards, can_stamp, cooldown, etc). */ + fun submitEnroll( + name: String, + email: String, + phone: String?, + birthday: String?, + ) { + if (_state.value.enrolling) return + if (name.isBlank() || email.isBlank()) { + _state.value = _state.value.copy(enrollError = "Name and email are required") + return + } + _state.value = _state.value.copy(enrolling = true, enrollError = null) + viewModelScope.launch { + val result = runCatching { + api.enrollCustomer( + EnrollRequest( + email = email.trim(), + customer_name = name.trim(), + customer_phone = phone?.trim()?.takeIf { it.isNotBlank() }, + customer_birthday = birthday?.trim()?.takeIf { it.isNotBlank() }, + ) + ) + } + result.fold( + onSuccess = { card -> + // Re-fetch with the lookup shape so the customer pane has + // the full set of fields (rewards, cooldown, etc). + runCatching { api.lookupCard(card.card_number) } + .onSuccess { hydrated -> + _state.value = _state.value.copy( + enrolling = false, + enrollDialogOpen = false, + customer = hydrated, + ) + refreshRecentTransactions() + } + .onFailure { + // Card was enrolled — but we couldn't fetch the + // hydrated view. Close the dialog anyway and let + // the user search by the new card_number. + _state.value = _state.value.copy( + enrolling = false, + enrollDialogOpen = false, + searchQuery = card.card_number, + ) + } + }, + onFailure = { err -> + _state.value = _state.value.copy( + enrolling = false, + enrollError = err.message ?: "Enrollment failed", + ) + }, + ) + } + } + fun clearCustomer() { _state.value = _state.value.copy( customer = null, @@ -263,6 +360,10 @@ data class TerminalUiState( val actionInProgress: Boolean = false, val actionResult: ActionResult? = null, val recentTransactions: List = emptyList(), + val scannerOpen: Boolean = false, + val enrollDialogOpen: Boolean = false, + val enrolling: Boolean = false, + val enrollError: String? = null, ) enum class ActionKind { AddStamp, EarnPoints, RedeemStamps, RedeemReward }