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")
|
@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)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
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.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
@@ -39,6 +41,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import lu.rewardflow.terminal.R
|
import lu.rewardflow.terminal.R
|
||||||
import lu.rewardflow.terminal.data.model.CardLookupResponse
|
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.
|
||||||
@@ -78,6 +84,7 @@ fun TerminalScreen(
|
|||||||
state = state,
|
state = state,
|
||||||
onSearchChanged = viewModel::onSearchChanged,
|
onSearchChanged = viewModel::onSearchChanged,
|
||||||
onSearchSubmit = viewModel::onSearchSubmit,
|
onSearchSubmit = viewModel::onSearchSubmit,
|
||||||
|
recentTransactions = state.recentTransactions,
|
||||||
)
|
)
|
||||||
RightPane(
|
RightPane(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -160,6 +167,7 @@ private fun LeftPane(
|
|||||||
state: TerminalUiState,
|
state: TerminalUiState,
|
||||||
onSearchChanged: (String) -> Unit,
|
onSearchChanged: (String) -> Unit,
|
||||||
onSearchSubmit: () -> Unit,
|
onSearchSubmit: () -> Unit,
|
||||||
|
recentTransactions: List<TransactionItem>,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -202,7 +210,7 @@ private fun LeftPane(
|
|||||||
Text(stringResource(R.string.terminal_search_hint))
|
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.
|
// Phase D.4 will replace these with real handlers.
|
||||||
OutlinedButton(
|
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
|
@Composable
|
||||||
private fun RightPane(
|
private fun RightPane(
|
||||||
modifier: Modifier,
|
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.ProgramResponse
|
||||||
import lu.rewardflow.terminal.data.model.StampRedeemRequest
|
import lu.rewardflow.terminal.data.model.StampRedeemRequest
|
||||||
import lu.rewardflow.terminal.data.model.StampRequest
|
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.network.NetworkMonitor
|
||||||
import lu.rewardflow.terminal.data.repository.CategoryRepository
|
import lu.rewardflow.terminal.data.repository.CategoryRepository
|
||||||
import lu.rewardflow.terminal.data.repository.DeviceConfigRepository
|
import lu.rewardflow.terminal.data.repository.DeviceConfigRepository
|
||||||
@@ -57,6 +58,7 @@ class TerminalViewModel @Inject constructor(
|
|||||||
init {
|
init {
|
||||||
loadCachedProgram()
|
loadCachedProgram()
|
||||||
loadCategories()
|
loadCategories()
|
||||||
|
refreshRecentTransactions()
|
||||||
networkMonitor.isOnline
|
networkMonitor.isOnline
|
||||||
.onEach { online -> _state.value = _state.value.copy(isOnline = online) }
|
.onEach { online -> _state.value = _state.value.copy(isOnline = online) }
|
||||||
.launchIn(viewModelScope)
|
.launchIn(viewModelScope)
|
||||||
@@ -190,6 +192,7 @@ class TerminalViewModel @Inject constructor(
|
|||||||
activeAction = null,
|
activeAction = null,
|
||||||
)
|
)
|
||||||
refreshCurrentCustomer()
|
refreshCurrentCustomer()
|
||||||
|
refreshRecentTransactions()
|
||||||
} else {
|
} else {
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
actionInProgress = false,
|
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 ─────────────────────────────────────────────────────
|
// ── Internal ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun loadCachedProgram() {
|
private fun loadCachedProgram() {
|
||||||
@@ -245,6 +262,7 @@ data class TerminalUiState(
|
|||||||
val activeAction: ActionKind? = null,
|
val activeAction: ActionKind? = null,
|
||||||
val actionInProgress: Boolean = false,
|
val actionInProgress: Boolean = false,
|
||||||
val actionResult: ActionResult? = null,
|
val actionResult: ActionResult? = null,
|
||||||
|
val recentTransactions: List<TransactionItem> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class ActionKind { AddStamp, EarnPoints, RedeemStamps, RedeemReward }
|
enum class ActionKind { AddStamp, EarnPoints, RedeemStamps, RedeemReward }
|
||||||
|
|||||||
Reference in New Issue
Block a user