diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/ActionSheets.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/ActionSheets.kt new file mode 100644 index 00000000..67f0bbcf --- /dev/null +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/ActionSheets.kt @@ -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) -> Unit, + onSubmitEarn: (amountCents: Int, categoryIds: List) -> 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, + inProgress: Boolean, + failure: String?, + onConfirm: (List) -> Unit, + onDismiss: () -> Unit, +) { + val selected = remember { mutableStateOf(emptySet()) } + + 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, + inProgress: Boolean, + failure: String?, + onConfirm: (amountCents: Int, categoryIds: List) -> Unit, + onDismiss: () -> Unit, +) { + var amountText by remember { mutableStateOf("") } + val selected = remember { mutableStateOf(emptySet()) } + + 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, + 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, + selected: Set, + 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.toggle(id: Int): Set = + 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() +} diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt index 15250781..524fe15a 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt @@ -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)) } } diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalViewModel.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalViewModel.kt index fc01de02..ae18d17a 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalViewModel.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalViewModel.kt @@ -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) = 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) = 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 = 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 +}