Compare commits
5 Commits
3bf23c1b23
...
d3f1c33b37
| Author | SHA1 | Date | |
|---|---|---|---|
| d3f1c33b37 | |||
| 01a12dcef4 | |||
| d345d65fd4 | |||
| 02652ee8c6 | |||
| 47565419e2 |
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package lu.rewardflow.terminal.ui.terminal
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import lu.rewardflow.terminal.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom-sheet-style enrollment dialog fired from the left-pane
|
||||||
|
* "Enroll Customer" button.
|
||||||
|
*
|
||||||
|
* Required: name + email. Phone + birthday are optional. Birthday
|
||||||
|
* format mirrors what the backend expects (``YYYY-MM-DD`` string) —
|
||||||
|
* the field is plain text for now; a date picker is a polish task.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EnrollDialog(
|
||||||
|
inProgress: Boolean,
|
||||||
|
error: String?,
|
||||||
|
onSubmit: (name: String, email: String, phone: String?, birthday: String?) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var email by remember { mutableStateOf("") }
|
||||||
|
var phone by remember { mutableStateOf("") }
|
||||||
|
var birthday by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.enroll_title)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text(stringResource(R.string.enroll_name)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = { email = it },
|
||||||
|
label = { Text(stringResource(R.string.enroll_email)) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = phone,
|
||||||
|
onValueChange = { phone = it },
|
||||||
|
label = { Text(stringResource(R.string.enroll_phone)) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = birthday,
|
||||||
|
onValueChange = { input -> birthday = input.filter { it.isDigit() || it == '-' } },
|
||||||
|
label = { Text(stringResource(R.string.enroll_birthday)) },
|
||||||
|
placeholder = { Text("YYYY-MM-DD") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
if (error != null) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onSubmit(
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
phone.takeIf { it.isNotBlank() },
|
||||||
|
birthday.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = !inProgress && name.isNotBlank() && email.isNotBlank(),
|
||||||
|
) {
|
||||||
|
if (inProgress) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.enroll_submit))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss, enabled = !inProgress) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user