diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt index d5c4941..778796e 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt @@ -857,13 +857,20 @@ class TransferFragment : Fragment() { message: String? = null, customView: android.view.View? = null, biometricSubtitle: String, - onConfirmed: () -> Unit + onConfirmed: (AlertDialog, android.widget.FrameLayout) -> Unit ) { + val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager + imm.hideSoftInputFromWindow(requireView().windowToken, 0) + + val frame = android.widget.FrameLayout(requireContext()) + if (customView != null) frame.addView(customView) + val builder = MaterialAlertDialogBuilder(requireContext()) .setTitle(title) - .setPositiveButton(R.string.transfer_confirm) { _, _ -> onConfirmed() } - .setNegativeButton(android.R.string.cancel, null) - if (customView != null) builder.setView(customView) else builder.setMessage(message) + .setPositiveButton(R.string.transfer_confirm, null) + .setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() } + .setCancelable(false) + if (customView != null) builder.setView(frame) else builder.setMessage(message) val dialog = builder.show() val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false) @@ -874,8 +881,7 @@ class TransferFragment : Fragment() { val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()), object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - dialog.dismiss() - onConfirmed() + onConfirmed(dialog, frame) } override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { if (errorCode != BiometricPrompt.ERROR_CANCELED && @@ -894,6 +900,10 @@ class TransferFragment : Fragment() { .build() ) } + } else { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + onConfirmed(dialog, frame) + } } } @@ -912,11 +922,27 @@ class TransferFragment : Fragment() { Toast.makeText(requireContext(), R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show() return } + val qrFromTypeLabel = AccountListParser.from(src)?.typeLabel + ?: BmlDashboardParser.productLabel(src.accountTypeName) + val qrFromDetail = listOfNotNull("BML", qrFromTypeLabel.ifBlank { null }).joinToString(" · ") + val qrConfirmView = buildTransferConfirmView( + amountCurrency = info.currency, + amountValue = "%.2f".format(amount), + fromName = src.accountBriefName, + fromNumber = src.accountNumber, + fromDetail = qrFromDetail, + toName = info.merchantName, + toNumber = "", + toDetail = info.merchantAddress.ifBlank { "BML Merchant" } + ) showConfirmWithBiometric( title = getString(R.string.transfer), - message = "Pay ${info.currency} ${"%.2f".format(amount)} to ${info.merchantName}?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}", + customView = qrConfirmView, biometricSubtitle = "${info.currency} ${"%.2f".format(amount)} → ${info.merchantName}", - onConfirmed = { executeBmlQrPayment(src, debitAccount, info, amount) } + onConfirmed = { dialog, frame -> + showProcessingInDialog(dialog, frame) + executeBmlQrPayment(src, debitAccount, info, amount, dialog, frame) + } ) return } @@ -971,18 +997,17 @@ class TransferFragment : Fragment() { val isUsdToMvr = currency.equals("USD", ignoreCase = true) && destCurrency.equals("MVR", ignoreCase = true) val isSrcCredit = src.profileType == "BML_CREDIT" - val mainMsg = "Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}" - - val doTransfer: () -> Unit = { + val doTransfer: (AlertDialog, android.widget.FrameLayout) -> Unit = { dialog, frame -> if (isBmlBusiness) { - // Business profile: async OTP channel selection flow + // Business profile: async OTP channel selection flow — dismiss dialog first + dialog.dismiss() startBmlBusinessOtpFlow( src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, isSrcCard, isDestMib, currency, allAccounts, allContacts, capturedToAvatar ) } else { + showProcessingInDialog(dialog, frame) binding.btnTransfer.isEnabled = false - (activity as? HomeActivity)?.setRefreshing(true) viewLifecycleOwner.lifecycleScope.launch { val (ok, msg, receipt) = withContext(Dispatchers.IO) { if (!isSrcBml) { @@ -992,14 +1017,15 @@ class TransferFragment : Fragment() { } } binding.btnTransfer.isEnabled = true - (activity as? HomeActivity)?.setRefreshing(false) if (ok && receipt != null) { ReceiptStore.save(requireContext(), receipt) clearForm() val activity = requireActivity() as HomeActivity activity.triggerRefresh() + dialog.dismiss() activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar)) } else if (!ok) { + dialog.dismiss() if (msg == "CONNECTIVITY") { (activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet)) } else { @@ -1010,56 +1036,202 @@ class TransferFragment : Fragment() { } } - val warningView: android.view.View? = if (isUsdToMvr || isSrcCredit) { - val ctx = requireContext() - val dp = resources.displayMetrics.density - LinearLayout(ctx).apply { - orientation = LinearLayout.VERTICAL - setPadding((24 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt(), 0) - addView(TextView(ctx).apply { text = mainMsg }) - if (isUsdToMvr) addView(TextView(ctx).apply { - text = "⚠ You are transferring from a USD account to an MVR account. The currency will be converted at the bank's rate and this cannot be reversed!" - setTextColor(Color.RED) - textSize = 16f - typeface = Typeface.DEFAULT_BOLD - setPadding(0, (16 * dp).toInt(), 0, 0) - }) - if (isSrcCredit) addView(TextView(ctx).apply { - text = "⚠ Transferring from a credit card is treated as a cash advance. Cash advance fees will be charged on the 10th of the month." - setTextColor(Color.RED) - textSize = 16f - typeface = Typeface.DEFAULT_BOLD - setPadding(0, (16 * dp).toInt(), 0, 0) - }) + val fromTypeLabel = AccountListParser.from(src)?.typeLabel + ?: if (src.bank == "BML") BmlDashboardParser.productLabel(src.accountTypeName) + else src.accountTypeName.ifBlank { src.profileType } + val fromBankLabel = when (src.bank) { + "BML" -> "BML" + "FAHIPAY" -> "Fahipay" + "MIB" -> "MIB" + else -> src.bank + } + val fromDetail = listOfNotNull(fromBankLabel.ifBlank { null }, fromTypeLabel.ifBlank { null }).joinToString(" · ") + + val toTypeLabel = resolvedToOwnAccount?.let { acc -> + AccountListParser.from(acc)?.typeLabel + ?: if (acc.bank == "BML") BmlDashboardParser.productLabel(acc.accountTypeName) + else acc.accountTypeName.ifBlank { acc.profileType } + } + val toBankLabel = resolvedToOwnAccount?.let { acc -> + when (acc.bank) { + "BML" -> "BML" + "FAHIPAY" -> "Fahipay" + "MIB" -> "MIB" + else -> acc.bank } - } else null + } ?: when { + bankNameCapture.equals("MALBMVMV", ignoreCase = true) -> "BML" + bankNameCapture.equals("MADVMVMV", ignoreCase = true) -> "MIB" + bankNameCapture.isNotBlank() -> bankNameCapture + isDestMib -> "MIB" + else -> when (selectedFahipayService) { + "RAASTAS" -> "Ooredoo · Raastas" + "OOREDOO_BILL" -> "Ooredoo · Bill Pay" + "DHIRAAGU_RELOAD" -> "Dhiraagu · Reload" + "DHIRAAGU_BILL" -> "Dhiraagu · Bill Pay" + else -> "" + } + } + val toDetail = listOfNotNull(toBankLabel.ifBlank { null }, toTypeLabel?.ifBlank { null }).joinToString(" · ") + + val warnings = buildList { + if (isUsdToMvr) add("⚠ You are transferring from a USD account to an MVR account. The currency will be converted at the bank's rate and this cannot be reversed!") + if (isSrcCredit) add("⚠ Transferring from a credit card is treated as a cash advance. Cash advance fees will be charged on the 10th of the month.") + } + val confirmView = buildTransferConfirmView( + amountCurrency = currency, + amountValue = "%.2f".format(amount), + fromName = src.accountBriefName, + fromNumber = src.accountNumber, + fromDetail = fromDetail, + toName = destDisplay, + toNumber = resolvedAccountNumber, + toDetail = toDetail, + warningTexts = warnings + ) showConfirmWithBiometric( title = getString(R.string.transfer), - message = if (warningView == null) mainMsg else null, - customView = warningView, - biometricSubtitle = "$currency $amountStr → $destDisplay", - onConfirmed = { doTransfer() } + customView = confirmView, + biometricSubtitle = "$currency ${"%.2f".format(amount)} → $destDisplay", + onConfirmed = { dialog, frame -> doTransfer(dialog, frame) } ) } + private fun buildTransferConfirmView( + amountCurrency: String, + amountValue: String, + fromName: String, + fromNumber: String, + fromDetail: String, + toName: String, + toNumber: String, + toDetail: String, + warningTexts: List = emptyList() + ): android.view.View { + val ctx = requireContext() + val dp = resources.displayMetrics.density + val colorOnSurface = com.google.android.material.color.MaterialColors.getColor( + requireView(), com.google.android.material.R.attr.colorOnSurface, Color.BLACK) + val colorMuted = com.google.android.material.color.MaterialColors.getColor( + requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY) + val colorPrimary = com.google.android.material.color.MaterialColors.getColor( + requireView(), com.google.android.material.R.attr.colorPrimary, Color.BLUE) + val MATCH = LinearLayout.LayoutParams.MATCH_PARENT + val WRAP = LinearLayout.LayoutParams.WRAP_CONTENT + + fun lp(w: Int = MATCH, h: Int = WRAP, init: LinearLayout.LayoutParams.() -> Unit = {}) = + LinearLayout.LayoutParams(w, h).apply(init) + + fun accountBlock(label: String, name: String, number: String, detail: String) = + LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_HORIZONTAL + layoutParams = lp() + addView(TextView(ctx).apply { + text = label + textSize = 10f + isAllCaps = true + letterSpacing = 0.12f + setTextColor(colorMuted) + gravity = Gravity.CENTER + }) + addView(TextView(ctx).apply { + text = name + textSize = 16f + setTypeface(null, Typeface.BOLD) + setTextColor(colorOnSurface) + gravity = Gravity.CENTER + layoutParams = lp { topMargin = (2 * dp).toInt() } + }) + if (number.isNotBlank()) addView(TextView(ctx).apply { + text = number + textSize = 13f + setTextColor(colorMuted) + gravity = Gravity.CENTER + }) + if (detail.isNotBlank()) addView(TextView(ctx).apply { + text = detail + textSize = 12f + setTextColor(colorMuted) + gravity = Gravity.CENTER + alpha = 0.75f + }) + } + + return LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_HORIZONTAL + setPadding((20 * dp).toInt(), (8 * dp).toInt(), (20 * dp).toInt(), (8 * dp).toInt()) + + // Currency + amount on same line, centered, baseline-aligned + addView(LinearLayout(ctx).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_HORIZONTAL + layoutParams = lp { bottomMargin = (20 * dp).toInt() } + addView(TextView(ctx).apply { + text = "$amountCurrency " + textSize = 16f + setTextColor(colorMuted) + layoutParams = LinearLayout.LayoutParams(WRAP, WRAP) + }) + addView(TextView(ctx).apply { + text = amountValue + textSize = 34f + setTypeface(null, Typeface.BOLD) + setTextColor(colorPrimary) + }) + }) + + addView(accountBlock("From", fromName, fromNumber, fromDetail)) + + // Down arrow — centered + addView(ImageView(ctx).apply { + setImageResource(R.drawable.ic_arrow_right) + rotation = 90f + setColorFilter(colorMuted) + layoutParams = lp(WRAP, WRAP) { + gravity = Gravity.CENTER_HORIZONTAL + width = (24 * dp).toInt() + height = (24 * dp).toInt() + topMargin = (12 * dp).toInt() + bottomMargin = (12 * dp).toInt() + } + }) + + addView(accountBlock("To", toName, toNumber, toDetail)) + + for (warning in warningTexts) { + addView(TextView(ctx).apply { + text = warning + setTextColor(Color.RED) + textSize = 14f + setTypeface(null, Typeface.BOLD) + layoutParams = lp { topMargin = (16 * dp).toInt() } + }) + } + } + } + private fun executeBmlQrPayment( src: BankAccount, debitAccount: String, info: BmlQrPayInfo, - amount: Double + amount: Double, + dialog: AlertDialog, + frame: android.widget.FrameLayout ) { val app = requireActivity().application as BasedBankApp val loginId = src.loginTag.removePrefix("bml_") val session = bmlSessionFor(src) ?: run { + dialog.dismiss() Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() return } val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed ?.let { Totp.generate(it) } - ?: run { Toast.makeText(requireContext(), "OTP unavailable", Toast.LENGTH_SHORT).show(); return } + ?: run { dialog.dismiss(); Toast.makeText(requireContext(), "OTP unavailable", Toast.LENGTH_SHORT).show(); return } binding.btnTransfer.isEnabled = false - (activity as? HomeActivity)?.setRefreshing(true) viewLifecycleOwner.lifecycleScope.launch { val result = withContext(Dispatchers.IO) { @@ -1080,75 +1252,174 @@ class TransferFragment : Fragment() { sh.sar.basedbank.api.bml.BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed") } } - (activity as? HomeActivity)?.setRefreshing(false) if (_binding == null) return@launch if (result == null) { + dialog.dismiss() binding.btnTransfer.isEnabled = true Toast.makeText(requireContext(), "Failed to initiate payment", Toast.LENGTH_LONG).show() return@launch } if (result.success) { - showBmlQrSuccessDialog( - merchant = result.merchant.ifBlank { info.merchantName }, - amount = result.amount.ifBlank { "%.2f".format(amount) }, - currency = result.currency.ifBlank { info.currency } - ) + showSuccessInDialog( + dialog, frame, + amountCurrency = result.currency.ifBlank { info.currency }, + amountValue = result.amount.ifBlank { "%.2f".format(amount) }, + fromName = src.accountBriefName, + toName = result.merchant.ifBlank { info.merchantName } + ) { + clearForm() + (activity as? HomeActivity)?.triggerRefresh() + } } else { + dialog.dismiss() binding.btnTransfer.isEnabled = true Toast.makeText(requireContext(), result.errorMessage, Toast.LENGTH_LONG).show() } } } - private fun showBmlQrSuccessDialog(merchant: String, amount: String, currency: String) { + private fun showProcessingInDialog(dialog: AlertDialog, frame: android.widget.FrameLayout) { + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE + dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE + dialog.setCancelable(false) val ctx = requireContext() val dp = resources.displayMetrics.density - val container = android.widget.LinearLayout(ctx).apply { - orientation = android.widget.LinearLayout.VERTICAL - gravity = android.view.Gravity.CENTER_HORIZONTAL - setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt()) + val spinner = CircularProgressDrawable(ctx).apply { + setStyle(CircularProgressDrawable.LARGE) + setColorSchemeColors(com.google.android.material.color.MaterialColors.getColor( + requireView(), com.google.android.material.R.attr.colorPrimary, Color.GRAY)) + start() } - container.addView(android.widget.ImageView(ctx).apply { - setImageResource(R.drawable.ic_check_circle) - setColorFilter(android.graphics.Color.parseColor("#4CAF50")) - layoutParams = android.widget.LinearLayout.LayoutParams( - (64 * dp).toInt(), (64 * dp).toInt() - ).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL; bottomMargin = (16 * dp).toInt() } + frame.removeAllViews() + frame.addView(LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_HORIZONTAL + setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt()) + addView(ImageView(ctx).apply { + setImageDrawable(spinner) + layoutParams = LinearLayout.LayoutParams((48 * dp).toInt(), (48 * dp).toInt()).apply { + gravity = Gravity.CENTER_HORIZONTAL + bottomMargin = (12 * dp).toInt() + } + }) + addView(TextView(ctx).apply { + text = "Processing..." + textSize = 16f + gravity = Gravity.CENTER + setTextColor(com.google.android.material.color.MaterialColors.getColor( + requireView(), com.google.android.material.R.attr.colorOnSurface, Color.BLACK)) + }) }) - container.addView(android.widget.TextView(ctx).apply { - text = "$currency $amount" - textSize = 28f - setTypeface(null, android.graphics.Typeface.BOLD) - setTextColor(com.google.android.material.color.MaterialColors.getColor( - requireView(), com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK)) - gravity = android.view.Gravity.CENTER - layoutParams = android.widget.LinearLayout.LayoutParams( - android.widget.LinearLayout.LayoutParams.WRAP_CONTENT, - android.widget.LinearLayout.LayoutParams.WRAP_CONTENT - ).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL; bottomMargin = (8 * dp).toInt() } - }) - container.addView(android.widget.TextView(ctx).apply { - text = merchant - textSize = 14f - setTextColor(com.google.android.material.color.MaterialColors.getColor( - requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, android.graphics.Color.GRAY)) - gravity = android.view.Gravity.CENTER - layoutParams = android.widget.LinearLayout.LayoutParams( - android.widget.LinearLayout.LayoutParams.WRAP_CONTENT, - android.widget.LinearLayout.LayoutParams.WRAP_CONTENT - ).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL } - }) - MaterialAlertDialogBuilder(ctx) - .setTitle(R.string.bml_qr_payment_success) - .setView(container) - .setPositiveButton(android.R.string.ok) { _, _ -> - requireActivity().onBackPressedDispatcher.onBackPressed() - } - .setCancelable(false) - .show() } + private fun showSuccessInDialog( + dialog: AlertDialog, + frame: android.widget.FrameLayout, + amountCurrency: String, + amountValue: String, + fromName: String, + toName: String, + onDone: () -> Unit + ) { + val ctx = requireContext() + val dp = resources.displayMetrics.density + val colorOnSurface = com.google.android.material.color.MaterialColors.getColor( + requireView(), com.google.android.material.R.attr.colorOnSurface, Color.BLACK) + val colorMuted = com.google.android.material.color.MaterialColors.getColor( + requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY) + val colorPrimary = com.google.android.material.color.MaterialColors.getColor( + requireView(), com.google.android.material.R.attr.colorPrimary, Color.BLUE) + val MATCH = LinearLayout.LayoutParams.MATCH_PARENT + val WRAP = LinearLayout.LayoutParams.WRAP_CONTENT + + frame.removeAllViews() + frame.addView(LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_HORIZONTAL + setPadding((24 * dp).toInt(), (20 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt()) + + // Checkmark + addView(ImageView(ctx).apply { + setImageResource(R.drawable.ic_check_circle) + setColorFilter(Color.parseColor("#4CAF50")) + layoutParams = LinearLayout.LayoutParams((64 * dp).toInt(), (64 * dp).toInt()).apply { + gravity = Gravity.CENTER_HORIZONTAL + bottomMargin = (16 * dp).toInt() + } + }) + + // Currency + amount + addView(LinearLayout(ctx).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_HORIZONTAL + layoutParams = LinearLayout.LayoutParams(MATCH, WRAP).apply { + bottomMargin = (16 * dp).toInt() + } + addView(TextView(ctx).apply { + text = "$amountCurrency " + textSize = 16f + setTextColor(colorMuted) + layoutParams = LinearLayout.LayoutParams(WRAP, WRAP) + }) + addView(TextView(ctx).apply { + text = amountValue + textSize = 28f + setTypeface(null, Typeface.BOLD) + setTextColor(colorPrimary) + }) + }) + + // From row + addView(LinearLayout(ctx).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_HORIZONTAL + layoutParams = LinearLayout.LayoutParams(MATCH, WRAP) + addView(TextView(ctx).apply { + text = "From " + textSize = 12f + setTextColor(colorMuted) + layoutParams = LinearLayout.LayoutParams(WRAP, WRAP) + }) + addView(TextView(ctx).apply { + text = fromName + textSize = 13f + setTypeface(null, Typeface.BOLD) + setTextColor(colorOnSurface) + gravity = Gravity.CENTER + }) + }) + + // To row + addView(LinearLayout(ctx).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_HORIZONTAL + layoutParams = LinearLayout.LayoutParams(MATCH, WRAP).apply { + topMargin = (4 * dp).toInt() + } + addView(TextView(ctx).apply { + text = "To " + textSize = 12f + setTextColor(colorMuted) + layoutParams = LinearLayout.LayoutParams(WRAP, WRAP) + }) + addView(TextView(ctx).apply { + text = toName + textSize = 13f + setTypeface(null, Typeface.BOLD) + setTextColor(colorOnSurface) + gravity = Gravity.CENTER + }) + }) + }) + + val okBtn = dialog.getButton(AlertDialog.BUTTON_POSITIVE) + okBtn?.visibility = View.VISIBLE + okBtn?.text = "OK" + okBtn?.setOnClickListener { dialog.dismiss(); onDone() } + } + + private fun doMibTransfer( src: BankAccount, destAccount: String, @@ -1718,6 +1989,7 @@ class TransferFragment : Fragment() { requireActivity().title = getString(R.string.transfer) } + override fun onDestroyView() { super.onDestroyView() _binding = null