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 5959289c..15250781 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 @@ -1,95 +1,409 @@ package lu.rewardflow.terminal.ui.terminal -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* +import androidx.compose.foundation.background +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.getValue import androidx.compose.ui.Alignment 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.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. * - * Layout (landscape tablet): - * ┌──────────────────────────────────────────────┐ - * │ [Staff: Jane] [Store: Fashion Hub] [Lock] │ - * ├──────────────────┬───────────────────────────┤ - * │ │ │ - * │ Customer Search │ Card Details │ - * │ + QR Scanner │ Points/Stamps balance │ - * │ │ Quick actions │ - * │ │ (Earn, Redeem, Enroll) │ - * │ │ │ - * └──────────────────┴───────────────────────────┘ + * Phase D.1 surface — landscape layout: + * + * ┌────────────────────────────────────────────────────┐ + * │ Staff: Diana Online • | Lock │ + * ├──────────────┬─────────────────────────────────────┤ + * │ │ Customer card (or empty state) │ + * │ Search / │ Balance, stamps, available rewards │ + * │ QR / Enroll│ │ + * │ │ Action buttons (wired in D.2) │ + * └──────────────┴─────────────────────────────────────┘ */ @Composable fun TerminalScreen( staffPinId: Int, staffName: String, onLockScreen: () -> Unit, + viewModel: TerminalViewModel = hiltViewModel(), ) { - Column(modifier = Modifier.fillMaxSize()) { - // Top bar - Surface( - color = MaterialTheme.colorScheme.primaryContainer, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Staff: $staffName", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f), - ) - // TODO: show store name, offline indicator, pending sync count - FilledTonalButton(onClick = onLockScreen) { - Text("Lock") - } - } - } + val state by viewModel.state.collectAsStateWithLifecycle() - // Main content — two-pane layout + Column(modifier = Modifier.fillMaxSize()) { + TopBar( + staffName = staffName, + isOnline = state.isOnline, + onLockScreen = onLockScreen, + ) Row(modifier = Modifier.fillMaxSize()) { - // Left pane: search + scan - Surface( + LeftPane( modifier = Modifier .weight(0.4f) .fillMaxHeight() .padding(16.dp), - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceVariant, - ) { - Column( - modifier = Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text("Search Customer", style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.height(16.dp)) - // TODO: Search field + QR camera preview - Text("QR Scanner / Search field", style = MaterialTheme.typography.bodyMedium) - } - } - - // Right pane: card details + actions - Surface( + state = state, + onSearchChanged = viewModel::onSearchChanged, + onSearchSubmit = viewModel::onSearchSubmit, + ) + RightPane( modifier = Modifier .weight(0.6f) .fillMaxHeight() .padding(16.dp), - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surface, + 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( + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = staffName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.size(16.dp)) + OnlinePill(isOnline = isOnline) + Spacer(modifier = Modifier.weight(1f)) + FilledTonalButton(onClick = onLockScreen) { + Text(stringResource(R.string.terminal_lock)) + } + } + } +} + +@Composable +private fun OnlinePill(isOnline: Boolean) { + 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( + shape = RoundedCornerShape(12.dp), + color = color.copy(alpha = 0.18f), + ) { + Text( + 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, + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = stringResource(R.string.terminal_search_hint), + style = MaterialTheme.typography.titleMedium, + ) + 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), ) { - Column( - modifier = Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + 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("Select a customer to begin", style = MaterialTheme.typography.bodyLarge) - // TODO: Show card details, balance, quick action buttons + Text( + text = state.errorMessage, + modifier = Modifier.padding(12.dp), + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyMedium, + ) } } } } } + +@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)) } + } +} 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 new file mode 100644 index 00000000..fc01de02 --- /dev/null +++ b/clients/terminal-android/app/src/main/java/lu/rewardflow/terminal/ui/terminal/TerminalViewModel.kt @@ -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 = + moshi.adapter(ProgramResponse::class.java) + + private val _state = MutableStateFlow(TerminalUiState()) + val state: StateFlow = _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, +)