feat(android-terminal): Phase D.2 — action sheets (stamp/earn/redeem)

The four right-pane buttons now work end-to-end (online happy path).
Each opens an AlertDialog wired to a ViewModel action; on success the
sheet closes and the customer card refreshes from the server.

- TerminalViewModel: + categories cache load (via CategoryRepository),
  + activeAction / actionInProgress / actionResult on the state. Action
  methods (submitStamp, submitEarnPoints, submitRedeemStamps,
  submitRedeemReward) all funnel through a runAction helper that toggles
  in-progress, calls the LoyaltyApi, refreshes the customer on success,
  surfaces failures inline.
- ActionSheets.kt (new): one ActionSheet entrypoint that dispatches by
  ActionKind. Category multi-select via FlowRow + FilterChip pills.
  EarnPoints accepts "12.50" / "12,50" → cents. Reward picker shows
  available_rewards from the lookup response.
- TerminalScreen: action buttons enabled per state — stamp/earn stay
  active offline (Phase E will queue them), redeem is hard-disabled when
  offline per the plan ("redemption requires an internet connection").

Categories list comes from the cache filled at pairing; fresh refresh
runs in the background so newly-added categories show up next launch
without a manual sync.

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:13:31 +02:00
parent 47565419e2
commit 02652ee8c6
3 changed files with 567 additions and 32 deletions

View File

@@ -0,0 +1,402 @@
package lu.rewardflow.terminal.ui.terminal
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
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
import lu.rewardflow.terminal.data.model.CardLookupResponse
import lu.rewardflow.terminal.data.model.CategoryItem
import lu.rewardflow.terminal.data.model.RewardItem
/**
* Modal action sheets fired from the Terminal screen's right-pane buttons.
*
* Each is an [AlertDialog] keyed on [TerminalUiState.activeAction] —
* [TerminalScreen] delegates to this single composable which dispatches
* to the right body. The dialog dismisses on submit (the ViewModel closes
* `activeAction` on success) or on Cancel.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ActionSheet(
state: TerminalUiState,
onDismiss: () -> Unit,
onSubmitStamp: (List<Int>) -> Unit,
onSubmitEarn: (amountCents: Int, categoryIds: List<Int>) -> Unit,
onSubmitRedeemStamps: () -> Unit,
onSubmitRedeemReward: (rewardId: String) -> Unit,
) {
val customer = state.customer ?: return
when (state.activeAction) {
ActionKind.AddStamp -> AddStampDialog(
categories = state.categories,
inProgress = state.actionInProgress,
failure = (state.actionResult as? ActionResult.Failure)?.message,
onConfirm = onSubmitStamp,
onDismiss = onDismiss,
)
ActionKind.EarnPoints -> EarnPointsDialog(
categories = state.categories,
inProgress = state.actionInProgress,
failure = (state.actionResult as? ActionResult.Failure)?.message,
onConfirm = onSubmitEarn,
onDismiss = onDismiss,
)
ActionKind.RedeemStamps -> RedeemStampsDialog(
customer = customer,
inProgress = state.actionInProgress,
failure = (state.actionResult as? ActionResult.Failure)?.message,
onConfirm = onSubmitRedeemStamps,
onDismiss = onDismiss,
)
ActionKind.RedeemReward -> RedeemRewardDialog(
rewards = customer.available_rewards,
inProgress = state.actionInProgress,
failure = (state.actionResult as? ActionResult.Failure)?.message,
onConfirm = onSubmitRedeemReward,
onDismiss = onDismiss,
)
null -> Unit
}
}
// ── Add Stamp ─────────────────────────────────────────────────────────
@Composable
private fun AddStampDialog(
categories: List<CategoryItem>,
inProgress: Boolean,
failure: String?,
onConfirm: (List<Int>) -> Unit,
onDismiss: () -> Unit,
) {
val selected = remember { mutableStateOf(emptySet<Int>()) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.action_add_stamp)) },
text = {
Column {
CategoryPills(
categories = categories,
selected = selected.value,
onToggle = { id -> selected.value = selected.value.toggle(id) },
)
FailureLine(failure)
}
},
confirmButton = {
ConfirmButton(
inProgress = inProgress,
onClick = { onConfirm(selected.value.toList()) },
)
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !inProgress) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
// ── Earn Points ───────────────────────────────────────────────────────
@Composable
private fun EarnPointsDialog(
categories: List<CategoryItem>,
inProgress: Boolean,
failure: String?,
onConfirm: (amountCents: Int, categoryIds: List<Int>) -> Unit,
onDismiss: () -> Unit,
) {
var amountText by remember { mutableStateOf("") }
val selected = remember { mutableStateOf(emptySet<Int>()) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.action_earn_points)) },
text = {
Column {
OutlinedTextField(
value = amountText,
onValueChange = { input ->
// Accept digits + optional single comma/period for cents.
amountText = input.filter { it.isDigit() || it == '.' || it == ',' }
},
label = { Text(stringResource(R.string.action_purchase_amount)) },
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
keyboardType = KeyboardType.Decimal,
),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
CategoryPills(
categories = categories,
selected = selected.value,
onToggle = { id -> selected.value = selected.value.toggle(id) },
)
FailureLine(failure)
}
},
confirmButton = {
val cents = parseCents(amountText)
ConfirmButton(
inProgress = inProgress,
enabled = cents != null && cents > 0,
onClick = {
parseCents(amountText)?.let { v ->
onConfirm(v, selected.value.toList())
}
},
)
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !inProgress) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
// ── Redeem Stamps ─────────────────────────────────────────────────────
@Composable
private fun RedeemStampsDialog(
customer: CardLookupResponse,
inProgress: Boolean,
failure: String?,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.action_redeem_stamps)) },
text = {
Column {
Text(
text = customer.stamp_reward_description
?: stringResource(R.string.action_redeem_stamps),
style = MaterialTheme.typography.bodyLarge,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(
R.string.balance_stamps,
customer.stamp_count,
customer.stamps_target,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
FailureLine(failure)
}
},
confirmButton = {
ConfirmButton(inProgress = inProgress, onClick = onConfirm)
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !inProgress) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
// ── Redeem Reward ─────────────────────────────────────────────────────
@Composable
private fun RedeemRewardDialog(
rewards: List<RewardItem>,
inProgress: Boolean,
failure: String?,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var selectedId by remember { mutableStateOf(rewards.firstOrNull()?.id) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.action_redeem_reward)) },
text = {
Column {
if (rewards.isEmpty()) {
Text(
text = stringResource(R.string.error_unknown),
style = MaterialTheme.typography.bodyMedium,
)
} else {
LazyColumn {
items(rewards, key = { it.id }) { reward ->
RewardRow(
reward = reward,
selected = reward.id == selectedId,
onClick = { selectedId = reward.id },
)
HorizontalDivider()
}
}
}
FailureLine(failure)
}
},
confirmButton = {
ConfirmButton(
inProgress = inProgress,
enabled = selectedId != null,
onClick = { selectedId?.let(onConfirm) },
)
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !inProgress) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
@Composable
private fun RewardRow(
reward: RewardItem,
selected: Boolean,
onClick: () -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
onClick = onClick,
color = if (selected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surface,
) {
Row(
modifier = Modifier.wrapContentSize(),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
RadioButton(selected = selected, onClick = onClick)
Column(modifier = Modifier.padding(end = 8.dp)) {
Text(
text = reward.name,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = "${reward.points_required} pts",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
// ── Shared bits ───────────────────────────────────────────────────────
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CategoryPills(
categories: List<CategoryItem>,
selected: Set<Int>,
onToggle: (Int) -> Unit,
) {
if (categories.isEmpty()) {
return
}
Text(
text = stringResource(R.string.action_select_category),
style = MaterialTheme.typography.labelLarge,
)
Spacer(Modifier.height(6.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
categories.forEach { c ->
FilterChip(
selected = c.id in selected,
onClick = { onToggle(c.id) },
label = { Text(c.name) },
)
}
}
}
@Composable
private fun FailureLine(failure: String?) {
if (failure == null) return
Spacer(Modifier.height(8.dp))
Text(
text = failure,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
@Composable
private fun ConfirmButton(
inProgress: Boolean,
enabled: Boolean = true,
onClick: () -> Unit,
) {
TextButton(
onClick = onClick,
enabled = enabled && !inProgress,
) {
if (inProgress) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.size(8.dp))
}
Text(stringResource(R.string.action_confirm))
}
}
// ── Helpers ───────────────────────────────────────────────────────────
private fun Set<Int>.toggle(id: Int): Set<Int> =
if (contains(id)) this - id else this + id
/** Parse "12.50" / "12,50" / "1250" → cents. Returns null on garbage. */
private fun parseCents(text: String): Int? {
val normalized = text.replace(',', '.').trim()
if (normalized.isEmpty()) return null
val euros = normalized.toDoubleOrNull() ?: return null
if (euros <= 0) return null
return (euros * 100).toInt()
}

View File

@@ -88,9 +88,21 @@ fun TerminalScreen(
stampsTarget = state.program?.stamps_target ?: 0,
isOnline = state.isOnline,
onClearCustomer = viewModel::clearCustomer,
onAction = viewModel::openAction,
)
}
}
if (state.activeAction != null) {
ActionSheet(
state = state,
onDismiss = viewModel::dismissAction,
onSubmitStamp = viewModel::submitStamp,
onSubmitEarn = viewModel::submitEarnPoints,
onSubmitRedeemStamps = viewModel::submitRedeemStamps,
onSubmitRedeemReward = viewModel::submitRedeemReward,
)
}
}
@Composable
@@ -236,6 +248,7 @@ private fun RightPane(
stampsTarget: Int,
isOnline: Boolean,
onClearCustomer: () -> Unit,
onAction: (ActionKind) -> Unit,
) {
Surface(
modifier = modifier,
@@ -251,6 +264,7 @@ private fun RightPane(
stampsTarget = stampsTarget,
isOnline = isOnline,
onClearCustomer = onClearCustomer,
onAction = onAction,
)
}
}
@@ -285,9 +299,9 @@ private fun CustomerPanel(
stampsTarget: Int,
isOnline: Boolean,
onClearCustomer: () -> Unit,
onAction: (ActionKind) -> Unit,
) {
Column(modifier = modifier) {
// Customer header
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
@@ -321,10 +335,10 @@ private fun CustomerPanel(
Spacer(Modifier.height(16.dp))
// Action buttons — wired in D.2
ActionButtonsPlaceholder(
ActionButtons(
customer = customer,
isOnline = isOnline,
canRedeemStamps = customer.can_redeem_stamps,
onAction = onAction,
)
}
}
@@ -376,33 +390,37 @@ private fun BalanceCard(
}
@Composable
private fun ActionButtonsPlaceholder(
private fun ActionButtons(
customer: CardLookupResponse,
isOnline: Boolean,
canRedeemStamps: Boolean,
onAction: (ActionKind) -> Unit,
) {
// D.2 will replace this with real action sheets.
// Earn-side actions (stamp + points) are queueable in Phase E, so
// they remain enabled offline. Redemption requires an authoritative
// server check, so we hard-disable when offline per the plan.
val canRedeemReward = customer.available_rewards.isNotEmpty()
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = { },
enabled = false,
Button(
onClick = { onAction(ActionKind.AddStamp) },
enabled = customer.can_stamp,
modifier = Modifier.weight(1f),
) { Text(stringResource(R.string.action_add_stamp)) }
OutlinedButton(
onClick = { },
enabled = false,
Button(
onClick = { onAction(ActionKind.EarnPoints) },
modifier = Modifier.weight(1f),
) { Text(stringResource(R.string.action_earn_points)) }
}
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = { },
enabled = false,
onClick = { onAction(ActionKind.RedeemStamps) },
enabled = isOnline && customer.can_redeem_stamps,
modifier = Modifier.weight(1f),
) { Text(stringResource(R.string.action_redeem_stamps)) }
OutlinedButton(
onClick = { },
enabled = false,
onClick = { onAction(ActionKind.RedeemReward) },
enabled = isOnline && canRedeemReward,
modifier = Modifier.weight(1f),
) { Text(stringResource(R.string.action_redeem_reward)) }
}

View File

@@ -14,27 +14,36 @@ import kotlinx.coroutines.flow.onEach
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.PointsEarnRequest
import lu.rewardflow.terminal.data.model.PointsRedeemRequest
import lu.rewardflow.terminal.data.model.ProgramResponse
import lu.rewardflow.terminal.data.model.StampRedeemRequest
import lu.rewardflow.terminal.data.model.StampRequest
import lu.rewardflow.terminal.data.network.NetworkMonitor
import lu.rewardflow.terminal.data.repository.CategoryRepository
import lu.rewardflow.terminal.data.repository.DeviceConfigRepository
import javax.inject.Inject
/**
* Powers the main POS terminal screen.
*
* Tracks: which customer is currently selected (or none), the cached
* program config (for stamps_target / card colors / etc), online state,
* and a transient error message. Card-lookup hits the API directly —
* no offline fallback because we need the latest balance to show
* something accurate; queueing a "lookup" doesn't make sense.
* Tracks: which customer is currently selected, the cached program +
* category list, online state, and any open action sheet. Card-lookup
* hits the API directly — no offline fallback because we need the
* latest balance to render something accurate; queueing a "lookup"
* doesn't make sense.
*
* Phase D.1 surface: search → lookup → render. Action sheets / actions
* land in D.2.
* Phase D.1: search + display.
* Phase D.2: action sheets — stamp / earn points / redeem stamps /
* redeem reward, all online-only for now (Phase E adds the
* Room queue).
*/
@HiltViewModel
class TerminalViewModel @Inject constructor(
private val api: LoyaltyApi,
private val configRepository: DeviceConfigRepository,
private val categoryRepository: CategoryRepository,
networkMonitor: NetworkMonitor,
moshi: Moshi,
) : ViewModel() {
@@ -47,6 +56,7 @@ class TerminalViewModel @Inject constructor(
init {
loadCachedProgram()
loadCategories()
networkMonitor.isOnline
.onEach { online -> _state.value = _state.value.copy(isOnline = online) }
.launchIn(viewModelScope)
@@ -56,7 +66,6 @@ class TerminalViewModel @Inject constructor(
_state.value = _state.value.copy(searchQuery = query, errorMessage = null)
}
/** Card lookup — accepts card number, email, or partial name. */
fun onSearchSubmit() {
val q = _state.value.searchQuery.trim()
if (q.isBlank()) return
@@ -85,7 +94,6 @@ class TerminalViewModel @Inject constructor(
}
}
/** Card-number-based lookup, used by the QR scanner overlay (D.4). */
fun lookupByCardNumber(cardNumber: String) {
if (cardNumber.isBlank()) return
onSearchChanged(cardNumber)
@@ -104,19 +112,105 @@ class TerminalViewModel @Inject constructor(
_state.value = _state.value.copy(errorMessage = null)
}
/** Refresh the currently-displayed customer card from the server.
* Called by the action sheets (D.2) after a successful operation
* so balances reflect the new state. */
// ── Action sheets ────────────────────────────────────────────────
fun openAction(kind: ActionKind) {
if (_state.value.customer == null) return
_state.value = _state.value.copy(
activeAction = kind,
actionResult = null,
errorMessage = null,
)
}
fun dismissAction() {
_state.value = _state.value.copy(
activeAction = null,
actionInProgress = false,
actionResult = null,
)
}
/** Add a stamp to the currently-selected card. */
fun submitStamp(categoryIds: List<Int>) = runAction {
val card = _state.value.customer ?: return@runAction
val response = api.addStamp(
StampRequest(
card_id = card.id,
category_ids = categoryIds.ifEmpty { null },
)
)
if (!response.success) error("Server reported failure")
}
/** Earn points on a purchase. [amountCents] must be > 0. */
fun submitEarnPoints(amountCents: Int, categoryIds: List<Int>) = runAction {
if (amountCents <= 0) error("Amount must be greater than 0")
val card = _state.value.customer ?: return@runAction
val response = api.earnPoints(
PointsEarnRequest(
card_id = card.id,
purchase_amount_cents = amountCents,
category_ids = categoryIds.ifEmpty { null },
)
)
if (!response.success) error("Server reported failure")
}
/** Redeem the stamp reward on the currently-selected card. */
fun submitRedeemStamps() = runAction {
val card = _state.value.customer ?: return@runAction
val response = api.redeemStamps(
StampRedeemRequest(card_id = card.id)
)
if (!response.success) error("Server reported failure")
}
/** Redeem a points reward by id. */
fun submitRedeemReward(rewardId: String) = runAction {
val card = _state.value.customer ?: return@runAction
val response = api.redeemPoints(
PointsRedeemRequest(card_id = card.id, reward_id = rewardId)
)
if (!response.success) error("Server reported failure")
}
private fun runAction(block: suspend () -> Unit) {
if (_state.value.actionInProgress) return
_state.value = _state.value.copy(
actionInProgress = true,
actionResult = null,
)
viewModelScope.launch {
val result = runCatching { block() }
if (result.isSuccess) {
_state.value = _state.value.copy(
actionInProgress = false,
actionResult = ActionResult.Success,
activeAction = null,
)
refreshCurrentCustomer()
} else {
_state.value = _state.value.copy(
actionInProgress = false,
actionResult = ActionResult.Failure(
result.exceptionOrNull()?.message ?: "Operation failed"
),
)
}
}
}
fun refreshCurrentCustomer() {
val current = _state.value.customer ?: return
viewModelScope.launch {
runCatching { api.lookupCard(current.card_number) }
.onSuccess { card ->
_state.value = _state.value.copy(customer = card)
}
.onSuccess { card -> _state.value = _state.value.copy(customer = card) }
}
}
// ── Internal ─────────────────────────────────────────────────────
private fun loadCachedProgram() {
viewModelScope.launch {
val json = configRepository.programJson.first()
@@ -128,13 +222,34 @@ class TerminalViewModel @Inject constructor(
}
}
}
private fun loadCategories() {
viewModelScope.launch {
// Cached list first so the action sheets render instantly;
// refresh in the background to pick up new categories.
val cached = runCatching { categoryRepository.listOrRefresh() }
.getOrDefault(emptyList())
_state.value = _state.value.copy(categories = cached)
}
}
}
data class TerminalUiState(
val program: ProgramResponse? = null,
val categories: List<CategoryItem> = emptyList(),
val searchQuery: String = "",
val isSearching: Boolean = false,
val customer: CardLookupResponse? = null,
val errorMessage: String? = null,
val isOnline: Boolean = true,
val activeAction: ActionKind? = null,
val actionInProgress: Boolean = false,
val actionResult: ActionResult? = null,
)
enum class ActionKind { AddStamp, EarnPoints, RedeemStamps, RedeemReward }
sealed interface ActionResult {
data object Success : ActionResult
data class Failure(val message: String) : ActionResult
}