Compare commits

...

5 Commits

Author SHA1 Message Date
d3f1c33b37 feat(android-terminal): Phase D.5 — auto-lock idle timer
Some checks failed
CI / ruff (push) Successful in 19s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
Wraps the terminal screen in an IdleTracker that observes pointer-down
events and fires onLockScreen after 2 min of silence (per spec). Each
tap restarts the timer via a LaunchedEffect re-launch.

awaitFirstDown(requireUnconsumed = false) lets us observe touches
without intercepting them, so children (text fields, buttons, lists,
keypad) keep working normally. Body extracted into a private
TerminalContent so the wrapper stays tidy.

Caveat: Compose AlertDialogs render in a separate window, so the timer
keeps ticking through an open dialog. This is correct behavior — a
cashier who walks away mid-dialog should be locked out — and the
dialog disposes when the NavHost pops the terminal route on lock.
Documented in IdleTracker.kt.

Verified by ./gradlew assembleDebug — clean build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:31:16 +02:00
01a12dcef4 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>
2026-05-06 21:28:44 +02:00
d345d65fd4 feat(android-terminal): Phase D.3 — recent transactions list
Left pane below the search/scan/enroll buttons now shows the last ~10
transactions for the merchant, refreshed on init and after every
successful action sheet submission.

- ApiModels: TransactionListResponse + TransactionItem mirroring the
  store-API shape (deltas, customer/staff names, purchase amount,
  reward description, ISO transaction_at).
- LoyaltyApi.listRecentTransactions(skip, limit) → GET /transactions.
- TerminalViewModel.refreshRecentTransactions called on init and from
  runAction's success path so balances + feed stay in lockstep.
- TerminalScreen: RecentTransactionsList composable. Each row renders
  the type (translated by transactionLabel), customer name when known,
  signed delta (+50 pts / +1 ★ / -100 pts), and HH:mm timestamp
  localized to the device timezone via java.time (Android 26+).

Verified by ./gradlew assembleDebug — clean build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:25:10 +02:00
02652ee8c6 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>
2026-05-06 21:13:31 +02:00
47565419e2 feat(android-terminal): Phase D.1 — terminal lookup + customer card
The terminal screen is now functional for card lookup and customer
display. Phase D.2 will fill in the action sheets (stamp / earn /
redeem) on top of this.

- TerminalViewModel: state machine with program (from cache), customer,
  search/error/online state. onSearchSubmit hits /cards/lookup;
  refreshCurrentCustomer re-fetches after actions land.
- TerminalScreen rewrite: top bar with staff name + online pill + Lock;
  left pane with search field + buttons; right pane shows the empty
  state or a customer panel (name/email/card number, points + stamps
  card, four placeholder action buttons greyed out for D.2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:27:06 +02:00
8 changed files with 1578 additions and 61 deletions

View File

@@ -56,6 +56,14 @@ interface LoyaltyApi {
@GET("api/v1/store/loyalty/categories") @GET("api/v1/store/loyalty/categories")
suspend fun listCategories(): CategoryListResponse suspend fun listCategories(): CategoryListResponse
// Recent transactions for the merchant — used in the terminal screen's
// left-pane "Recent Transactions" panel.
@GET("api/v1/store/loyalty/transactions")
suspend fun listRecentTransactions(
@Query("skip") skip: Int = 0,
@Query("limit") limit: Int = 10,
): TransactionListResponse
// Auth // Auth
@POST("api/v1/store/auth/login") @POST("api/v1/store/auth/login")
suspend fun login(@Body request: LoginRequest): LoginResponse suspend fun login(@Body request: LoginRequest): LoginResponse

View File

@@ -226,6 +226,28 @@ data class SetupPayload(
val auth_token: String, val auth_token: String,
) )
// ── Transactions ────────────────────────────────────────────────────────
@JsonClass(generateAdapter = true)
data class TransactionListResponse(
val transactions: List<TransactionItem>,
val total: Int,
)
@JsonClass(generateAdapter = true)
data class TransactionItem(
val id: Int,
val card_id: Int,
val transaction_type: String,
val stamps_delta: Int = 0,
val points_delta: Int = 0,
val customer_name: String? = null,
val staff_name: String? = null,
val purchase_amount_cents: Int? = null,
val reward_description: String? = null,
val transaction_at: String,
)
// ── Categories ────────────────────────────────────────────────────────── // ── Categories ──────────────────────────────────────────────────────────
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)

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

@@ -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,56 @@
package lu.rewardflow.terminal.ui.terminal
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import kotlinx.coroutines.delay
/**
* Wraps a screen and fires [onIdle] after [timeoutMillis] of no pointer
* activity. Each touch-down restarts the timer. Disposing the wrapper
* cancels the pending timer.
*
* Used by the terminal screen to auto-lock back to PIN after 2 minutes
* of inactivity, per the implementation plan. Camera flows (QR scanner
* overlay) don't generate pointer events, but those flows are short-
* lived and the user is clearly using the device anyway — we accept
* the trade-off rather than wire a separate "in-active-flow" guard.
*/
@Composable
fun IdleTracker(
timeoutMillis: Long,
onIdle: () -> Unit,
content: @Composable () -> Unit,
) {
var lastActivity by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(lastActivity) {
delay(timeoutMillis)
onIdle()
}
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
awaitEachGesture {
// requireUnconsumed=false → observe even when a child
// consumes the gesture (otherwise we'd miss every tap
// on a button / text field, which is most of them).
awaitFirstDown(requireUnconsumed = false)
lastActivity = System.currentTimeMillis()
}
},
) {
content()
}
}

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

@@ -1,95 +1,581 @@
package lu.rewardflow.terminal.ui.terminal package lu.rewardflow.terminal.ui.terminal
import androidx.compose.foundation.layout.* import androidx.compose.foundation.background
import androidx.compose.material3.* import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import lu.rewardflow.terminal.R
import lu.rewardflow.terminal.data.model.CardLookupResponse
import lu.rewardflow.terminal.data.model.TransactionItem
import java.time.format.DateTimeFormatter
import java.time.OffsetDateTime
import java.time.ZoneId
/** /**
* Main POS terminal screen. * Main POS terminal screen.
* *
* Layout (landscape tablet): * Phase D.1 surface — landscape layout:
* ┌──────────────────────────────────────────────┐ *
* │ [Staff: Jane] [Store: Fashion Hub] [Lock] │ * ┌────────────────────────────────────────────────────┐
* ├──────────────────┬───────────────────────────┤ * │ Staff: Diana Online • | Lock │
* │ │ │ * ├──────────────┬─────────────────────────────────────┤
* │ Customer Search │ Card Details * │ │ Customer card (or empty state)
* + QR Scanner │ Points/Stamps balance * │ Search / │ Balance, stamps, available rewards
* │ │ Quick actions * │ QR / Enroll
* (Earn, Redeem, Enroll) * Action buttons (wired in D.2)
* │ │ │ * └──────────────┴─────────────────────────────────────┘
* └──────────────────┴───────────────────────────┘
*/ */
@Composable @Composable
fun TerminalScreen( fun TerminalScreen(
staffPinId: Int, staffPinId: Int,
staffName: String, staffName: String,
onLockScreen: () -> Unit, onLockScreen: () -> Unit,
viewModel: TerminalViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
IdleTracker(
timeoutMillis = AUTO_LOCK_TIMEOUT_MS,
onIdle = onLockScreen,
) {
TerminalContent(
state = state,
staffName = staffName,
onLockScreen = onLockScreen,
viewModel = viewModel,
)
}
}
private const val AUTO_LOCK_TIMEOUT_MS = 2 * 60 * 1000L // 2 minutes per spec
@Composable
private fun TerminalContent(
state: TerminalUiState,
staffName: String,
onLockScreen: () -> Unit,
viewModel: TerminalViewModel,
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Top bar TopBar(
Surface( staffName = staffName,
color = MaterialTheme.colorScheme.primaryContainer, isOnline = state.isOnline,
modifier = Modifier.fillMaxWidth(), onLockScreen = onLockScreen,
) { )
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Staff: $staffName",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
// TODO: show store name, offline indicator, pending sync count
FilledTonalButton(onClick = onLockScreen) {
Text("Lock")
}
}
}
// Main content — two-pane layout
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
// Left pane: search + scan LeftPane(
Surface(
modifier = Modifier modifier = Modifier
.weight(0.4f) .weight(0.4f)
.fillMaxHeight() .fillMaxHeight()
.padding(16.dp), .padding(16.dp),
shape = MaterialTheme.shapes.large, state = state,
color = MaterialTheme.colorScheme.surfaceVariant, onSearchChanged = viewModel::onSearchChanged,
) { onSearchSubmit = viewModel::onSearchSubmit,
Column( onScanQrClicked = viewModel::openScanner,
modifier = Modifier.padding(16.dp), onEnrollClicked = viewModel::openEnrollDialog,
horizontalAlignment = Alignment.CenterHorizontally, recentTransactions = state.recentTransactions,
) { )
Text("Search Customer", style = MaterialTheme.typography.titleMedium) RightPane(
Spacer(modifier = Modifier.height(16.dp))
// TODO: Search field + QR camera preview
Text("QR Scanner / Search field", style = MaterialTheme.typography.bodyMedium)
}
}
// Right pane: card details + actions
Surface(
modifier = Modifier modifier = Modifier
.weight(0.6f) .weight(0.6f)
.fillMaxHeight() .fillMaxHeight()
.padding(16.dp), .padding(16.dp),
shape = MaterialTheme.shapes.large, customer = state.customer,
color = MaterialTheme.colorScheme.surface, 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,
)
}
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
private fun TopBar(
staffName: String,
isOnline: Boolean,
onLockScreen: () -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = staffName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.size(16.dp))
OnlinePill(isOnline = isOnline)
Spacer(modifier = Modifier.weight(1f))
FilledTonalButton(onClick = onLockScreen) {
Text(stringResource(R.string.terminal_lock))
}
}
}
}
@Composable
private fun OnlinePill(isOnline: Boolean) {
val (label, color) = if (isOnline)
stringResource(R.string.terminal_online) to MaterialTheme.colorScheme.tertiary
else
stringResource(R.string.terminal_offline) to MaterialTheme.colorScheme.error
Surface(
shape = RoundedCornerShape(12.dp),
color = color.copy(alpha = 0.18f),
) {
Text(
text = label,
color = color,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
)
}
}
@Composable
private fun LeftPane(
modifier: Modifier,
state: TerminalUiState,
onSearchChanged: (String) -> Unit,
onSearchSubmit: () -> Unit,
onScanQrClicked: () -> Unit,
onEnrollClicked: () -> Unit,
recentTransactions: List<TransactionItem>,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Text(
text = stringResource(R.string.terminal_search_hint),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = state.searchQuery,
onValueChange = onSearchChanged,
singleLine = true,
placeholder = { Text(stringResource(R.string.terminal_search_hint)) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { onSearchSubmit() }),
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
if (state.isSearching) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
}
},
)
Spacer(Modifier.height(12.dp))
Button(
onClick = onSearchSubmit,
enabled = state.searchQuery.isNotBlank() && !state.isSearching,
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(vertical = 12.dp),
) { ) {
Column( Text(stringResource(R.string.terminal_search_hint))
modifier = Modifier.padding(16.dp), }
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = onScanQrClicked,
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(vertical = 12.dp),
) {
Text(stringResource(R.string.terminal_scan_qr))
}
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = onEnrollClicked,
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(vertical = 12.dp),
) {
Text(stringResource(R.string.terminal_enroll_customer))
}
if (state.errorMessage != null) {
Spacer(Modifier.height(16.dp))
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.errorContainer,
) { ) {
Text("Select a customer to begin", style = MaterialTheme.typography.bodyLarge) Text(
// TODO: Show card details, balance, quick action buttons text = state.errorMessage,
modifier = Modifier.padding(12.dp),
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodyMedium,
)
}
}
Spacer(Modifier.height(20.dp))
RecentTransactionsList(
modifier = Modifier.weight(1f),
transactions = recentTransactions,
)
}
}
}
@Composable
private fun RecentTransactionsList(
modifier: Modifier,
transactions: List<TransactionItem>,
) {
Column(modifier = modifier) {
Text(
text = stringResource(R.string.terminal_recent_transactions),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(8.dp))
if (transactions.isEmpty()) {
Text(
text = "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
items(transactions, key = { it.id }) { tx ->
TransactionRow(tx)
} }
} }
} }
} }
} }
@Composable
private fun TransactionRow(tx: TransactionItem) {
val delta = formatDelta(tx)
val time = formatTransactionTime(tx.transaction_at)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = transactionLabel(tx.transaction_type),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
if (!tx.customer_name.isNullOrBlank()) {
Text(
text = tx.customer_name,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Column(horizontalAlignment = Alignment.End) {
if (delta != null) {
Text(
text = delta,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
}
Text(
text = time,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
private fun transactionLabel(type: String): String = when (type) {
"stamp_earned" -> "Stamp"
"stamp_redeemed" -> "Stamp redeemed"
"stamp_voided" -> "Stamp voided"
"points_earned" -> "Points"
"points_redeemed" -> "Points redeemed"
"points_voided" -> "Points voided"
"card_created" -> "Enrolled"
"welcome_bonus" -> "Welcome bonus"
"points_expired" -> "Points expired"
"points_adjustment" -> "Points adjustment"
"stamp_adjustment" -> "Stamp adjustment"
else -> type.replace('_', ' ').replaceFirstChar { it.uppercase() }
}
private fun formatDelta(tx: TransactionItem): String? = when {
tx.points_delta != 0 -> "${if (tx.points_delta > 0) "+" else ""}${tx.points_delta} pts"
tx.stamps_delta != 0 -> "${if (tx.stamps_delta > 0) "+" else ""}${tx.stamps_delta}"
else -> null
}
private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
private fun formatTransactionTime(iso: String): String =
runCatching {
OffsetDateTime.parse(iso).atZoneSameInstant(ZoneId.systemDefault()).format(timeFormatter)
}.getOrDefault(iso.take(10))
@Composable
private fun RightPane(
modifier: Modifier,
customer: CardLookupResponse?,
stampsTarget: Int,
isOnline: Boolean,
onClearCustomer: () -> Unit,
onAction: (ActionKind) -> Unit,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
) {
if (customer == null) {
EmptyCustomerState(modifier = Modifier.fillMaxSize().padding(16.dp))
} else {
CustomerPanel(
modifier = Modifier.fillMaxSize().padding(16.dp),
customer = customer,
stampsTarget = stampsTarget,
isOnline = isOnline,
onClearCustomer = onClearCustomer,
onAction = onAction,
)
}
}
}
@Composable
private fun EmptyCustomerState(modifier: Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.terminal_no_customer_title),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.terminal_no_customer_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun CustomerPanel(
modifier: Modifier,
customer: CardLookupResponse,
stampsTarget: Int,
isOnline: Boolean,
onClearCustomer: () -> Unit,
onAction: (ActionKind) -> Unit,
) {
Column(modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = customer.customer_name ?: customer.customer_email,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
text = customer.customer_email,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "${stringResource(R.string.terminal_card_label)}: ${customer.card_number}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onClearCustomer) {
Text(stringResource(R.string.terminal_close_customer))
}
}
Spacer(Modifier.height(16.dp))
BalanceCard(
pointsBalance = customer.points_balance,
stampCount = customer.stamp_count,
stampsTarget = if (stampsTarget > 0) stampsTarget else customer.stamps_target,
)
Spacer(Modifier.height(16.dp))
ActionButtons(
customer = customer,
isOnline = isOnline,
onAction = onAction,
)
}
}
@Composable
private fun BalanceCard(
pointsBalance: Int,
stampCount: Int,
stampsTarget: Int,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primaryContainer,
) {
Row(
modifier = Modifier.padding(20.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(R.string.balance_points, pointsBalance),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.Bold,
)
}
Box(
modifier = Modifier
.size(width = 1.dp, height = 48.dp)
.clip(RoundedCornerShape(1.dp))
.background(MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.3f)),
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(
R.string.balance_stamps,
stampCount,
if (stampsTarget > 0) stampsTarget else 10,
),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.Bold,
)
}
}
}
}
@Composable
private fun ActionButtons(
customer: CardLookupResponse,
isOnline: Boolean,
onAction: (ActionKind) -> Unit,
) {
// 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)) {
Button(
onClick = { onAction(ActionKind.AddStamp) },
enabled = customer.can_stamp,
modifier = Modifier.weight(1f),
) { Text(stringResource(R.string.action_add_stamp)) }
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 = { onAction(ActionKind.RedeemStamps) },
enabled = isOnline && customer.can_redeem_stamps,
modifier = Modifier.weight(1f),
) { Text(stringResource(R.string.action_redeem_stamps)) }
OutlinedButton(
onClick = { onAction(ActionKind.RedeemReward) },
enabled = isOnline && canRedeemReward,
modifier = Modifier.weight(1f),
) { Text(stringResource(R.string.action_redeem_reward)) }
}
}

View File

@@ -0,0 +1,374 @@
package lu.rewardflow.terminal.ui.terminal
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
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.EnrollRequest
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.model.TransactionItem
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, 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: 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() {
private val programAdapter: JsonAdapter<ProgramResponse> =
moshi.adapter(ProgramResponse::class.java)
private val _state = MutableStateFlow(TerminalUiState())
val state: StateFlow<TerminalUiState> = _state.asStateFlow()
init {
loadCachedProgram()
loadCategories()
refreshRecentTransactions()
networkMonitor.isOnline
.onEach { online -> _state.value = _state.value.copy(isOnline = online) }
.launchIn(viewModelScope)
}
fun onSearchChanged(query: String) {
_state.value = _state.value.copy(searchQuery = query, errorMessage = null)
}
fun onSearchSubmit() {
val q = _state.value.searchQuery.trim()
if (q.isBlank()) return
_state.value = _state.value.copy(
isSearching = true,
customer = null,
errorMessage = null,
)
viewModelScope.launch {
val result = runCatching { api.lookupCard(q) }
_state.value = result.fold(
onSuccess = { card ->
_state.value.copy(
isSearching = false,
customer = card,
searchQuery = "",
)
},
onFailure = { err ->
_state.value.copy(
isSearching = false,
errorMessage = err.message ?: "Lookup failed",
)
},
)
}
}
fun lookupByCardNumber(cardNumber: String) {
if (cardNumber.isBlank()) return
onSearchChanged(cardNumber)
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() {
_state.value = _state.value.copy(
customer = null,
searchQuery = "",
errorMessage = null,
)
}
fun consumeError() {
_state.value = _state.value.copy(errorMessage = null)
}
// ── 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()
refreshRecentTransactions()
} 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) }
}
}
/** Pull the latest ~10 transactions for the left-pane feed. Failure is
* silent: a missing list is acceptable degradation, the rest of the
* terminal screen still works. */
fun refreshRecentTransactions() {
viewModelScope.launch {
runCatching { api.listRecentTransactions(skip = 0, limit = 10) }
.onSuccess { resp ->
_state.value = _state.value.copy(
recentTransactions = resp.transactions,
)
}
}
}
// ── Internal ─────────────────────────────────────────────────────
private fun loadCachedProgram() {
viewModelScope.launch {
val json = configRepository.programJson.first()
if (json != null) {
val program = runCatching { programAdapter.fromJson(json) }.getOrNull()
if (program != null) {
_state.value = _state.value.copy(program = program)
}
}
}
}
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,
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 }
sealed interface ActionResult {
data object Success : ActionResult
data class Failure(val message: String) : ActionResult
}