manual entry added flow only

This commit is contained in:
kizzy 2026-08-18 17:38:07 +07:00
parent 92a24851f1
commit 145b3ea898
3 changed files with 319 additions and 297 deletions

View File

@ -116,6 +116,7 @@ class CardWaitingViewModel(
}
fun onManualEntryClick() {
stopCardReading()
viewModelScope.launch {
_events.send(CardWaitingEvent.GoManualEntry)
}

View File

@ -1,8 +1,8 @@
package com.mob.utsmyanmar.ui.manual_entry
import androidx.compose.animation.AnimatedVisibility
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -10,21 +10,18 @@ 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.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.outlined.CreditCard
import androidx.compose.material.icons.automirrored.rounded.Backspace
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -35,33 +32,37 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.mob.utsmyanmar.ui.components.NumericKeypad
import com.mob.utsmyanmar.ui.components.appbar.AppBar
import com.mob.utsmyanmar.ui.pinpad.PinPadViewModel
import com.mob.utsmyanmar.ui.preview.P2Preview
import com.mob.utsmyanmar.ui.preview.P3Preview
import com.mob.utsmyanmar.ui.theme.Color
import com.mob.utsmyanmar.utils.TransactionUtil
import com.mob.utsmyanmar.viewmodel.SharedViewModel
import com.mob.utsmyanmar.viewmodel.TransProcessViewModel
import com.utsmyanmar.checkxread.model.CardDataX
import com.utsmyanmar.checkxread.util.CardTypeX
import com.utsmyanmar.paylibs.model.TradeData
import com.utsmyanmar.paylibs.utils.POSUtil
import com.utsmyanmar.paylibs.utils.iso_utils.TransactionsType
import sunmi.sunmiui.utils.LogUtil
private const val PAN_RAW_MAX = 16
private const val PAN_RAW_MAX = 19
private const val PAN_RAW_MIN = 13
private const val EXPIRY_RAW_MAX = 4
private enum class ManualEntryStep {
CARD_NUMBER,
EXPIRY
}
private val digitKeys = listOf(
listOf("1", "2", "3"),
listOf("4", "5", "6"),
listOf("7", "8", "9"),
listOf("", "0", "")
)
@Composable
fun InputManualCardRoute(
@ -71,50 +72,67 @@ fun InputManualCardRoute(
onBack: () -> Unit,
onNavigatePinPad: () -> Unit
) {
var step by rememberSaveable { mutableStateOf(ManualEntryStep.CARD_NUMBER) }
var pan by rememberSaveable { mutableStateOf("") }
var expiry by rememberSaveable { mutableStateOf("") }
var panError by rememberSaveable { mutableStateOf<String?>(null) }
var expiryError by rememberSaveable { mutableStateOf<String?>(null) }
val focusManager = LocalFocusManager.current
var errorMessage by rememberSaveable { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
pan = ""; expiry = ""
panError = null; expiryError = null
step = ManualEntryStep.CARD_NUMBER
pan = ""
expiry = ""
errorMessage = null
}
fun validateAndConfirm() {
panError = null; expiryError = null
val rawPan = pan.filter { it.isDigit() }
val rawExp = expiry.filter { it.isDigit() }
fun goBack() {
when (step) {
ManualEntryStep.EXPIRY -> {
step = ManualEntryStep.CARD_NUMBER
expiry = ""
errorMessage = null
}
ManualEntryStep.CARD_NUMBER -> onBack()
}
}
if (rawPan.length < 13 || rawPan.length > PAN_RAW_MAX) {
panError = "Invalid card number"; return
BackHandler { goBack() }
fun confirmCardNumber() {
errorMessage = null
if (pan.length < PAN_RAW_MIN || pan.length > PAN_RAW_MAX) {
errorMessage = "Invalid card number"
return
}
if (!luhnCheck(rawPan)) {
panError = "Invalid card number"; return
if (!luhnCheck(pan)) {
errorMessage = "Invalid card number"
return
}
if (rawExp.length != EXPIRY_RAW_MAX) {
expiryError = "Enter MM/YY"; return
step = ManualEntryStep.EXPIRY
errorMessage = null
}
val month = rawExp.take(2).toIntOrNull() ?: 0
fun confirmExpiry() {
errorMessage = null
if (expiry.length != EXPIRY_RAW_MAX) {
errorMessage = "Enter MM/YY"
return
}
val month = expiry.take(2).toIntOrNull() ?: 0
if (month < 1 || month > 12) {
expiryError = "Invalid month"; return
errorMessage = "Invalid month"
return
}
focusManager.clearFocus()
val expDate = POSUtil.getInstance().getISOFormatExpDate(expiry)
val mpuCardData = CardDataX().apply {
setPan(pan)
setExp(expDate)
setCardHolderName("MANUAL ENTRY")
}
LogUtil.d("Tag","exp date : $rawExp")
val cardNo = rawPan.replace("\\s","")
val expDate =
POSUtil.getInstance().getISOFormatExpDate(rawExp)
val mpuCardData = CardDataX()
mpuCardData.setPan(cardNo)
mpuCardData.setExp(expDate)
mpuCardData.setCardHolderName("MANUAL ENTRY")
val tradeData =
TransactionUtil.initMPUTransaction(mpuCardData, CardTypeX.MANUAL)
val tradeData = TransactionUtil.initMPUTransaction(mpuCardData, CardTypeX.MANUAL)
sharedViewModel.setCardDataExist(true)
sharedViewModel.payDetail.value = tradeData.payDetail
transProcessViewModel.setTransType(sharedViewModel.transactionsType.value)
transProcessViewModel.setTradeData(tradeData)
pinPadViewModel.setTradeData(tradeData)
@ -122,190 +140,179 @@ fun InputManualCardRoute(
}
ManualCardEntryScreen(
step = step,
pan = pan,
expiry = expiry,
panError = panError,
expiryError = expiryError,
onPanChange = { input ->
val digits = input.filter { it.isDigit() }.take(PAN_RAW_MAX)
pan = digits
panError = null
errorMessage = errorMessage,
onBackClick = ::goBack,
onCancelClick = ::goBack,
onConfirmClick = {
when (step) {
ManualEntryStep.CARD_NUMBER -> confirmCardNumber()
ManualEntryStep.EXPIRY -> confirmExpiry()
}
},
onExpiryChange = { input ->
val digits = input.filter { it.isDigit() }.take(EXPIRY_RAW_MAX)
expiry = digits
expiryError = null
onKeyClick = { key ->
if (key.isBlank()) return@ManualCardEntryScreen
errorMessage = null
when (step) {
ManualEntryStep.CARD_NUMBER -> {
if (pan.length < PAN_RAW_MAX) {
pan += key.filter { it.isDigit() }
}
}
ManualEntryStep.EXPIRY -> {
if (expiry.length < EXPIRY_RAW_MAX) {
expiry += key.filter { it.isDigit() }
}
}
}
},
onBackClick = onBack,
onCancelClick = onBack,
onConfirmClick = ::validateAndConfirm
onDeleteClick = {
errorMessage = null
when (step) {
ManualEntryStep.CARD_NUMBER -> {
if (pan.isNotEmpty()) pan = pan.dropLast(1)
}
ManualEntryStep.EXPIRY -> {
if (expiry.isNotEmpty()) expiry = expiry.dropLast(1)
}
}
}
)
}
@Composable
private fun ManualCardEntryScreen(
step: ManualEntryStep,
pan: String,
expiry: String,
panError: String?,
expiryError: String?,
onPanChange: (String) -> Unit,
onExpiryChange: (String) -> Unit,
errorMessage: String?,
onBackClick: () -> Unit,
onCancelClick: () -> Unit,
onConfirmClick: () -> Unit
onConfirmClick: () -> Unit,
onKeyClick: (String) -> Unit,
onDeleteClick: () -> Unit
) {
val panDisplay = remember(pan) {
formatPan(pan)
val isCardStep = step == ManualEntryStep.CARD_NUMBER
val displayValue = if (isCardStep) formatPanDisplay(pan) else formatExpiryDisplay(expiry)
val prompt = if (isCardStep) "CARD NUMBER" else "EXPIRY DATE"
val supportingText = when {
errorMessage != null -> errorMessage
isCardStep -> "Enter the card number to continue"
else -> "Enter expiry as MM/YY"
}
val expiryDisplay = remember(expiry) {
formatExpiry(expiry)
}
val cardScheme = detectCardScheme(pan)
val confirmEnabled = pan.length >= 13 && expiry.length == 4
val expiryFocusRequester = remember { FocusRequester() }
LaunchedEffect(pan) {
if (pan.length == PAN_RAW_MAX) expiryFocusRequester.requestFocus()
val confirmText = if (isCardStep) "Next" else "Confirm"
val confirmEnabled = if (isCardStep) {
pan.length >= PAN_RAW_MIN
} else {
expiry.length == EXPIRY_RAW_MAX
}
val canDelete = if (isCardStep) pan.isNotEmpty() else expiry.isNotEmpty()
val cardScheme = remember(pan) { detectCardScheme(pan) }
Scaffold(
topBar = { AppBar(title = "Manual Entry", icon = Icons.AutoMirrored.Rounded.ArrowBack, onIconClick = onBackClick) },
) { innerPadding ->
topBar = {
AppBar(
title = "Manual Entry",
icon = Icons.AutoMirrored.Rounded.ArrowBack,
onIconClick = onBackClick
)
},
containerColor = Color.IvoryBeige
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
.padding(horizontal = 20.dp)
) {
Spacer(modifier = Modifier.height(12.dp))
Surface(
shape = MaterialTheme.shapes.medium,
tonalElevation = 1.dp,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 16.dp)
) {
Icon(
imageVector = Icons.Outlined.CreditCard,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(8.dp))
Text(
"Card details",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (cardScheme != null) {
Spacer(Modifier.weight(1f))
SchemeBadge(cardScheme)
}
}
OutlinedTextField(
value = panDisplay,
onValueChange = {
onPanChange(it.text)
},
label = { Text("Card number") },
placeholder = { Text("0000 0000 0000 0000") },
isError = panError != null,
supportingText = {
Text(panError ?: " ") // reserve space to avoid layout jump
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { expiryFocusRequester.requestFocus() }
),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
textStyle = MaterialTheme.typography.bodyLarge.copy(
letterSpacing = 2.sp
)
text = prompt,
color = Color.Gray,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Spacer(Modifier.height(4.dp))
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = expiryDisplay,
onValueChange = {
onExpiryChange(it.text)
},
label = { Text("Expiry date") },
placeholder = { Text("MM/YY") },
isError = expiryError != null,
supportingText = {
Text(expiryError ?: " ")
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { onConfirmClick() }
),
singleLine = true,
modifier = Modifier
.width(160.dp)
.focusRequester(expiryFocusRequester),
textStyle = MaterialTheme.typography.bodyLarge.copy(
letterSpacing = 2.sp
)
)
}
}
AnimatedVisibility(visible = pan.length >= 4) {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "**** **** **** ${pan.takeLast(4)}",
style = MaterialTheme.typography.bodyMedium,
letterSpacing = 1.5.sp
text = displayValue.ifBlank {
if (isCardStep) "---- ---- ---- ----" else "--/--"
},
color = Color.LegacyRed,
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.SemiBold,
letterSpacing = if (isCardStep) 1.sp else 2.sp,
textAlign = TextAlign.Center
),
textAlign = TextAlign.Center,
maxLines = 2,
modifier = Modifier.weight(1f)
)
if (expiry.length == 4) {
Spacer(modifier = Modifier.width(8.dp))
Card(
modifier = Modifier.clickable(enabled = canDelete, onClick = onDeleteClick),
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.Backspace,
contentDescription = "Delete",
tint = if (canDelete) Color.LegacyRed else Color.Gray,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)
)
}
}
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "Exp ${expiryDisplay.text}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
text = supportingText,
color = if (errorMessage == null) Color.Gray else Color.Error,
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
if (!isCardStep && pan.length >= 4) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "**** **** **** ${pan.takeLast(4)}" +
(cardScheme?.let { " · $it" } ?: ""),
color = Color.Gray,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
}
}
}
}
Spacer(Modifier.weight(1f))
Spacer(modifier = Modifier.height(28.dp))
Box(
NumericKeypad(
modifier = Modifier
.fillMaxWidth()
){
.height(280.dp),
onKeyClick = onKeyClick,
keys = digitKeys
)
Spacer(modifier = Modifier.weight(1f))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = onBackClick,
onClick = onCancelClick,
modifier = Modifier
.weight(1f)
.height(56.dp),
@ -315,15 +322,18 @@ private fun ManualCardEntryScreen(
contentColor = Color.LegacyRed
)
) {
Text("Cancel")
Text(
text = if (isCardStep) "Cancel" else "Back",
style = MaterialTheme.typography.titleSmall
)
}
Button(
onClick = onConfirmClick,
enabled = confirmEnabled,
modifier = Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.LegacyRed,
@ -333,100 +343,84 @@ private fun ManualCardEntryScreen(
)
) {
Text(
text = "Confirm",
text = confirmText,
style = MaterialTheme.typography.titleSmall
)
}
}
}
Spacer(modifier = Modifier.height(18.dp))
}
}
}
private fun formatPanDisplay(raw: String): String {
if (raw.isEmpty()) return ""
return raw.chunked(4).joinToString(" ")
}
@Composable
private fun SchemeBadge(scheme: String) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = scheme,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
private fun formatExpiryDisplay(raw: String): String {
return when {
raw.isEmpty() -> ""
raw.length <= 2 -> raw
else -> "${raw.take(2)}/${raw.drop(2)}"
}
}
private fun formatPan(raw: String): TextFieldValue {
val formatted = raw.chunked(4).joinToString(" ")
val cursorPos = buildString {
raw.forEachIndexed { index, c ->
append(c)
if ((index + 1) % 4 == 0 && index != raw.lastIndex) {
append(" ")
}
}
}.length
return TextFieldValue(
text = formatted,
selection = TextRange(cursorPos)
)
}
private fun formatExpiry(raw: String): TextFieldValue {
val formatted = when {
raw.length > 2 -> "${raw.take(2)}/${raw.drop(2)}"
else -> raw
}
val cursorPos = when {
raw.length <= 2 -> raw.length
else -> raw.length + 1 // account for '/'
}
return TextFieldValue(
text = formatted,
selection = TextRange(cursorPos)
)
}
private fun detectCardScheme(pan: String): String? = when {
pan.startsWith("4") -> "Visa"
pan.length >= 2 && (pan.take(2).toInt() in 51..55 || pan.take(4).toIntOrNull() in 2221..2720) -> "Mastercard"
pan.length >= 2 && (
pan.take(2).toIntOrNull() in 51..55 ||
pan.take(4).toIntOrNull() in 2221..2720
) -> "Mastercard"
pan.length >= 2 && pan.take(2) in listOf("34", "37") -> "Amex"
pan.startsWith("6") -> "UnionPay"
pan.startsWith("62") || pan.startsWith("6") -> "UnionPay / MPU"
else -> null
}
private fun luhnCheck(pan: String): Boolean {
var sum = 0; var alt = false
var sum = 0
var alt = false
for (i in pan.length - 1 downTo 0) {
var n = pan[i].digitToIntOrNull() ?: return false
if (alt) { n *= 2; if (n > 9) n -= 9 }
sum += n; alt = !alt
if (alt) {
n *= 2
if (n > 9) n -= 9
}
sum += n
alt = !alt
}
return sum % 10 == 0
}
@P2Preview
@P3Preview
@Composable
private fun ManualCardEntryScreenPreview() {
MaterialTheme {
private fun PreviewManualCardEntryPan() {
ManualCardEntryScreen(
pan = "4111111111111111",
expiry = "1228",
panError = null,
expiryError = null,
onPanChange = {},
onExpiryChange = {},
step = ManualEntryStep.CARD_NUMBER,
pan = "6244310039574344",
expiry = "",
errorMessage = null,
onBackClick = {},
onCancelClick = {},
onConfirmClick = {}
onConfirmClick = {},
onKeyClick = {},
onDeleteClick = {}
)
}
}
@P2Preview
@Composable
private fun PreviewManualCardEntryExpiry() {
ManualCardEntryScreen(
step = ManualEntryStep.EXPIRY,
pan = "6244310039574344",
expiry = "1228",
errorMessage = null,
onBackClick = {},
onCancelClick = {},
onConfirmClick = {},
onKeyClick = {},
onDeleteClick = {}
)
}

View File

@ -28,6 +28,7 @@ import com.mob.utsmyanmar.ui.functions.FunctionsScreen
import com.mob.utsmyanmar.ui.history_functions.HistoryRoute
import com.mob.utsmyanmar.ui.history_functions.prepareReprintPayDetail
import com.mob.utsmyanmar.ui.input_amount.AmountRoute
import com.mob.utsmyanmar.ui.manual_entry.InputManualCardRoute
import com.mob.utsmyanmar.ui.password_input.InputPassword
import com.mob.utsmyanmar.ui.password_input.PasswordType
import com.mob.utsmyanmar.ui.pinpad.PinPadRoute
@ -554,7 +555,11 @@ fun AppNavGraph(
CardWaitingScreen(
viewModel = cardWaitingViewModel,
amount = formatAmountForDisplay(sharedViewModel.amount.value),
onManualEntry = {},
onManualEntry = {
navController.navigate(Routes.ManualEntry.route) {
launchSingleTop = true
}
},
onProcessingCard = {
navController.navigate(Routes.ProcessingCard.route) {
popUpTo(Routes.CardWaiting.route) {
@ -575,6 +580,28 @@ fun AppNavGraph(
)
}
composable(Routes.ManualEntry.route) {
val sharedViewModel: SharedViewModel = hiltViewModel(activity)
val transProcessViewModel: TransProcessViewModel = hiltViewModel(activity)
val pinPadViewModel: PinPadViewModel = hiltViewModel(activity)
InputManualCardRoute(
transProcessViewModel = transProcessViewModel,
sharedViewModel = sharedViewModel,
pinPadViewModel = pinPadViewModel,
onBack = { navController.popBackStack() },
onNavigatePinPad = {
pinPadViewModel.resetSessionState()
navController.navigate(Routes.PinPad.route) {
popUpTo(Routes.ManualEntry.route) {
inclusive = true
}
launchSingleTop = true
}
}
)
}
composable(Routes.ProcessingCard.route) {
val sharedViewModel: SharedViewModel = hiltViewModel(activity)
val cardReaderViewModel: CardReaderViewModel = hiltViewModel(activity)