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:
2026-05-06 21:25:10 +02:00
parent 02652ee8c6
commit d345d65fd4
4 changed files with 164 additions and 1 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

@@ -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,

View File

@@ -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 }