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>
This commit is contained in:
@@ -56,6 +56,14 @@ interface LoyaltyApi {
|
||||
@GET("api/v1/store/loyalty/categories")
|
||||
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
|
||||
@POST("api/v1/store/auth/login")
|
||||
suspend fun login(@Body request: LoginRequest): LoginResponse
|
||||
|
||||
@@ -226,6 +226,28 @@ data class SetupPayload(
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
|
||||
@@ -13,6 +13,8 @@ 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
|
||||
@@ -39,6 +41,10 @@ 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.
|
||||
@@ -78,6 +84,7 @@ fun TerminalScreen(
|
||||
state = state,
|
||||
onSearchChanged = viewModel::onSearchChanged,
|
||||
onSearchSubmit = viewModel::onSearchSubmit,
|
||||
recentTransactions = state.recentTransactions,
|
||||
)
|
||||
RightPane(
|
||||
modifier = Modifier
|
||||
@@ -160,6 +167,7 @@ private fun LeftPane(
|
||||
state: TerminalUiState,
|
||||
onSearchChanged: (String) -> Unit,
|
||||
onSearchSubmit: () -> Unit,
|
||||
recentTransactions: List<TransactionItem>,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
@@ -202,7 +210,7 @@ private fun LeftPane(
|
||||
Text(stringResource(R.string.terminal_search_hint))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Phase D.4 will replace these with real handlers.
|
||||
OutlinedButton(
|
||||
@@ -237,10 +245,117 @@ private fun LeftPane(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -20,6 +20,7 @@ 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
|
||||
@@ -57,6 +58,7 @@ class TerminalViewModel @Inject constructor(
|
||||
init {
|
||||
loadCachedProgram()
|
||||
loadCategories()
|
||||
refreshRecentTransactions()
|
||||
networkMonitor.isOnline
|
||||
.onEach { online -> _state.value = _state.value.copy(isOnline = online) }
|
||||
.launchIn(viewModelScope)
|
||||
@@ -190,6 +192,7 @@ class TerminalViewModel @Inject constructor(
|
||||
activeAction = null,
|
||||
)
|
||||
refreshCurrentCustomer()
|
||||
refreshRecentTransactions()
|
||||
} else {
|
||||
_state.value = _state.value.copy(
|
||||
actionInProgress = false,
|
||||
@@ -209,6 +212,20 @@ class TerminalViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
@@ -245,6 +262,7 @@ data class TerminalUiState(
|
||||
val activeAction: ActionKind? = null,
|
||||
val actionInProgress: Boolean = false,
|
||||
val actionResult: ActionResult? = null,
|
||||
val recentTransactions: List<TransactionItem> = emptyList(),
|
||||
)
|
||||
|
||||
enum class ActionKind { AddStamp, EarnPoints, RedeemStamps, RedeemReward }
|
||||
|
||||
Reference in New Issue
Block a user