feat(android-terminal): Phase D.4 — enrollment dialog + QR scanner overlay
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<TransactionItem>,
|
||||
) {
|
||||
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),
|
||||
) {
|
||||
|
||||
@@ -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<TransactionItem> = emptyList(),
|
||||
val scannerOpen: Boolean = false,
|
||||
val enrollDialogOpen: Boolean = false,
|
||||
val enrolling: Boolean = false,
|
||||
val enrollError: String? = null,
|
||||
)
|
||||
|
||||
enum class ActionKind { AddStamp, EarnPoints, RedeemStamps, RedeemReward }
|
||||
|
||||
Reference in New Issue
Block a user