From 7c0e9c4e66bda46d915e00d1fbbcc7b20f216404 Mon Sep 17 00:00:00 2001 From: kizzy Date: Wed, 8 Jul 2026 13:59:26 +0700 Subject: [PATCH] emv first upload --- .../utsmyanmar/ui/emv/EmvTransactionRoute.kt | 248 ++++++++++++++++++ .../utsmyanmar/ui/emv/EmvTransactionScreen.kt | 248 ++++++++++++++++++ .../utsmyanmar/ui/emv/EmvTransactionState.kt | 15 ++ .../utsmyanmar/ui/navigation/AppNavGraph.kt | 120 ++++++++- .../mob/utsmyanmar/ui/navigation/Routes.kt | 5 + .../ui/print_receipt/PrintReceiptScreen.kt | 182 +++++++++---- .../ui/processing_card/ProcessingCardRoute.kt | 2 + .../ui/sending_to_host/SendingToHostRoute.kt | 163 ++++++++---- 8 files changed, 888 insertions(+), 95 deletions(-) create mode 100644 app/src/main/java/com/mob/utsmyanmar/ui/emv/EmvTransactionRoute.kt create mode 100644 app/src/main/java/com/mob/utsmyanmar/ui/emv/EmvTransactionScreen.kt create mode 100644 app/src/main/java/com/mob/utsmyanmar/ui/emv/EmvTransactionState.kt diff --git a/app/src/main/java/com/mob/utsmyanmar/ui/emv/EmvTransactionRoute.kt b/app/src/main/java/com/mob/utsmyanmar/ui/emv/EmvTransactionRoute.kt new file mode 100644 index 0000000..9b021ac --- /dev/null +++ b/app/src/main/java/com/mob/utsmyanmar/ui/emv/EmvTransactionRoute.kt @@ -0,0 +1,248 @@ +package com.mob.utsmyanmar.ui.emv + +import android.text.TextUtils +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.livedata.observeAsState +import com.mob.utsmyanmar.viewmodel.EmvTransactionProcessViewModel +import com.mob.utsmyanmar.viewmodel.SharedViewModel +import com.utsmyanmar.baselib.util.enums.EmvResultStatus +import com.utsmyanmar.baselib.util.enums.PinUtil +import com.utsmyanmar.baselib.ui.CustomPinPadKeyboard +import com.utsmyanmar.checkxread.util.CardTypeX +import com.utsmyanmar.paylibs.utils.POSUtil +import com.utsmyanmar.paylibs.utils.iso_utils.TransactionsType +import sunmi.sunmiui.utils.LogUtil + +private const val TAG = "EmvTransactionRoute" +private const val MAX_PIN_RETRY = 3 + +@Composable +fun EmvTransactionRoute( + emvViewModel: EmvTransactionProcessViewModel, + sharedViewModel: SharedViewModel, + onNavigateProcessing: () -> Unit, + onNavigateTransactionResult: () -> Unit, + onNavigateInputRrn: () -> Unit, + onNavigateCardWaiting: () -> Unit, + onNavigateError: (String) -> Unit, + onNavigateMain: () -> Unit +) { + val emvResultStatus by emvViewModel.emvResultStatus.observeAsState() + val candiList by emvViewModel.candiList.observeAsState() + val cardNum by emvViewModel.cardNum.observeAsState("") + val expDate by emvViewModel.expDate.observeAsState("") + val emvCardType by emvViewModel.emvCardType.observeAsState("") + val pinText by emvViewModel.pinText.observeAsState("") + val pinRemaining by emvViewModel.pinRemainingCount.observeAsState("") + val pinMode by emvViewModel.emvPinMode.observeAsState("") + val pinPadVisibility by emvViewModel.pinPadVisibility.observeAsState(8) + val errorCodeMsg by emvViewModel.errorCodeMsg.observeAsState() + + var loadingMessage by remember { mutableStateOf(null) } + var pinRetryCount by remember { mutableIntStateOf(MAX_PIN_RETRY) } + + val canGoBack = sharedViewModel.transactionsType.value != TransactionsType.SALE + + fun goToMain() { + emvViewModel.onCancelEmvTransaction() + onNavigateMain() + } + + BackHandler(enabled = canGoBack) { + goToMain() + } + + fun startEmvProcess(keyboard: CustomPinPadKeyboard) { + emvViewModel.customPinPadKeyboard = keyboard + + if (sharedViewModel.transactionsType.value == TransactionsType.PIN_CHANGE) { + emvViewModel.pinUtil.postValue(PinUtil.OLD_PIN) + } + + val tradeData = emvViewModel.getTradeData() + if (tradeData == null) { + onNavigateError("Please try again") + return + } + + val payDetail = tradeData.payDetail + val cardType = payDetail.cardType + + payDetail.amount = POSUtil.getInstance().convertAmount(sharedViewModel.amount.value) + + val processCode = sharedViewModel.processCode.value + if (!TextUtils.equals(processCode, "")) { + payDetail.processCode = processCode + } + + if (cardType != CardTypeX.MANUAL.value) { + emvViewModel.startProcess() + } else { + onNavigateProcessing() + } + } + + LaunchedEffect(emvResultStatus) { + val status = emvResultStatus ?: return@LaunchedEffect + LogUtil.d(TAG, "EMV Result Status : $status") + + when (status) { + EmvResultStatus.SELECT_APP -> { + // Candidate list dialog is driven by candiList observer. + } + + EmvResultStatus.READ_CARD -> { + sharedViewModel.setIsFallback(true) + onNavigateCardWaiting() + } + + EmvResultStatus.ON_NEXT_SCREEN -> onNavigateInputRrn() + + EmvResultStatus.READ_CARD_RETRY, + EmvResultStatus.READ_CARD_INTERFACE_NOT_FOUND, + EmvResultStatus.READ_CARD_NO_APP, + EmvResultStatus.READ_CARD_REFER_INSTRUCTION -> { + onNavigateCardWaiting() + } + + EmvResultStatus.TRY_AGAIN -> { + sharedViewModel.set_errorFragmentMsg("Card Not Supported!") + onNavigateError("Card Not Supported!") + } + + EmvResultStatus.OFFLINE_DECLINE -> { + sharedViewModel.set_errorFragmentMsg("Offline Denial") + onNavigateError("Offline Denial") + } + + EmvResultStatus.CONFIRM_CODE_VERIFY -> onNavigateCardWaiting() + + EmvResultStatus.SECONDARY -> { + loadingMessage = "Sending secondary....." + } + + EmvResultStatus.PIN_MISMATCH -> { + sharedViewModel.set_errorFragmentMsg("PIN MISMATCH!") + onNavigateError("PIN MISMATCH!") + } + + EmvResultStatus.PIN_EMPTY -> { + pinRetryCount-- + if (pinRetryCount > 0) { + emvViewModel.pinRemainingCount.value = "$pinRetryCount LEFT" + emvViewModel.startPinProcess() + } else { + emvViewModel.setPinPadCancel() + LogUtil.d(TAG, "Pin Try limit reached") + } + } + + EmvResultStatus.SUCCESS -> onNavigateProcessing() + + EmvResultStatus.SUCCESS_ONLINE, + EmvResultStatus.FAIL_ONLINE -> { + sharedViewModel.payDetail.value = emvViewModel.payDetailResult.value + loadingMessage = null + onNavigateTransactionResult() + } + + EmvResultStatus.REVERSAL_PROCESS -> { + loadingMessage = "Sending reversal....." + } + + EmvResultStatus.REVERSAL_SECONDARY -> { + loadingMessage = "Sending reversal secondary..." + } + + EmvResultStatus.REVERSAL_FAIL -> { + sharedViewModel.set_errorFragmentMsg("Reversal failed!") + onNavigateError("Reversal failed!") + } + + EmvResultStatus.REVERSAL_SUCCESS -> { + sharedViewModel.set_successFragmentMsg("Reversal success!") + onNavigateMain() + } + + EmvResultStatus.FAIL -> { + onNavigateError("Transaction failed") + } + + EmvResultStatus.NETWORK_ERROR -> { + onNavigateError("Com Error!") + } + + EmvResultStatus.ERROR -> { + loadingMessage = null + val mapped = mapEmvError(errorCodeMsg) + sharedViewModel.set_errorFragmentMsg(mapped) + onNavigateError(mapped) + } + + else -> LogUtil.d(TAG, "Unhandled EmvResult Status : $status") + } + } + + DisposableEffect(Unit) { + onDispose { + emvViewModel.resetTransactionStatus() + } + } + + val state = EmvTransactionState( + cardNumber = cardNum.orEmpty(), + expDate = expDate.orEmpty(), + cardScheme = emvCardType.orEmpty(), + amount = formatAmount(sharedViewModel.amount.value), + pinText = pinText.orEmpty(), + pinRemaining = pinRemaining.orEmpty(), + pinMode = pinMode.orEmpty(), + showPinPad = pinPadVisibility == 0, + loadingMessage = loadingMessage, + candidateList = candiList?.toList() + ) + + EmvTransactionScreen( + state = state, + onCandidateSelected = { index -> + emvViewModel.setEmvAppSelect(index) + emvViewModel.candiList.value = null + }, + onKeyboardReady = { keyboard -> + startEmvProcess(keyboard) + } + ) +} + +private fun mapEmvError(pair: String?): String { + if (pair.isNullOrEmpty()) { + return "Emv process timeout!" + } + + if (!pair.contains(":")) { + return pair + } + + val code = pair.substringBefore(":") + val msg = pair.substringAfter(":") + + return if (code == "-4100" || code == "-4125") { + "Card read failed!" + } else { + "$code\n$msg" + } +} + +private fun formatAmount(amount: String?): String { + val normalized = amount.orEmpty() + val value = normalized.toLongOrNull() ?: return normalized.ifBlank { "0" } + return "%,d".format(value) +} diff --git a/app/src/main/java/com/mob/utsmyanmar/ui/emv/EmvTransactionScreen.kt b/app/src/main/java/com/mob/utsmyanmar/ui/emv/EmvTransactionScreen.kt new file mode 100644 index 0000000..cf7b6e4 --- /dev/null +++ b/app/src/main/java/com/mob/utsmyanmar/ui/emv/EmvTransactionScreen.kt @@ -0,0 +1,248 @@ +package com.mob.utsmyanmar.ui.emv + +import androidx.compose.foundation.background +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 +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.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import com.mob.utsmyanmar.ui.theme.Black +import com.mob.utsmyanmar.ui.theme.Primary +import com.mob.utsmyanmar.ui.theme.White +import com.utsmyanmar.baselib.ui.CustomPinPadKeyboard + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EmvTransactionScreen( + state: EmvTransactionState, + onCandidateSelected: (Int) -> Unit, + onKeyboardReady: (CustomPinPadKeyboard) -> Unit +) { + var keyboardView by remember { mutableStateOf(null) } + var isStarted by remember { mutableStateOf(false) } + + LaunchedEffect(keyboardView) { + val keyboard = keyboardView ?: return@LaunchedEffect + if (!isStarted) { + isStarted = true + onKeyboardReady(keyboard) + } + } + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = "CARD TRANSACTION", + color = White, + style = MaterialTheme.typography.titleMedium + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Primary + ) + ) + }, + containerColor = White + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .background(White) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CardInfoPanel(state) + + Spacer(modifier = Modifier.height(20.dp)) + + if (state.loadingMessage != null) { + CircularProgressIndicator(color = Primary) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = state.loadingMessage, + color = Black, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + } else if (state.showPinPad) { + PinEntryPanel(state) + } else { + Text( + text = state.statusMessage, + color = Black, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // The Sunmi SDK draws its secure keypad on top of this view. It is + // always present in the tree but only meaningful during PIN entry. + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + AndroidView( + factory = { context -> + CustomPinPadKeyboard(context).also { + keyboardView = it + } + } + ) + } + } + } + + val candidates = state.candidateList + if (candidates != null) { + AlertDialog( + onDismissRequest = { }, + confirmButton = { }, + title = { + Text(text = "Select Application") + }, + text = { + Column { + candidates.forEachIndexed { index, name -> + Text( + text = name, + color = Black, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .clickable { onCandidateSelected(index) } + .padding(vertical = 14.dp) + ) + if (index != candidates.lastIndex) { + HorizontalDivider() + } + } + } + } + ) + } +} + +@Composable +private fun CardInfoPanel(state: EmvTransactionState) { + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = Primary, + shape = RoundedCornerShape(24.dp) + ) + .padding(horizontal = 24.dp, vertical = 24.dp) + ) { + Column { + if (state.cardScheme.isNotBlank()) { + Text( + text = state.cardScheme, + color = White, + style = MaterialTheme.typography.labelLarge + ) + Spacer(modifier = Modifier.height(10.dp)) + } + + Text( + text = state.cardNumber.ifBlank { "**** **** **** ****" }, + color = White, + style = MaterialTheme.typography.headlineSmall, + letterSpacing = 2.sp + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = "EXPIRY", + color = White, + style = MaterialTheme.typography.labelSmall + ) + Text( + text = state.expDate.ifBlank { "--/--" }, + color = White, + style = MaterialTheme.typography.bodyLarge + ) + } + + Column(horizontalAlignment = Alignment.End) { + Text( + text = "AMOUNT", + color = White, + style = MaterialTheme.typography.labelSmall + ) + Text( + text = state.amount.ifBlank { "0" }, + color = White, + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + } +} + +@Composable +private fun PinEntryPanel(state: EmvTransactionState) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "${state.pinMode}Please Input PIN".trim(), + color = Black, + style = MaterialTheme.typography.titleMedium + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = if (state.pinText.isBlank()) "------" else state.pinText, + color = Primary, + style = MaterialTheme.typography.displaySmall, + letterSpacing = 4.sp + ) + + if (state.pinRemaining.isNotBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = state.pinRemaining, + color = Black, + style = MaterialTheme.typography.bodyMedium + ) + } + } +} diff --git a/app/src/main/java/com/mob/utsmyanmar/ui/emv/EmvTransactionState.kt b/app/src/main/java/com/mob/utsmyanmar/ui/emv/EmvTransactionState.kt new file mode 100644 index 0000000..e54c85f --- /dev/null +++ b/app/src/main/java/com/mob/utsmyanmar/ui/emv/EmvTransactionState.kt @@ -0,0 +1,15 @@ +package com.mob.utsmyanmar.ui.emv + +data class EmvTransactionState( + val cardNumber: String = "", + val expDate: String = "", + val cardScheme: String = "", + val amount: String = "", + val pinText: String = "", + val pinRemaining: String = "", + val pinMode: String = "", + val showPinPad: Boolean = false, + val statusMessage: String = "Processing card, please wait...", + val loadingMessage: String? = null, + val candidateList: List? = null +) diff --git a/app/src/main/java/com/mob/utsmyanmar/ui/navigation/AppNavGraph.kt b/app/src/main/java/com/mob/utsmyanmar/ui/navigation/AppNavGraph.kt index 8e6a986..8cb7cdd 100644 --- a/app/src/main/java/com/mob/utsmyanmar/ui/navigation/AppNavGraph.kt +++ b/app/src/main/java/com/mob/utsmyanmar/ui/navigation/AppNavGraph.kt @@ -23,6 +23,8 @@ import com.mob.utsmyanmar.ui.dashboard.DashboardScreen2 import com.mob.utsmyanmar.ui.dashboard.DashboardViewModel import com.mob.utsmyanmar.ui.dashboard.SeeMoreScreen import com.mob.utsmyanmar.ui.device_info.DeviceInfoViewModel +import com.mob.utsmyanmar.ui.emv.EmvTransactionRoute +import com.mob.utsmyanmar.ui.error.TransactionErrorScreen import com.mob.utsmyanmar.ui.functions.FunctionsScreen import com.mob.utsmyanmar.ui.history_functions.HistoryRoute import com.mob.utsmyanmar.ui.input_amount.AmountRoute @@ -574,9 +576,30 @@ fun AppNavGraph( } }, onNavigateInputAmount = { navController.popBackStack(Routes.Amount.route, false) }, - onNavigateProcessing = {}, - onNavigateEmvTransaction = {}, - onNavigateError = {}, + onNavigateProcessing = { + navController.navigate(Routes.Processing.route) { + popUpTo(Routes.ProcessingCard.route) { + inclusive = true + } + launchSingleTop = true + } + }, + onNavigateEmvTransaction = { + navController.navigate(Routes.EmvTransaction.route) { + popUpTo(Routes.ProcessingCard.route) { + inclusive = true + } + launchSingleTop = true + } + }, + onNavigateError = { message -> + navController.navigate(Routes.TransactionError.createRoute(message)) { + popUpTo(Routes.ProcessingCard.route) { + inclusive = true + } + launchSingleTop = true + } + }, onBack = { navController.popBackStack() }, onShowDecline = {} ) @@ -619,6 +642,64 @@ fun AppNavGraph( ) } + composable(Routes.EmvTransaction.route) { + val sharedViewModel: SharedViewModel = hiltViewModel(activity) + val emvTransactionViewModel: EmvTransactionProcessViewModel = hiltViewModel(activity) + + EmvTransactionRoute( + emvViewModel = emvTransactionViewModel, + sharedViewModel = sharedViewModel, + onNavigateProcessing = { + navController.navigate(Routes.Processing.route) { + popUpTo(Routes.EmvTransaction.route) { + inclusive = true + } + launchSingleTop = true + } + }, + onNavigateTransactionResult = { + navController.navigate(Routes.TransactionResult.route) { + popUpTo(Routes.EmvTransaction.route) { + inclusive = true + } + launchSingleTop = true + } + }, + onNavigateInputRrn = { + navController.navigate(Routes.InputRrn.route) { + popUpTo(Routes.EmvTransaction.route) { + inclusive = true + } + launchSingleTop = true + } + }, + onNavigateCardWaiting = { + navController.navigate(Routes.CardWaiting.route) { + popUpTo(Routes.EmvTransaction.route) { + inclusive = true + } + launchSingleTop = true + } + }, + onNavigateError = { message -> + navController.navigate(Routes.TransactionError.createRoute(message)) { + popUpTo(Routes.EmvTransaction.route) { + inclusive = true + } + launchSingleTop = true + } + }, + onNavigateMain = { + navController.navigate(Routes.Dashboard.route) { + popUpTo(Routes.Dashboard.route) { + inclusive = false + } + launchSingleTop = true + } + } + ) + } + composable(Routes.InputRrn.route) { val sharedViewModel: SharedViewModel = hiltViewModel(activity) val rrnViewModel: RRNViewModel = hiltViewModel(activity) @@ -660,10 +741,12 @@ fun AppNavGraph( composable(Routes.Processing.route) { val sharedViewModel: SharedViewModel = hiltViewModel(activity) val transProcessViewModel: TransProcessViewModel = hiltViewModel(activity) + val emvTransactionViewModel: EmvTransactionProcessViewModel = hiltViewModel(activity) val settlementViewModel: SettlementViewModel = hiltViewModel(activity) ProcessingRoute( sharedViewModel = sharedViewModel, transProcessViewModel = transProcessViewModel, + emvTransactionViewModel = emvTransactionViewModel, settlementViewModel = settlementViewModel, onNavigateTransactionResult = { navController.navigate(Routes.TransactionResult.route) { @@ -681,6 +764,14 @@ fun AppNavGraph( launchSingleTop = true } }, + onNavigateError = { message -> + navController.navigate(Routes.TransactionError.createRoute(message)) { + popUpTo(Routes.Processing.route) { + inclusive = true + } + launchSingleTop = true + } + }, onNavigateMain = { navController.navigate(Routes.Dashboard.route) { popUpTo(Routes.Dashboard.route) { @@ -720,6 +811,29 @@ fun AppNavGraph( ) } + composable( + route = Routes.TransactionError.route, + arguments = listOf( + navArgument("message") { + type = NavType.StringType + } + ) + ) { backStackEntry -> + val message = backStackEntry.arguments?.getString("message").orEmpty().trim() + + TransactionErrorScreen( + message = message, + onDone = { + navController.navigate(Routes.Dashboard.route) { + popUpTo(Routes.Dashboard.route) { + inclusive = false + } + launchSingleTop = true + } + } + ) + } + composable(Routes.PrintReceipt.route) { val sharedViewModel: SharedViewModel = hiltViewModel(activity) val transResultViewModel: TransactionResultViewModel = hiltViewModel(activity) diff --git a/app/src/main/java/com/mob/utsmyanmar/ui/navigation/Routes.kt b/app/src/main/java/com/mob/utsmyanmar/ui/navigation/Routes.kt index 6ba9067..438c86a 100644 --- a/app/src/main/java/com/mob/utsmyanmar/ui/navigation/Routes.kt +++ b/app/src/main/java/com/mob/utsmyanmar/ui/navigation/Routes.kt @@ -24,12 +24,17 @@ sealed class Routes(val route: String) { data object CardWaiting : Routes("card_waiting") data object ProcessingCard : Routes("processing_card") data object PinPad : Routes("pin_pad") + data object EmvTransaction : Routes("emv_transaction") data object InputRrn : Routes("input_rrn") data object Processing : Routes("processing") data object ManualEntry : Routes("manual_entry") data object TransactionResult : Routes("transaction_result") + data object TransactionError : Routes("transaction_error/{message}") { + fun createRoute(message: String): String = + "transaction_error/${Uri.encode(message.ifBlank { " " })}" + } data object PrintReceipt : Routes("print_receipt") data object Version : Routes("version") data object Functions : Routes("functions") diff --git a/app/src/main/java/com/mob/utsmyanmar/ui/print_receipt/PrintReceiptScreen.kt b/app/src/main/java/com/mob/utsmyanmar/ui/print_receipt/PrintReceiptScreen.kt index a1ab21b..e53d531 100644 --- a/app/src/main/java/com/mob/utsmyanmar/ui/print_receipt/PrintReceiptScreen.kt +++ b/app/src/main/java/com/mob/utsmyanmar/ui/print_receipt/PrintReceiptScreen.kt @@ -1,9 +1,11 @@ package com.mob.utsmyanmar.ui.print_receipt -import android.util.Log -import androidx.activity.compose.BackHandler import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -13,19 +15,24 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -33,16 +40,20 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.mob.utsmyanmar.ui.components.appbar.AppBar import com.mob.utsmyanmar.ui.theme.Color -import com.mob.utsmyanmar.ui.transaction_result.TransactionResultEvent import com.mob.utsmyanmar.ui.transaction_result.TransactionResultState import com.mob.utsmyanmar.ui.transaction_result.TransactionResultViewModel import com.mob.utsmyanmar.viewmodel.SharedViewModel -import com.utsmyanmar.paylibs.print.PrintReceipt import com.utsmyanmar.paylibs.utils.POSUtil +import kotlin.math.abs import kotlinx.coroutines.delay import kotlinx.coroutines.launch +private const val PRINT_ANIMATION_DURATION_MS = 2300 +private const val RESET_ANIMATION_DURATION_MS = 350 +private const val PRINTED_HOLD_DELAY_MS = 500L +private const val OFFSCREEN_BUFFER_PX = 120f + @Composable fun PrintReceiptScreen( transactionResultViewModel: TransactionResultViewModel, @@ -51,12 +62,10 @@ fun PrintReceiptScreen( onDone: () -> Unit ) { val state by transactionResultViewModel.state.collectAsStateWithLifecycle() - val scope = rememberCoroutineScope(); - val receiptOffsetY = remember { - Animatable(0f); - } - val animationDuration = 3000; - + val scope = rememberCoroutineScope() + val receiptOffsetY = remember { Animatable(0f) } + var slotHeightPx by remember { mutableFloatStateOf(0f) } + var isPrinting by remember { mutableStateOf(false) } Scaffold( topBar = { @@ -73,15 +82,41 @@ fun PrintReceiptScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { - ReceiptPreview( - state = state, + Box( modifier = Modifier .weight(1f) - .verticalScroll(rememberScrollState()) - .graphicsLayer{ - translationY = receiptOffsetY.value - } - ) + .fillMaxWidth() + .onSizeChanged { slotHeightPx = it.height.toFloat() } + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clipToBounds() + ) { + ReceiptPreview( + state = state, + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .graphicsLayer { + translationY = receiptOffsetY.value + val travel = slotHeightPx + OFFSCREEN_BUFFER_PX + alpha = if (travel > 0f) { + 1f - (abs(receiptOffsetY.value) / travel).coerceIn(0f, 1f) + } else { + 1f + } + } + ) + } + + PrinterSlot( + isActive = isPrinting, + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + ) + } Spacer(modifier = Modifier.height(16.dp)) @@ -92,6 +127,7 @@ fun PrintReceiptScreen( OutlinedButton( onClick = onDone, + enabled = !isPrinting, modifier = Modifier .weight(1f) .height(50.dp), @@ -107,47 +143,55 @@ fun PrintReceiptScreen( modifier = Modifier .weight(1f) .height(50.dp), + enabled = !isPrinting, colors = ButtonDefaults.buttonColors( containerColor = Color.LegacyRed, contentColor = Color.White, disabledContainerColor = Color.LegacyRed.copy(alpha = 0.5f), - disabledContentColor = Color.White.copy(alpha = 0.5f), + disabledContentColor = Color.White.copy(alpha = 0.7f), ), shape = RoundedCornerShape(12.dp), onClick = { + if (isPrinting) return@Button + + isPrinting = true onPrint() -// try { -// PrintReceipt.getInstance().printNow() -// scope.launch { -// launch { -// receiptOffsetY.animateTo( -// targetValue = -1500f, -// animationSpec = tween( -// durationMillis = animationDuration, -// easing = FastOutSlowInEasing -// ) -// ) -// } -// delay(5000) -// launch { -// receiptOffsetY.animateTo( -// targetValue = 0f, -// animationSpec = tween( -// durationMillis = 0, -// easing = FastOutSlowInEasing -// ) -// ) -// } -// } -// } catch (e: Exception) { -// -// Log.d("PrintReceipt", "error with $e") -// -// } + + scope.launch { + val targetOffset = -(slotHeightPx + OFFSCREEN_BUFFER_PX) + + receiptOffsetY.animateTo( + targetValue = targetOffset, + animationSpec = tween( + durationMillis = PRINT_ANIMATION_DURATION_MS, + easing = FastOutSlowInEasing + ) + ) + + delay(PRINTED_HOLD_DELAY_MS) + + receiptOffsetY.animateTo( + targetValue = 0f, + animationSpec = tween( + durationMillis = RESET_ANIMATION_DURATION_MS, + easing = FastOutSlowInEasing + ) + ) + + isPrinting = false + } } ) { + if (isPrinting) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + color = Color.White, + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + } Text( - text = "Print", + text = if (isPrinting) "Printing..." else "Print", style = MaterialTheme.typography.titleMedium ) } @@ -157,6 +201,48 @@ fun PrintReceiptScreen( } } +/** + * Visual "exit slot" pinned to the top of the receipt preview. The receipt + * appears to feed upward through this slot and out of frame while printing, + * mimicking a physical thermal printer. + */ +@Composable +private fun PrinterSlot( + isActive: Boolean, + modifier: Modifier = Modifier +) { + val infiniteTransition = rememberInfiniteTransition(label = "printerSlotGlow") + val glow by infiniteTransition.animateFloat( + initialValue = 0.35f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 550, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "glow" + ) + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .padding(horizontal = 40.dp) + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(50)) + .background( + if (isActive) { + Color.LegacyRed.copy(alpha = glow) + } else { + Color.Gray.copy(alpha = 0.25f) + } + ) + ) + } +} + @Composable fun ReceiptPreview( diff --git a/app/src/main/java/com/mob/utsmyanmar/ui/processing_card/ProcessingCardRoute.kt b/app/src/main/java/com/mob/utsmyanmar/ui/processing_card/ProcessingCardRoute.kt index 67956db..70c74ad 100644 --- a/app/src/main/java/com/mob/utsmyanmar/ui/processing_card/ProcessingCardRoute.kt +++ b/app/src/main/java/com/mob/utsmyanmar/ui/processing_card/ProcessingCardRoute.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import sunmi.sunmiui.utils.LogUtil @Composable fun ProcessingCardRoute( @@ -24,6 +25,7 @@ fun ProcessingCardRoute( LaunchedEffect(Unit) { viewModel.uiEvent.collect { event -> + LogUtil.d("tag","Processing card : $event") when (event) { ProcessingCardUiEvent.NavigateToPinPad -> onNavigatePinPad() ProcessingCardUiEvent.NavigateToInputAmount -> onNavigateInputAmount() diff --git a/app/src/main/java/com/mob/utsmyanmar/ui/sending_to_host/SendingToHostRoute.kt b/app/src/main/java/com/mob/utsmyanmar/ui/sending_to_host/SendingToHostRoute.kt index 2b50ef7..3f99d39 100644 --- a/app/src/main/java/com/mob/utsmyanmar/ui/sending_to_host/SendingToHostRoute.kt +++ b/app/src/main/java/com/mob/utsmyanmar/ui/sending_to_host/SendingToHostRoute.kt @@ -3,24 +3,29 @@ package com.mob.utsmyanmar.ui.sending_to_host import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.livedata.observeAsState import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.mob.utsmyanmar.model.TransResultStatus import com.mob.utsmyanmar.model.TransactionStatus import com.mob.utsmyanmar.ui.settlement.SettlementViewModel +import com.mob.utsmyanmar.viewmodel.EmvTransactionProcessViewModel import com.mob.utsmyanmar.viewmodel.SharedViewModel import com.mob.utsmyanmar.viewmodel.TransProcessViewModel import com.utsmyanmar.paylibs.model.enums.TransCVM import com.utsmyanmar.paylibs.utils.iso_utils.TransactionsType -private const val MOCK_HOST_DELAY_MS = 2000L - @Composable fun ProcessingRoute( sharedViewModel: SharedViewModel, transProcessViewModel: TransProcessViewModel, + emvTransactionViewModel: EmvTransactionProcessViewModel, settlementViewModel: SettlementViewModel, onNavigateTransactionResult: () -> Unit, onNavigateSignature: () -> Unit, + onNavigateError: (String) -> Unit, onNavigateMain: ()-> Unit ) { @@ -30,60 +35,130 @@ fun ProcessingRoute( val settlementStatus by settlementViewModel.uiState.collectAsStateWithLifecycle() - if(sharedViewModel.transactionsType.value == TransactionsType.SETTLEMENT) { - LaunchedEffect(Unit) { - settlementViewModel.startSettlementProcess() - } + val isEmv = sharedViewModel.isEmv.value == true + val isSettlement = sharedViewModel.transactionsType.value == TransactionsType.SETTLEMENT - LaunchedEffect(settlementStatus.status) { - when(settlementStatus.status) { - TransactionStatus.ON_ERROR, - TransactionStatus.ON_SUCCESS -> { - sharedViewModel.payDetail.value = settlementViewModel.getPayDetail() - onNavigateTransactionResult() - } - else -> {} + when { + isSettlement -> { + LaunchedEffect(Unit) { + settlementViewModel.startSettlementProcess() } - } - } else { - LaunchedEffect(Unit) { -// transProcessViewModel.resetTransactionStatus() - transProcessViewModel.startOnlineProcess() - } - LaunchedEffect(transStatus) { - when(transStatus) { - TransResultStatus.SUCCESS -> { - sharedViewModel.payDetail.value = transProcessViewModel.payDetailResult.value - if (requiresSignature(sharedViewModel.payDetail.value)) { - onNavigateSignature() - } else { + LaunchedEffect(settlementStatus.status) { + when (settlementStatus.status) { + TransactionStatus.ON_ERROR, + TransactionStatus.ON_SUCCESS -> { + sharedViewModel.payDetail.value = settlementViewModel.getPayDetail() onNavigateTransactionResult() } + else -> {} } - - TransResultStatus.FAIL -> { - sharedViewModel.payDetail.value = transProcessViewModel.payDetailResult.value - onNavigateTransactionResult() - } - TransResultStatus.REVERSAL_SUCCESS -> onNavigateMain() - TransResultStatus.REVERSAL_FAIL -> onNavigateMain() - TransResultStatus.ERROR -> onNavigateMain() - else -> {} } + + ProcessingScreen(settlementStatus.processingState) + } + + isEmv -> { + EmvProcessingContent( + sharedViewModel = sharedViewModel, + emvTransactionViewModel = emvTransactionViewModel, + onNavigateTransactionResult = onNavigateTransactionResult, + onNavigateError = onNavigateError, + onNavigateMain = onNavigateMain + ) + } + + else -> { + LaunchedEffect(Unit) { + transProcessViewModel.startOnlineProcess() + } + + LaunchedEffect(transStatus) { + when (transStatus) { + TransResultStatus.SUCCESS -> { + sharedViewModel.payDetail.value = transProcessViewModel.payDetailResult.value + if (requiresSignature(sharedViewModel.payDetail.value)) { + onNavigateSignature() + } else { + onNavigateTransactionResult() + } + } + + TransResultStatus.FAIL -> { + sharedViewModel.payDetail.value = transProcessViewModel.payDetailResult.value + onNavigateTransactionResult() + } + TransResultStatus.REVERSAL_SUCCESS -> onNavigateMain() + TransResultStatus.REVERSAL_FAIL -> onNavigateMain() + TransResultStatus.ERROR -> onNavigateMain() + else -> {} + } + } + + ProcessingScreen(state) + } + } +} + +@Composable +private fun EmvProcessingContent( + sharedViewModel: SharedViewModel, + emvTransactionViewModel: EmvTransactionProcessViewModel, + onNavigateTransactionResult: () -> Unit, + onNavigateError: (String) -> Unit, + onNavigateMain: () -> Unit +) { + val emvTransStatus by emvTransactionViewModel.getTransStatus().observeAsState() + + var title by remember { mutableStateOf("Sending...") } + + LaunchedEffect(Unit) { + emvTransactionViewModel.startOnlineProcess() + } + + LaunchedEffect(emvTransStatus) { + when (emvTransStatus) { + TransResultStatus.SUCCESS, + TransResultStatus.FAIL, + TransResultStatus.OFFLINE_SUCCESS -> { + sharedViewModel.payDetail.value = emvTransactionViewModel.payDetailResult.value + onNavigateTransactionResult() + } + + TransResultStatus.SECONDARY -> title = "Sending Secondary..." + TransResultStatus.REVERSAL_PREPARE -> title = "Preparing Reversal..." + TransResultStatus.REVERSAL_PROCESS -> title = "Sending Reversal..." + TransResultStatus.REVERSAL_SECONDARY -> title = "Sending Secondary Reversal..." + TransResultStatus.REVERSAL_THIRD -> title = "Sending Third Reversal..." + + TransResultStatus.REVERSAL_SUCCESS -> { + sharedViewModel.payDetail.value = emvTransactionViewModel.payDetailResult.value + sharedViewModel.set_successFragmentMsg("Reversal success!") + onNavigateMain() + } + + TransResultStatus.REVERSAL_FAIL -> { + sharedViewModel.payDetail.value = emvTransactionViewModel.payDetailResult.value + sharedViewModel.set_errorFragmentMsg("Reversal failed!") + onNavigateError("Reversal failed!") + } + + TransResultStatus.ERROR, + TransResultStatus.NETWORK_ERROR -> { + val message = emvTransactionViewModel.errorCodeMsg.value + ?.substringAfter(":") + ?.takeIf { it.isNotBlank() } + ?: "Transaction error" + onNavigateError(message) + } + + else -> {} } } - if(sharedViewModel.transactionsType.value == TransactionsType.SETTLEMENT) { - ProcessingScreen(settlementStatus.processingState) - } else { - ProcessingScreen(state) - } - - + ProcessingScreen(ProcessingState(title = title)) } private fun requiresSignature(payDetail: com.utsmyanmar.paylibs.model.PayDetail?): Boolean { return payDetail?.transCVM == TransCVM.SIGNATURE && !payDetail.isFreeSign -// return true; }