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:
@@ -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))
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = state.errorMessage,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right pane: card details + actions
|
@Composable
|
||||||
|
private fun RightPane(
|
||||||
|
modifier: Modifier,
|
||||||
|
customer: CardLookupResponse?,
|
||||||
|
stampsTarget: Int,
|
||||||
|
isOnline: Boolean,
|
||||||
|
onClearCustomer: () -> Unit,
|
||||||
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = modifier,
|
||||||
.weight(0.6f)
|
shape = RoundedCornerShape(16.dp),
|
||||||
.fillMaxHeight()
|
|
||||||
.padding(16.dp),
|
|
||||||
shape = MaterialTheme.shapes.large,
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
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(
|
Column(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = modifier,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Text("Select a customer to begin", style = MaterialTheme.typography.bodyLarge)
|
Text(
|
||||||
// TODO: Show card details, balance, quick action buttons
|
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)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user