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