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:
2026-05-06 21:28:44 +02:00
parent d345d65fd4
commit 01a12dcef4
4 changed files with 292 additions and 5 deletions

View File

@@ -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))
}
},
)
}

View File

@@ -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))
}
}
}

View File

@@ -84,6 +84,8 @@ fun TerminalScreen(
state = state, state = state,
onSearchChanged = viewModel::onSearchChanged, onSearchChanged = viewModel::onSearchChanged,
onSearchSubmit = viewModel::onSearchSubmit, onSearchSubmit = viewModel::onSearchSubmit,
onScanQrClicked = viewModel::openScanner,
onEnrollClicked = viewModel::openEnrollDialog,
recentTransactions = state.recentTransactions, recentTransactions = state.recentTransactions,
) )
RightPane( RightPane(
@@ -110,6 +112,22 @@ fun TerminalScreen(
onSubmitRedeemReward = viewModel::submitRedeemReward, 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 @Composable
@@ -167,6 +185,8 @@ private fun LeftPane(
state: TerminalUiState, state: TerminalUiState,
onSearchChanged: (String) -> Unit, onSearchChanged: (String) -> Unit,
onSearchSubmit: () -> Unit, onSearchSubmit: () -> Unit,
onScanQrClicked: () -> Unit,
onEnrollClicked: () -> Unit,
recentTransactions: List<TransactionItem>, recentTransactions: List<TransactionItem>,
) { ) {
Surface( Surface(
@@ -212,10 +232,8 @@ private fun LeftPane(
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
// Phase D.4 will replace these with real handlers.
OutlinedButton( OutlinedButton(
onClick = { /* QR scan — D.4 */ }, onClick = onScanQrClicked,
enabled = false,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(vertical = 12.dp), contentPadding = PaddingValues(vertical = 12.dp),
) { ) {
@@ -223,8 +241,7 @@ private fun LeftPane(
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
OutlinedButton( OutlinedButton(
onClick = { /* Enroll — D.4 */ }, onClick = onEnrollClicked,
enabled = false,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(vertical = 12.dp), contentPadding = PaddingValues(vertical = 12.dp),
) { ) {

View File

@@ -15,6 +15,7 @@ import kotlinx.coroutines.launch
import lu.rewardflow.terminal.data.api.LoyaltyApi import lu.rewardflow.terminal.data.api.LoyaltyApi
import lu.rewardflow.terminal.data.model.CardLookupResponse import lu.rewardflow.terminal.data.model.CardLookupResponse
import lu.rewardflow.terminal.data.model.CategoryItem 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.PointsEarnRequest
import lu.rewardflow.terminal.data.model.PointsRedeemRequest import lu.rewardflow.terminal.data.model.PointsRedeemRequest
import lu.rewardflow.terminal.data.model.ProgramResponse import lu.rewardflow.terminal.data.model.ProgramResponse
@@ -102,6 +103,102 @@ class TerminalViewModel @Inject constructor(
onSearchSubmit() 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() { fun clearCustomer() {
_state.value = _state.value.copy( _state.value = _state.value.copy(
customer = null, customer = null,
@@ -263,6 +360,10 @@ data class TerminalUiState(
val actionInProgress: Boolean = false, val actionInProgress: Boolean = false,
val actionResult: ActionResult? = null, val actionResult: ActionResult? = null,
val recentTransactions: List<TransactionItem> = emptyList(), 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 } enum class ActionKind { AddStamp, EarnPoints, RedeemStamps, RedeemReward }