forked from shihaam/thijooree
added support for static QR payments from BML cards
This commit is contained in:
Generated
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<component name="deploymentTargetSelector">
|
<component name="deploymentTargetSelector">
|
||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DIALOG" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2026-05-22T00:11:32.873305232Z">
|
<DropdownSelection timestamp="2026-05-22T00:11:32.873305232Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
|
|||||||
@@ -66,6 +66,22 @@ data class BmlLoanDetail(
|
|||||||
val overdueAmount: Double
|
val overdueAmount: Double
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class BmlQrPayInfo(
|
||||||
|
val requestId: String, // base64-encoded full QR URL (trxn_hash)
|
||||||
|
val merchantName: String, // narrative1
|
||||||
|
val merchantAddress: String, // narrative2 + narrative3
|
||||||
|
val amount: Double, // 0.0 for static QR
|
||||||
|
val currency: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BmlQrPayResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val merchant: String = "",
|
||||||
|
val amount: String = "",
|
||||||
|
val currency: String = "",
|
||||||
|
val errorMessage: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
data class BmlForeignLimit(
|
data class BmlForeignLimit(
|
||||||
val type: String,
|
val type: String,
|
||||||
val used: Double,
|
val used: Double,
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class BmlQrPayClient {
|
||||||
|
|
||||||
|
private val client = newBmlApiClient()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a BML QR URL to merchant details.
|
||||||
|
* [base64Url] is the full QR URL Base64-encoded (standard, with padding).
|
||||||
|
*/
|
||||||
|
fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo {
|
||||||
|
val request = bmlApiRequest(session,
|
||||||
|
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url")
|
||||||
|
return client.newCall(request).execute().use { response ->
|
||||||
|
val body = response.body?.string() ?: throw Exception("No response")
|
||||||
|
val json = JSONObject(body)
|
||||||
|
if (!json.optBoolean("success"))
|
||||||
|
throw Exception(json.optString("message").ifBlank { "Lookup failed" })
|
||||||
|
val payload = json.getJSONObject("payload")
|
||||||
|
val addr2 = payload.optString("narrative2").trim()
|
||||||
|
val addr3 = payload.optString("narrative3").trim()
|
||||||
|
val address = listOf(addr2, addr3).filter { it.isNotBlank() }.joinToString(", ")
|
||||||
|
BmlQrPayInfo(
|
||||||
|
requestId = payload.optString("trxn_hash"),
|
||||||
|
merchantName = payload.optString("narrative1").trim(),
|
||||||
|
merchantAddress = address,
|
||||||
|
amount = payload.optString("amount").toDoubleOrNull() ?: 0.0,
|
||||||
|
currency = payload.optString("currency").ifBlank { "MVR" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1 — initiate: POST with channel but no OTP.
|
||||||
|
* Returns true when server responds with code 22 (OTP generated).
|
||||||
|
*/
|
||||||
|
fun initiatePayment(
|
||||||
|
session: BmlSession,
|
||||||
|
debitAccount: String,
|
||||||
|
requestId: String,
|
||||||
|
amount: Double,
|
||||||
|
currency: String
|
||||||
|
): Boolean {
|
||||||
|
val jo = JSONObject().apply {
|
||||||
|
put("action", "approve")
|
||||||
|
put("debitAccount", debitAccount)
|
||||||
|
put("requestId", requestId)
|
||||||
|
put("amount", amount)
|
||||||
|
put("currency", currency)
|
||||||
|
put("channel", "token")
|
||||||
|
}
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
|
||||||
|
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
.header("User-Agent", BML_USER_AGENT)
|
||||||
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.header("accept", "application/json")
|
||||||
|
.build()
|
||||||
|
return client.newCall(request).execute().use { response ->
|
||||||
|
val body = response.body?.string() ?: return@use false
|
||||||
|
val json = try { JSONObject(body) } catch (_: Exception) { return@use false }
|
||||||
|
json.optBoolean("success") && json.optInt("code") == 22
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 2 — confirm: POST with channel + OTP.
|
||||||
|
* Returns [BmlQrPayResult] with success/error.
|
||||||
|
*/
|
||||||
|
fun confirmPayment(
|
||||||
|
session: BmlSession,
|
||||||
|
debitAccount: String,
|
||||||
|
requestId: String,
|
||||||
|
amount: Double,
|
||||||
|
currency: String,
|
||||||
|
otp: String
|
||||||
|
): BmlQrPayResult {
|
||||||
|
val jo = JSONObject().apply {
|
||||||
|
put("action", "approve")
|
||||||
|
put("debitAccount", debitAccount)
|
||||||
|
put("requestId", requestId)
|
||||||
|
put("amount", amount)
|
||||||
|
put("currency", currency)
|
||||||
|
put("channel", "token")
|
||||||
|
put("otp", otp)
|
||||||
|
}
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
|
||||||
|
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
.header("User-Agent", BML_USER_AGENT)
|
||||||
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.header("accept", "application/json")
|
||||||
|
.build()
|
||||||
|
return client.newCall(request).execute().use { response ->
|
||||||
|
val body = response.body?.string()
|
||||||
|
?: return@use BmlQrPayResult(false, errorMessage = "No response")
|
||||||
|
val json = try { JSONObject(body) } catch (_: Exception) {
|
||||||
|
return@use BmlQrPayResult(false, errorMessage = "Parse error")
|
||||||
|
}
|
||||||
|
if (!json.optBoolean("success")) {
|
||||||
|
BmlQrPayResult(false, errorMessage = json.optString("message").ifBlank { "Payment failed" })
|
||||||
|
} else {
|
||||||
|
val payload = json.optJSONObject("payload")
|
||||||
|
BmlQrPayResult(
|
||||||
|
success = true,
|
||||||
|
merchant = payload?.optString("merchant") ?: "",
|
||||||
|
amount = payload?.optString("amount") ?: "",
|
||||||
|
currency = payload?.optString("currency") ?: currency
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Base64
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.BaseAdapter
|
||||||
|
import android.widget.Filter
|
||||||
|
import android.widget.Filterable
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.widget.addTextChangedListener
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.bml.BmlQrPayClient
|
||||||
|
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.databinding.FragmentBmlQrPayBinding
|
||||||
|
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.Totp
|
||||||
|
|
||||||
|
class BmlQrPayFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentBmlQrPayBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
private var merchantInfo: BmlQrPayInfo? = null
|
||||||
|
private var selectedAccount: BankAccount? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_QR_URL = "qr_url"
|
||||||
|
private const val ARG_FROM_ACCOUNT = "from_account"
|
||||||
|
|
||||||
|
fun newInstance(qrUrl: String, fromAccountNumber: String? = null) = BmlQrPayFragment().apply {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putString(ARG_QR_URL, qrUrl)
|
||||||
|
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentBmlQrPayBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
setupFromDropdown()
|
||||||
|
|
||||||
|
binding.etAmount.addTextChangedListener { updatePayButton() }
|
||||||
|
|
||||||
|
binding.btnClearFromInfo.setOnClickListener {
|
||||||
|
selectedAccount = null
|
||||||
|
binding.cardFromInfo.visibility = View.GONE
|
||||||
|
binding.tilFrom.visibility = View.VISIBLE
|
||||||
|
binding.actvFrom.setText("", false)
|
||||||
|
updatePayButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnPay.setOnClickListener { initiatePay() }
|
||||||
|
|
||||||
|
val qrUrl = arguments?.getString(ARG_QR_URL) ?: run {
|
||||||
|
requireActivity().onBackPressedDispatcher.onBackPressed(); return
|
||||||
|
}
|
||||||
|
lookupMerchant(qrUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupFromDropdown() {
|
||||||
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
|
val bmlAccounts = accounts.filter {
|
||||||
|
it.bank == "BML" &&
|
||||||
|
it.profileType != "BML_LOAN" &&
|
||||||
|
it.profileType != "BML_CREDIT"
|
||||||
|
}
|
||||||
|
val adapter = BmlAccountAdapter(bmlAccounts)
|
||||||
|
binding.actvFrom.setAdapter(adapter)
|
||||||
|
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
|
||||||
|
val picked = adapter.getItem(position) as? BankAccount ?: return@setOnItemClickListener
|
||||||
|
selectedAccount = picked
|
||||||
|
showFromCard(picked)
|
||||||
|
updatePayButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-select card passed in from the card wallet/dashboard
|
||||||
|
val preselect = arguments?.getString(ARG_FROM_ACCOUNT)
|
||||||
|
if (preselect != null && selectedAccount == null) {
|
||||||
|
bmlAccounts.firstOrNull { it.accountNumber == preselect }?.let {
|
||||||
|
selectedAccount = it
|
||||||
|
showFromCard(it)
|
||||||
|
updatePayButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showFromCard(account: BankAccount) {
|
||||||
|
binding.tvFromAccountName.text = account.accountBriefName
|
||||||
|
binding.tvFromAccountNumber.text = account.accountNumber
|
||||||
|
val currency = account.currencyName.ifBlank { "MVR" }
|
||||||
|
binding.tvFromBalance.text = "$currency ${account.availableBalance}"
|
||||||
|
binding.ivFromPhoto.setImageBitmap(makeInitialsBitmap(account.accountBriefName, "#0066A1"))
|
||||||
|
binding.tilFrom.visibility = View.GONE
|
||||||
|
binding.cardFromInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// Update amount prefix to match account currency
|
||||||
|
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lookupMerchant(qrUrl: String) {
|
||||||
|
val base64Url = Base64.encodeToString(qrUrl.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val session = app.anyBmlSession() ?: run {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.tvLookingUp.visibility = View.VISIBLE
|
||||||
|
binding.cardMerchant.visibility = View.GONE
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val info = withContext(Dispatchers.IO) {
|
||||||
|
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
|
||||||
|
catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
if (_binding == null) return@launch
|
||||||
|
binding.tvLookingUp.visibility = View.GONE
|
||||||
|
if (info == null) {
|
||||||
|
Toast.makeText(requireContext(), R.string.bml_qr_lookup_failed, Toast.LENGTH_LONG).show()
|
||||||
|
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
merchantInfo = info
|
||||||
|
populateMerchant(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun populateMerchant(info: BmlQrPayInfo) {
|
||||||
|
binding.tvMerchantName.text = info.merchantName
|
||||||
|
binding.tvMerchantAddress.text = info.merchantAddress
|
||||||
|
binding.ivMerchantIcon.setImageBitmap(makeInitialsBitmap(info.merchantName, "#0066A1"))
|
||||||
|
binding.cardMerchant.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// Dynamic QR: pre-fill amount and lock the field
|
||||||
|
if (info.amount > 0.0) {
|
||||||
|
binding.etAmount.setText("%.2f".format(info.amount))
|
||||||
|
binding.tilAmount.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePayButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updatePayButton() {
|
||||||
|
val merchant = merchantInfo ?: run { binding.btnPay.isEnabled = false; return }
|
||||||
|
val account = selectedAccount ?: run { binding.btnPay.isEnabled = false; return }
|
||||||
|
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
||||||
|
binding.btnPay.isEnabled = amount > 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initiatePay() {
|
||||||
|
val info = merchantInfo ?: return
|
||||||
|
val account = selectedAccount ?: run {
|
||||||
|
Toast.makeText(requireContext(), R.string.bml_qr_select_account, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
|
||||||
|
val amount = amountStr.toDoubleOrNull()
|
||||||
|
if (amount == null || amount <= 0) {
|
||||||
|
binding.tilAmount.error = "Enter a valid amount"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.tilAmount.error = null
|
||||||
|
|
||||||
|
val debitAccount = account.internalId.ifBlank {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val currency = info.currency
|
||||||
|
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(R.string.bml_qr_pay_now)
|
||||||
|
.setMessage("Pay $currency ${"%.2f".format(amount)} to ${info.merchantName}?\n\nFrom: ${account.accountBriefName} · ${account.accountNumber}")
|
||||||
|
.setPositiveButton(R.string.transfer_confirm) { _, _ ->
|
||||||
|
executePay(account, debitAccount, info.requestId, amount, currency, info.merchantName)
|
||||||
|
}
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executePay(
|
||||||
|
account: BankAccount,
|
||||||
|
debitAccount: String,
|
||||||
|
requestId: String,
|
||||||
|
amount: Double,
|
||||||
|
currency: String,
|
||||||
|
merchantName: String
|
||||||
|
) {
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val loginId = account.loginTag.removePrefix("bml_")
|
||||||
|
val session = app.bmlSessionFor(account) ?: run {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnPay.isEnabled = false
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val initiated = BmlQrPayClient().initiatePayment(session, debitAccount, requestId, amount, currency)
|
||||||
|
if (!initiated) return@withContext null
|
||||||
|
val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
||||||
|
?.let { Totp.generate(it) } ?: otp
|
||||||
|
BmlQrPayClient().confirmPayment(session, debitAccount, requestId, amount, currency, confirmOtp)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
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) {
|
||||||
|
binding.btnPay.isEnabled = true
|
||||||
|
Toast.makeText(requireContext(), "Failed to initiate payment", Toast.LENGTH_LONG).show()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showSuccessDialog(
|
||||||
|
merchant = result.merchant.ifBlank { merchantName },
|
||||||
|
amount = result.amount.ifBlank { "%.2f".format(amount) },
|
||||||
|
currency = result.currency.ifBlank { currency }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
binding.btnPay.isEnabled = true
|
||||||
|
Toast.makeText(requireContext(), result.errorMessage, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSuccessDialog(merchant: String, amount: String, currency: String) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = resources.displayMetrics.density
|
||||||
|
|
||||||
|
val container = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
gravity = Gravity.CENTER_HORIZONTAL
|
||||||
|
setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Green checkmark icon
|
||||||
|
container.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() }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Amount
|
||||||
|
container.addView(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, Color.BLACK))
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply { gravity = Gravity.CENTER_HORIZONTAL; bottomMargin = (8 * dp).toInt() }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Merchant name
|
||||||
|
container.addView(TextView(ctx).apply {
|
||||||
|
text = merchant
|
||||||
|
textSize = 14f
|
||||||
|
setTextColor(com.google.android.material.color.MaterialColors.getColor(
|
||||||
|
requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY))
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply { gravity = 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 makeInitialsBitmap(name: String, colorHex: String): Bitmap {
|
||||||
|
val sizePx = (resources.displayMetrics.density * 40).toInt()
|
||||||
|
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
|
||||||
|
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bm)
|
||||||
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
paint.color = bgColor
|
||||||
|
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
||||||
|
paint.color = Color.WHITE
|
||||||
|
paint.textSize = sizePx * 0.42f
|
||||||
|
paint.textAlign = Paint.Align.CENTER
|
||||||
|
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||||
|
val metrics = paint.fontMetrics
|
||||||
|
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
||||||
|
return bm
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = "BML QR Pay"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class BmlAccountAdapter(private val accounts: List<BankAccount>) :
|
||||||
|
BaseAdapter(), Filterable {
|
||||||
|
|
||||||
|
override fun getCount() = accounts.size
|
||||||
|
override fun getItem(position: Int) = accounts[position]
|
||||||
|
override fun getItemId(position: Int) = position.toLong()
|
||||||
|
|
||||||
|
override fun getView(position: Int, convertView: View?, parent: ViewGroup) =
|
||||||
|
getDropDownView(position, convertView, parent)
|
||||||
|
|
||||||
|
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||||
|
val acc = accounts[position]
|
||||||
|
val b = if (convertView?.tag is ItemAccountDropdownBinding) {
|
||||||
|
convertView.tag as ItemAccountDropdownBinding
|
||||||
|
} else {
|
||||||
|
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
|
||||||
|
.also { it.root.tag = it }
|
||||||
|
}
|
||||||
|
val ownerPrefix = if (acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||||
|
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
||||||
|
b.tvDropdownAccountNumber.text = acc.accountNumber
|
||||||
|
b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}"
|
||||||
|
b.root.alpha = 1f
|
||||||
|
return b.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFilter() = object : Filter() {
|
||||||
|
override fun performFiltering(c: CharSequence?) =
|
||||||
|
FilterResults().apply { values = accounts; count = accounts.size }
|
||||||
|
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
|
||||||
|
override fun convertResultToString(r: Any?) =
|
||||||
|
(r as? BankAccount)?.let {
|
||||||
|
val prefix = if (it.profileName.isNotBlank()) "${it.profileName} · " else ""
|
||||||
|
"$prefix${it.accountBriefName}"
|
||||||
|
} ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -8,6 +10,7 @@ import android.view.ViewGroup
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
@@ -32,6 +35,21 @@ class DashboardFragment : Fragment() {
|
|||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
private var pendingQrAccountNumber: String? = null
|
||||||
|
|
||||||
|
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
|
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/")) {
|
||||||
|
(requireActivity() as HomeActivity).navigateTo(
|
||||||
|
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, pendingQrAccountNumber)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
pendingQrAccountNumber = null
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentDashboardBinding.inflate(inflater, container, false)
|
_binding = FragmentDashboardBinding.inflate(inflater, container, false)
|
||||||
return binding.root
|
return binding.root
|
||||||
@@ -283,8 +301,12 @@ class DashboardFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
val isMib = item is CardItem.Mib
|
val isMib = item is CardItem.Mib
|
||||||
btnPayQr.setOnClickListener {
|
btnPayQr.setOnClickListener {
|
||||||
val msg = if (isMib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
|
if (isMib) {
|
||||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
|
||||||
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(requireContext())
|
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(requireContext())
|
||||||
val nfcSupported = nfcAdapter != null
|
val nfcSupported = nfcAdapter != null
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ class PayMvQrFragment : Fragment() {
|
|||||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
|
|
||||||
|
// BML card QR — hand off to dedicated payment screen
|
||||||
|
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/")) {
|
||||||
|
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw))
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
val qr = PaymvQrParser.parse(raw)
|
val qr = PaymvQrParser.parse(raw)
|
||||||
if (qr == null || qr.accountNumber == null) {
|
if (qr == null || qr.accountNumber == null) {
|
||||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.drawable.GradientDrawable
|
import android.graphics.drawable.GradientDrawable
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -10,6 +12,7 @@ import android.view.ViewGroup
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
@@ -29,6 +32,21 @@ class PayWithCardFragment : Fragment() {
|
|||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
private var pendingQrAccountNumber: String? = null
|
||||||
|
|
||||||
|
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
|
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/")) {
|
||||||
|
(requireActivity() as HomeActivity).navigateTo(
|
||||||
|
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, pendingQrAccountNumber)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
pendingQrAccountNumber = null
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentPayWithCardBinding.inflate(inflater, container, false)
|
_binding = FragmentPayWithCardBinding.inflate(inflater, container, false)
|
||||||
return binding.root
|
return binding.root
|
||||||
@@ -133,8 +151,12 @@ class PayWithCardFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
val isMib = item is CardItem.Mib
|
val isMib = item is CardItem.Mib
|
||||||
btnPayQr.setOnClickListener {
|
btnPayQr.setOnClickListener {
|
||||||
val msg = if (isMib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
|
if (isMib) {
|
||||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
|
||||||
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(context)
|
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(context)
|
||||||
val nfcSupported = nfcAdapter != null
|
val nfcSupported = nfcAdapter != null
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import sh.sar.basedbank.BasedBankApp
|
|||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||||
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
||||||
|
import sh.sar.basedbank.api.bml.BmlQrPayClient
|
||||||
|
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
||||||
import sh.sar.basedbank.api.bml.BmlTransferClient
|
import sh.sar.basedbank.api.bml.BmlTransferClient
|
||||||
import sh.sar.basedbank.api.bml.BmlTransferResult
|
import sh.sar.basedbank.api.bml.BmlTransferResult
|
||||||
import sh.sar.basedbank.api.bml.BmlValidateClient
|
import sh.sar.basedbank.api.bml.BmlValidateClient
|
||||||
@@ -59,6 +61,7 @@ import sh.sar.basedbank.util.AccountListParser
|
|||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.util.AccountInputParser
|
import sh.sar.basedbank.util.AccountInputParser
|
||||||
import sh.sar.basedbank.util.PaymvQrParser
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
|
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||||
import sh.sar.basedbank.util.RecentPick
|
import sh.sar.basedbank.util.RecentPick
|
||||||
import sh.sar.basedbank.util.RecentsCache
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
import sh.sar.basedbank.util.ReceiptStore
|
import sh.sar.basedbank.util.ReceiptStore
|
||||||
@@ -87,6 +90,9 @@ class TransferFragment : Fragment() {
|
|||||||
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
|
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
|
||||||
private var selectedFahipayService: String? = null
|
private var selectedFahipayService: String? = null
|
||||||
|
|
||||||
|
// BML QR merchant payment mode (set when navigated from a card QR scan)
|
||||||
|
private var bmlQrInfo: BmlQrPayInfo? = null
|
||||||
|
|
||||||
// BML business profile OTP flow state
|
// BML business profile OTP flow state
|
||||||
private enum class BmlOtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
|
private enum class BmlOtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
|
||||||
private var bmlOtpState = BmlOtpState.NONE
|
private var bmlOtpState = BmlOtpState.NONE
|
||||||
@@ -113,6 +119,13 @@ class TransferFragment : Fragment() {
|
|||||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
|
|
||||||
|
// BML card QR — hand off to dedicated payment screen
|
||||||
|
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/")) {
|
||||||
|
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw))
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
val qr = PaymvQrParser.parse(raw)
|
val qr = PaymvQrParser.parse(raw)
|
||||||
if (qr == null || qr.accountNumber == null) {
|
if (qr == null || qr.accountNumber == null) {
|
||||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||||
@@ -132,6 +145,14 @@ class TransferFragment : Fragment() {
|
|||||||
private const val ARG_FROM_ACCOUNT = "from_account"
|
private const val ARG_FROM_ACCOUNT = "from_account"
|
||||||
private const val ARG_AMOUNT_PREFILL = "amount_prefill"
|
private const val ARG_AMOUNT_PREFILL = "amount_prefill"
|
||||||
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
|
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
|
||||||
|
private const val ARG_BML_QR_URL = "bml_qr_url"
|
||||||
|
|
||||||
|
fun newInstanceFromBmlQr(qrUrl: String, fromAccountNumber: String? = null) = TransferFragment().apply {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putString(ARG_BML_QR_URL, qrUrl)
|
||||||
|
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun newInstanceFrom(account: BankAccount) = TransferFragment().apply {
|
fun newInstanceFrom(account: BankAccount) = TransferFragment().apply {
|
||||||
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
|
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
|
||||||
@@ -224,6 +245,54 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
|
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
|
||||||
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
|
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
|
||||||
|
|
||||||
|
arguments?.getString(ARG_BML_QR_URL)?.let { lookupBmlQrMerchant(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lookupBmlQrMerchant(qrUrl: String) {
|
||||||
|
val base64Url = android.util.Base64.encodeToString(
|
||||||
|
qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP)
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val session = app.anyBmlSession() ?: return
|
||||||
|
|
||||||
|
// Lock the "To" input row while loading
|
||||||
|
binding.tilTo.visibility = View.GONE
|
||||||
|
binding.btnPickContact.visibility = View.GONE
|
||||||
|
binding.btnScanQr.visibility = View.GONE
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val info = withContext(Dispatchers.IO) {
|
||||||
|
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
|
||||||
|
catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
if (info == null) {
|
||||||
|
Toast.makeText(requireContext(), R.string.bml_qr_lookup_failed, Toast.LENGTH_LONG).show()
|
||||||
|
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
bmlQrInfo = info
|
||||||
|
|
||||||
|
// Show merchant in the "To" card — clear button hidden (can't change recipient for QR)
|
||||||
|
binding.tvToAccountName.text = info.merchantName
|
||||||
|
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
|
||||||
|
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(info.merchantName, "#0066A1"))
|
||||||
|
binding.btnClearToInfo.visibility = View.GONE
|
||||||
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// Pre-fill amount if dynamic QR
|
||||||
|
if (info.amount > 0.0) {
|
||||||
|
binding.etAmount.setText("%.2f".format(info.amount))
|
||||||
|
binding.tilAmount.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remarks not applicable for merchant QR payments
|
||||||
|
binding.tilRemarks.isEnabled = false
|
||||||
|
binding.tilRemarks.alpha = 0.4f
|
||||||
|
|
||||||
|
updateTransferButton()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startLookupLoading() {
|
private fun startLookupLoading() {
|
||||||
@@ -304,11 +373,18 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val hide = viewModel.hideAmounts.value ?: false
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
val balancePart = "${account.currencyName} ${account.availableBalance}"
|
val isDebitCard = account.profileType == "BML_DEBIT"
|
||||||
|
val balancePart = if (isDebitCard) null else "${account.currencyName} ${account.availableBalance}"
|
||||||
binding.tvFromAccountName.text = account.accountBriefName
|
binding.tvFromAccountName.text = account.accountBriefName
|
||||||
binding.tvFromAccountNumber.text = account.accountNumber
|
binding.tvFromAccountNumber.text = account.accountNumber
|
||||||
binding.tvFromAccountDetails.text = listOfNotNull(bankLabel, typeLabel, if (hide) maskAmount(balancePart) else balancePart).joinToString(" · ")
|
val balanceDisplay = balancePart?.let { if (hide) maskAmount(it) else it }
|
||||||
binding.ivFromPhoto.setImageBitmap(makeInitialsBitmap(account.accountBriefName, colorHex))
|
binding.tvFromAccountDetails.text = listOfNotNull(bankLabel, typeLabel, balanceDisplay).joinToString(" · ")
|
||||||
|
val networkIcon = BmlCardParser.cardNetworkIcon(account)
|
||||||
|
if (networkIcon != null) {
|
||||||
|
binding.ivFromPhoto.setImageResource(networkIcon)
|
||||||
|
} else {
|
||||||
|
binding.ivFromPhoto.setImageBitmap(makeInitialsBitmap(account.accountBriefName, colorHex))
|
||||||
|
}
|
||||||
binding.tilFrom.visibility = View.GONE
|
binding.tilFrom.visibility = View.GONE
|
||||||
binding.cardFromInfo.visibility = View.VISIBLE
|
binding.cardFromInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
@@ -631,6 +707,31 @@ class TransferFragment : Fragment() {
|
|||||||
// ── Transfer ──────────────────────────────────────────────────────────────
|
// ── Transfer ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun initiateTransfer() {
|
private fun initiateTransfer() {
|
||||||
|
// BML QR merchant payment — completely separate flow
|
||||||
|
bmlQrInfo?.let { info ->
|
||||||
|
val src = selectedAccount ?: run {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
|
||||||
|
val amount = amountStr.toDoubleOrNull()
|
||||||
|
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
|
||||||
|
binding.tilAmount.error = null
|
||||||
|
val debitAccount = src.internalId.ifBlank {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(R.string.bml_qr_pay_now)
|
||||||
|
.setMessage("Pay ${info.currency} ${"%.2f".format(amount)} to ${info.merchantName}?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}")
|
||||||
|
.setPositiveButton(R.string.transfer_confirm) { _, _ ->
|
||||||
|
executeBmlQrPayment(src, debitAccount, info, amount)
|
||||||
|
}
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val src = selectedAccount ?: run {
|
val src = selectedAccount ?: run {
|
||||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
return
|
return
|
||||||
@@ -786,6 +887,108 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun executeBmlQrPayment(
|
||||||
|
src: BankAccount,
|
||||||
|
debitAccount: String,
|
||||||
|
info: BmlQrPayInfo,
|
||||||
|
amount: Double
|
||||||
|
) {
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val loginId = src.loginTag.removePrefix("bml_")
|
||||||
|
val session = bmlSessionFor(src) ?: run {
|
||||||
|
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 }
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val initiated = BmlQrPayClient().initiatePayment(
|
||||||
|
session, debitAccount, info.requestId, amount, info.currency)
|
||||||
|
if (!initiated) return@withContext null
|
||||||
|
val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials(loginId)
|
||||||
|
?.otpSeed?.let { Totp.generate(it) } ?: otp
|
||||||
|
BmlQrPayClient().confirmPayment(
|
||||||
|
session, debitAccount, info.requestId, amount, info.currency, confirmOtp)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
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) {
|
||||||
|
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 }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
Toast.makeText(requireContext(), result.errorMessage, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showBmlQrSuccessDialog(merchant: String, amount: String, currency: String) {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
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() }
|
||||||
|
})
|
||||||
|
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 doMibTransfer(
|
private fun doMibTransfer(
|
||||||
src: BankAccount,
|
src: BankAccount,
|
||||||
destAccount: String,
|
destAccount: String,
|
||||||
@@ -1232,7 +1435,8 @@ class TransferFragment : Fragment() {
|
|||||||
private fun updateTransferButton() {
|
private fun updateTransferButton() {
|
||||||
if (bmlOtpState != BmlOtpState.NONE) return
|
if (bmlOtpState != BmlOtpState.NONE) return
|
||||||
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
||||||
val hasAll = selectedAccount != null && resolvedAccountNumber.isNotBlank() && amount > 0
|
val recipientReady = if (bmlQrInfo != null) bmlQrInfo != null else resolvedAccountNumber.isNotBlank()
|
||||||
|
val hasAll = selectedAccount != null && recipientReady && amount > 0
|
||||||
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
|
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
|
||||||
val errors = viewModel.connectivityErrors.value ?: emptySet()
|
val errors = viewModel.connectivityErrors.value ?: emptySet()
|
||||||
val bankOffline = "NO_INTERNET" in errors ||
|
val bankOffline = "NO_INTERNET" in errors ||
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
package sh.sar.basedbank.util.bmlapi
|
package sh.sar.basedbank.util.bmlapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
|
||||||
object BmlCardParser {
|
object BmlCardParser {
|
||||||
|
|
||||||
|
/** Returns the drawable res for the card network logo, or null for non-card/unknown. */
|
||||||
|
fun cardNetworkIcon(account: BankAccount): Int? = when {
|
||||||
|
account.productCode.startsWith("C1") -> R.drawable.visa
|
||||||
|
account.productCode.startsWith("C3") -> R.drawable.americanexpress
|
||||||
|
account.productCode == "C8905" || account.productCode == "C8995" -> R.drawable.visa
|
||||||
|
account.productCode.startsWith("C8") -> R.drawable.mastercard
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the asset path for the card image.
|
* Returns the asset path for the card image.
|
||||||
* The product code is stored in [BankAccount.productCode] for BML Card accounts.
|
* The product code is stored in [BankAccount.productCode] for BML Card accounts.
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.core.widget.NestedScrollView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="?attr/colorSurface">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<!-- Merchant info card -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardMerchant"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="24dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:cardCornerRadius="4dp"
|
||||||
|
app:cardElevation="0dp"
|
||||||
|
app:strokeWidth="1dp"
|
||||||
|
app:strokeColor="?attr/colorPrimary">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingStart="12dp"
|
||||||
|
android:paddingEnd="12dp"
|
||||||
|
android:paddingVertical="14dp"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
|
android:id="@+id/ivMerchantIcon"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvMerchantName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvMerchantAddress"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Loading placeholder shown while looking up merchant -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvLookingUp"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="24dp"
|
||||||
|
android:text="@string/bml_qr_looking_up"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:gravity="center"
|
||||||
|
android:visibility="visible" />
|
||||||
|
|
||||||
|
<!-- From label + account dropdown -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="6dp"
|
||||||
|
android:text="@string/transfer_label_from"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilFrom"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/transfer_from"
|
||||||
|
android:layout_marginBottom="8dp">
|
||||||
|
|
||||||
|
<AutoCompleteTextView
|
||||||
|
android:id="@+id/actvFrom"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="none"
|
||||||
|
android:focusable="false"
|
||||||
|
android:focusableInTouchMode="false" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<!-- Selected source account info card -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardFromInfo"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
app:cardCornerRadius="4dp"
|
||||||
|
app:cardElevation="0dp"
|
||||||
|
app:strokeWidth="1dp"
|
||||||
|
app:strokeColor="?attr/colorPrimary">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingStart="12dp"
|
||||||
|
android:paddingEnd="4dp"
|
||||||
|
android:paddingVertical="12dp"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
|
android:id="@+id/ivFromPhoto"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvFromAccountName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvFromAccountNumber"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:fontFamily="monospace" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvFromBalance"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnClearFromInfo"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:src="@android:drawable/ic_menu_close_clear_cancel"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/transfer_clear_recipient" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Amount -->
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilAmount"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/transfer_amount"
|
||||||
|
app:prefixText="MVR "
|
||||||
|
android:layout_marginBottom="24dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etAmount"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="numberDecimal"
|
||||||
|
android:maxLines="1" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnPay"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/bml_qr_pay_now"
|
||||||
|
android:enabled="false" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</androidx.core.widget.NestedScrollView>
|
||||||
@@ -238,6 +238,13 @@
|
|||||||
<string name="transfer_send_otp_via">Send verification code via</string>
|
<string name="transfer_send_otp_via">Send verification code via</string>
|
||||||
<string name="transfer_otp_code_hint">Verification code</string>
|
<string name="transfer_otp_code_hint">Verification code</string>
|
||||||
|
|
||||||
|
<!-- BML QR Pay -->
|
||||||
|
<string name="bml_qr_pay_now">Pay Now</string>
|
||||||
|
<string name="bml_qr_looking_up">Looking up merchant…</string>
|
||||||
|
<string name="bml_qr_lookup_failed">Could not load merchant details</string>
|
||||||
|
<string name="bml_qr_payment_success">Payment Successful</string>
|
||||||
|
<string name="bml_qr_select_account">Select a BML account to pay from</string>
|
||||||
|
|
||||||
<!-- Contacts -->
|
<!-- Contacts -->
|
||||||
<string name="contacts_empty">No contacts found</string>
|
<string name="contacts_empty">No contacts found</string>
|
||||||
<string name="contacts_search_hint">Search contacts</string>
|
<string name="contacts_search_hint">Search contacts</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user