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