diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/api/LoyaltyApi.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/api/LoyaltyApi.kt index be3e1dc1..73bb56ec 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/api/LoyaltyApi.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/api/LoyaltyApi.kt @@ -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 diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt index c813b26d..4437a8ad 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/data/model/ApiModels.kt @@ -226,6 +226,28 @@ data class SetupPayload( val auth_token: String, ) +// ── Transactions ──────────────────────────────────────────────────────── + +@JsonClass(generateAdapter = true) +data class TransactionListResponse( + val transactions: List, + 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) diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt index 524fe15a..32f56834 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalScreen.kt @@ -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, ) { 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, +) { + 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, diff --git a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalViewModel.kt b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalViewModel.kt index ae18d17a..95530286 100644 --- a/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalViewModel.kt +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalViewModel.kt @@ -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 = emptyList(), ) enum class ActionKind { AddStamp, EarnPoints, RedeemStamps, RedeemReward }