feat(android-terminal): Phase D.1 — terminal lookup + customer card

The terminal screen is now functional for card lookup and customer
display. Phase D.2 will fill in the action sheets (stamp / earn /
redeem) on top of this.

- TerminalViewModel: state machine with program (from cache), customer,
  search/error/online state. onSearchSubmit hits /cards/lookup;
  refreshCurrentCustomer re-fetches after actions land.
- TerminalScreen rewrite: top bar with staff name + online pill + Lock;
  left pane with search field + buttons; right pane shows the empty
  state or a customer panel (name/email/card number, points + stamps
  card, four placeholder action buttons greyed out for D.2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 20:27:06 +02:00
parent 3bf23c1b23
commit 47565419e2
2 changed files with 515 additions and 61 deletions

View File

@@ -1,35 +1,104 @@
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.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
/** /**
* 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()
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Top bar TopBar(
staffName = staffName,
isOnline = state.isOnline,
onLockScreen = onLockScreen,
)
Row(modifier = Modifier.fillMaxSize()) {
LeftPane(
modifier = Modifier
.weight(0.4f)
.fillMaxHeight()
.padding(16.dp),
state = state,
onSearchChanged = viewModel::onSearchChanged,
onSearchSubmit = viewModel::onSearchSubmit,
)
RightPane(
modifier = Modifier
.weight(0.6f)
.fillMaxHeight()
.padding(16.dp),
customer = state.customer,
stampsTarget = state.program?.stamps_target ?: 0,
isOnline = state.isOnline,
onClearCustomer = viewModel::clearCustomer,
)
}
}
}
@Composable
private fun TopBar(
staffName: String,
isOnline: Boolean,
onLockScreen: () -> Unit,
) {
Surface( Surface(
color = MaterialTheme.colorScheme.primaryContainer, color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -39,57 +108,302 @@ fun TerminalScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "Staff: $staffName", text = staffName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold,
) )
// TODO: show store name, offline indicator, pending sync count Spacer(Modifier.size(16.dp))
OnlinePill(isOnline = isOnline)
Spacer(modifier = Modifier.weight(1f))
FilledTonalButton(onClick = onLockScreen) { FilledTonalButton(onClick = onLockScreen) {
Text("Lock") Text(stringResource(R.string.terminal_lock))
} }
} }
} }
}
// Main content — two-pane layout @Composable
Row(modifier = Modifier.fillMaxSize()) { private fun OnlinePill(isOnline: Boolean) {
// Left pane: search + scan 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( Surface(
modifier = Modifier shape = RoundedCornerShape(12.dp),
.weight(0.4f) color = color.copy(alpha = 0.18f),
.fillMaxHeight() ) {
.padding(16.dp), Text(
shape = MaterialTheme.shapes.large, 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,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceVariant,
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text("Search Customer", style = MaterialTheme.typography.titleMedium) Text(
Spacer(modifier = Modifier.height(16.dp)) text = stringResource(R.string.terminal_search_hint),
// TODO: Search field + QR camera preview style = MaterialTheme.typography.titleMedium,
Text("QR Scanner / Search field", style = MaterialTheme.typography.bodyMedium) )
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),
) {
Text(stringResource(R.string.terminal_search_hint))
} }
// Right pane: card details + actions Spacer(Modifier.height(20.dp))
// Phase D.4 will replace these with real handlers.
OutlinedButton(
onClick = { /* QR scan — D.4 */ },
enabled = false,
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(vertical = 12.dp),
) {
Text(stringResource(R.string.terminal_scan_qr))
}
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = { /* Enroll — D.4 */ },
enabled = false,
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( Surface(
modifier = Modifier shape = RoundedCornerShape(8.dp),
.weight(0.6f) color = MaterialTheme.colorScheme.errorContainer,
.fillMaxHeight()
.padding(16.dp),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface,
) { ) {
Column( Text(
modifier = Modifier.padding(16.dp), text = state.errorMessage,
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.Center, color = MaterialTheme.colorScheme.onErrorContainer,
) { style = MaterialTheme.typography.bodyMedium,
Text("Select a customer to begin", style = MaterialTheme.typography.bodyLarge) )
// TODO: Show card details, balance, quick action buttons
} }
} }
} }
} }
} }
@Composable
private fun RightPane(
modifier: Modifier,
customer: CardLookupResponse?,
stampsTarget: Int,
isOnline: Boolean,
onClearCustomer: () -> 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,
)
}
}
}
@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,
) {
Column(modifier = modifier) {
// Customer header
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))
// Action buttons — wired in D.2
ActionButtonsPlaceholder(
isOnline = isOnline,
canRedeemStamps = customer.can_redeem_stamps,
)
}
}
@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 ActionButtonsPlaceholder(
isOnline: Boolean,
canRedeemStamps: Boolean,
) {
// D.2 will replace this with real action sheets.
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = { },
enabled = false,
modifier = Modifier.weight(1f),
) { Text(stringResource(R.string.action_add_stamp)) }
OutlinedButton(
onClick = { },
enabled = false,
modifier = Modifier.weight(1f),
) { Text(stringResource(R.string.action_earn_points)) }
}
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = { },
enabled = false,
modifier = Modifier.weight(1f),
) { Text(stringResource(R.string.action_redeem_stamps)) }
OutlinedButton(
onClick = { },
enabled = false,
modifier = Modifier.weight(1f),
) { Text(stringResource(R.string.action_redeem_reward)) }
}
}

View File

@@ -0,0 +1,140 @@
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.ProgramResponse
import lu.rewardflow.terminal.data.network.NetworkMonitor
import lu.rewardflow.terminal.data.repository.DeviceConfigRepository
import javax.inject.Inject
/**
* Powers the main POS terminal screen.
*
* Tracks: which customer is currently selected (or none), the cached
* program config (for stamps_target / card colors / etc), online state,
* and a transient error message. Card-lookup hits the API directly —
* no offline fallback because we need the latest balance to show
* something accurate; queueing a "lookup" doesn't make sense.
*
* Phase D.1 surface: search → lookup → render. Action sheets / actions
* land in D.2.
*/
@HiltViewModel
class TerminalViewModel @Inject constructor(
private val api: LoyaltyApi,
private val configRepository: DeviceConfigRepository,
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()
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)
}
/** Card lookup — accepts card number, email, or partial name. */
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",
)
},
)
}
}
/** Card-number-based lookup, used by the QR scanner overlay (D.4). */
fun lookupByCardNumber(cardNumber: String) {
if (cardNumber.isBlank()) return
onSearchChanged(cardNumber)
onSearchSubmit()
}
fun clearCustomer() {
_state.value = _state.value.copy(
customer = null,
searchQuery = "",
errorMessage = null,
)
}
fun consumeError() {
_state.value = _state.value.copy(errorMessage = null)
}
/** Refresh the currently-displayed customer card from the server.
* Called by the action sheets (D.2) after a successful operation
* so balances reflect the new state. */
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)
}
}
}
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)
}
}
}
}
}
data class TerminalUiState(
val program: ProgramResponse? = null,
val searchQuery: String = "",
val isSearching: Boolean = false,
val customer: CardLookupResponse? = null,
val errorMessage: String? = null,
val isOnline: Boolean = true,
)