share contact account numbers and PayMV QR for contacts
Auto Tag on Version Change / check-version (push) Successful in 3s

This commit is contained in:
2026-09-25 06:53:15 +05:00
parent 5210e6207f
commit 66568a464a
6 changed files with 178 additions and 23 deletions
@@ -3,6 +3,7 @@ package sh.sar.basedbank.ui.home
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -56,16 +57,25 @@ class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
binding.tvName.text = contact.name
binding.actionTransfer.visibility = if (contact.canTransfer) View.VISIBLE else View.GONE
// QR stays visible for every contact; greyed out where PayMV QR isn't supported yet
val canQr = canShowPayMvQr(contact)
binding.btnQr.isEnabled = canQr
binding.tvQrLabel.alpha = if (canQr) 1f else 0.38f
binding.actionEdit.visibility = if (contact.canEdit) View.VISIBLE else View.GONE
binding.actionDelete.visibility = if (contact.canDelete) View.VISIBLE else View.GONE
binding.actionRow.visibility =
if (contact.canTransfer || contact.canEdit || contact.canDelete) View.VISIBLE else View.GONE
binding.btnTransfer.setOnClickListener {
val parent = contactsFragment
dismiss()
parent?.openTransfer(contact)
}
binding.btnQr.setOnClickListener {
val activity = requireActivity() as HomeActivity
val bank = contactBank(contact) ?: return@setOnClickListener
val qrName = contact.realName.ifBlank { contact.name }
dismiss()
activity.showWithBackStack(PayMvQrFragment.forContact(contact.accountNumber, qrName, bank))
}
binding.btnEdit.setOnClickListener {
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
@@ -79,6 +89,9 @@ class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
val copy = View.OnClickListener { copyAccount(contact.accountNumber) }
binding.rowAccount.setOnClickListener(copy)
binding.btnCopyAccount.setOnClickListener(copy)
binding.btnShareAccount.setOnClickListener {
shareAccount(contact.realName.ifBlank { contact.name }, contact.accountNumber)
}
val showRealName = contact.realName.isNotBlank() && contact.realName != contact.name
binding.tvRealName.text = contact.realName
@@ -108,14 +121,30 @@ class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
}
}
/** Logo of the contact's bank; null when we have no logo for it (only MIB and BML for now). */
private fun bankLogoRes(contact: ContactDisplay): Int? = when {
contact.network == TransferNetwork.MIB -> R.drawable.mib_logo
contact.accountNumber.matches(Regex("^7\\d{12}$")) -> R.drawable.bml_logo_vector
contact.accountNumber.matches(Regex("^9\\d{16}$")) -> R.drawable.mib_logo
/** Which bank the contact's account is at ("BML" / "MIB"); null when we can't tell. */
private fun contactBank(contact: ContactDisplay): String? = when {
contact.network == TransferNetwork.MIB -> "MIB"
contact.accountNumber.matches(Regex("^7\\d{12}$")) -> "BML"
contact.accountNumber.matches(Regex("^9\\d{16}$")) -> "MIB"
else -> null
}
/** Logo of the contact's bank; null when we have no logo for it (only MIB and BML for now). */
private fun bankLogoRes(contact: ContactDisplay): Int? = when (contactBank(contact)) {
"BML" -> R.drawable.bml_logo_vector
"MIB" -> R.drawable.mib_logo
else -> null
}
/**
* PayMV QR is only offered for contacts whose account is at BML for now.
* TODO: enable for MIB and other banks once their PayMV QR payloads are verified —
* PayMvQrFragment.forContact already takes the bank, so this check is the only gate.
*/
private fun canShowPayMvQr(contact: ContactDisplay): Boolean =
contactBank(contact) == "BML" &&
contact.currency?.contains("USD", ignoreCase = true) != true // PayMV QR is MVR only
private data class SourceProfile(val logoRes: Int, val bankNameRes: Int, val name: String)
/** Which of the user's own bank profiles this contact is saved under. */
@@ -149,6 +178,14 @@ class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
}
}
private fun shareAccount(accountName: String, account: String) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, "$accountName\n$account")
}
startActivity(Intent.createChooser(intent, getString(R.string.contact_share_account)))
}
private fun copyAccount(account: String) {
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("account", account))
@@ -48,6 +48,8 @@ class PayMvQrFragment : Fragment() {
private val viewModel: HomeViewModel by activityViewModels()
private var selectedAccount: BankAccount? = null
/** Set when opened for a saved contact: the QR is for their account, not one of ours. */
private var contactTarget: QrTarget? = null
private var generatedBitmap: Bitmap? = null
private var generateJob: Job? = null
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
@@ -59,7 +61,17 @@ class PayMvQrFragment : Fragment() {
return binding.root
}
/** Who the QR pays into — one of our own accounts, or a contact's. */
private data class QrTarget(val accountNumber: String, val name: String, val bank: String)
private fun currentTarget(): QrTarget? = contactTarget
?: selectedAccount?.let { QrTarget(it.accountNumber, it.accountBriefName, it.bank) }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
contactTarget = arguments?.let { args ->
val number = args.getString(ARG_ACCOUNT_NUMBER) ?: return@let null
QrTarget(number, args.getString(ARG_ACCOUNT_NAME).orEmpty(), args.getString(ARG_BANK).orEmpty())
}
val basePaddingBottom = view.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
@@ -68,7 +80,15 @@ class PayMvQrFragment : Fragment() {
v.updatePadding(bottom = basePaddingBottom + navBarBottom)
insets
}
if (contactTarget != null) {
// Not our account, so no account picker and our phone number doesn't belong on it
binding.tilAccount.visibility = View.GONE
binding.layoutIncludePhone.visibility = View.GONE
binding.switchIncludePhone.isChecked = false
scheduleGenerate()
} else {
setupDropdown()
}
binding.etAmount.addTextChangedListener { scheduleGenerate() }
binding.etReference.addTextChangedListener { scheduleGenerate() }
binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() }
@@ -119,8 +139,8 @@ class PayMvQrFragment : Fragment() {
}
private suspend fun generateQr() {
val account = selectedAccount ?: return
val acquirer = when (account.bank) {
val target = currentTarget() ?: return
val acquirer = when (target.bank) {
"BML" -> "MALBMVMV"
"MIB" -> "MADVMVMV"
"FAHIPAY" -> "FAHIMVMV"
@@ -133,10 +153,10 @@ class PayMvQrFragment : Fragment() {
?.let { "%.2f".format(it) }
val ctx = requireContext()
val includePhone = binding.switchIncludePhone.isChecked
val account = selectedAccount
val mobile = if (binding.switchIncludePhone.isChecked && contactTarget == null && account != null) {
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
val store = CredentialStore(ctx)
val mobile = if (includePhone) {
when (account.bank) {
"BML" -> store.loadBmlUserProfile(loginId)?.mobile
"FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile
@@ -154,8 +174,8 @@ class PayMvQrFragment : Fragment() {
?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default)
val bmp = withContext(Dispatchers.Default) {
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted, mobile, purpose)
renderQrCard(ctx, account, payload, amountFormatted)
val payload = buildQrPayload(target.accountNumber, target.name, acquirer, amountFormatted, mobile, purpose)
renderQrCard(ctx, target, payload, amountFormatted)
}
if (_binding == null) return
generatedBitmap = bmp
@@ -224,7 +244,7 @@ class PayMvQrFragment : Fragment() {
private fun renderQrCard(
ctx: Context,
account: BankAccount,
target: QrTarget,
qrPayload: String,
amountStr: String?
): Bitmap {
@@ -246,7 +266,7 @@ class PayMvQrFragment : Fragment() {
canvas.drawColor(Color.WHITE)
// --- Bank logo top-left ---
val logoRes = when (account.bank) {
val logoRes = when (target.bank) {
"BML" -> R.drawable.bml_logo_vector
"MIB" -> R.drawable.mib_faisanet_logo
else -> R.drawable.fahipay_logo_long
@@ -279,7 +299,7 @@ class PayMvQrFragment : Fragment() {
paint.color = Color.WHITE
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
paint.textAlign = Paint.Align.CENTER
val nameText = account.accountBriefName.uppercase()
val nameText = target.name.uppercase()
paint.textSize = 36f
val maxNameW = boxR - boxL - 48f
if (paint.measureText(nameText) > maxNameW) {
@@ -338,13 +358,13 @@ class PayMvQrFragment : Fragment() {
private fun shareQr() {
val bmp = generatedBitmap ?: return
val account = selectedAccount ?: return
val target = currentTarget() ?: return
lifecycleScope.launch {
val uri = withContext(Dispatchers.IO) {
try {
val dir = File(requireContext().cacheDir, "qr")
dir.mkdirs()
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
val safeName = target.name.replace(Regex("[^A-Za-z0-9_]"), "_")
val file = File(dir, "${safeName}_paymv_qr.png")
FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
FileProvider.getUriForFile(
@@ -366,11 +386,11 @@ class PayMvQrFragment : Fragment() {
private fun saveQr() {
val bmp = generatedBitmap ?: return
val account = selectedAccount ?: return
val target = currentTarget() ?: return
lifecycleScope.launch {
val saved = withContext(Dispatchers.IO) {
try {
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
val safeName = target.name.replace(Regex("[^A-Za-z0-9_]"), "_")
val filename = "${safeName}_PayMV_QR.png"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues().apply {
@@ -412,6 +432,21 @@ class PayMvQrFragment : Fragment() {
_binding = null
}
companion object {
private const val ARG_ACCOUNT_NUMBER = "account_number"
private const val ARG_ACCOUNT_NAME = "account_name"
private const val ARG_BANK = "bank"
/** QR for a contact's account. [bank] is "BML" / "MIB" / "FAHIPAY", as on [BankAccount.bank]. */
fun forContact(accountNumber: String, name: String, bank: String) = PayMvQrFragment().apply {
arguments = Bundle().apply {
putString(ARG_ACCOUNT_NUMBER, accountNumber)
putString(ARG_ACCOUNT_NAME, name)
putString(ARG_BANK, bank)
}
}
}
// ── Account dropdown adapter ──────────────────────────────────────────────
private inner class QrAccountAdapter(
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="M3,11h8V3H3V11zM5,5h4v4H5V5zM3,21h8v-8H3V21zM5,15h4v4H5V15zM13,3v8h8V3H13zM19,9h-4V5h4V9zM19,19h2v2h-2V19zM13,13h2v2h-2V13zM15,15h2v2h-2V15zM13,17h2v2h-2V17zM15,19h2v2h-2V19zM17,17h2v2h-2V17zM17,13h2v2h-2V13zM19,15h2v2h-2V15z" />
</vector>
@@ -95,6 +95,7 @@
<!-- Include phone number toggle -->
<LinearLayout
android:id="@+id/layoutIncludePhone"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
@@ -56,18 +56,62 @@
android:layout_height="56dp"
android:contentDescription="@string/transfer"
app:icon="@drawable/ic_send"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:padding="0dp"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:text="@string/transfer"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<LinearLayout
android:id="@+id/actionQr"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_horizontal"
android:orientation="vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnQr"
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
android:layout_width="56dp"
android:layout_height="56dp"
android:contentDescription="@string/contact_qr"
app:icon="@drawable/ic_qr_code"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:padding="0dp"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp" />
<TextView
android:id="@+id/tvQrLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:text="@string/contact_qr"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<LinearLayout
android:id="@+id/actionEdit"
android:layout_width="0dp"
@@ -83,12 +127,20 @@
android:layout_height="56dp"
android:contentDescription="@string/contact_edit"
app:icon="@drawable/ic_edit"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:padding="0dp"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:text="@string/contact_edit"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurface" />
@@ -111,6 +163,13 @@
android:contentDescription="@string/contact_delete"
app:backgroundTint="?attr/colorErrorContainer"
app:icon="@drawable/ic_delete"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:padding="0dp"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp"
app:iconTint="?attr/colorOnErrorContainer" />
@@ -118,6 +177,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:text="@string/contact_delete"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorError" />
@@ -174,6 +234,16 @@
</LinearLayout>
<ImageButton
android:id="@+id/btnShareAccount"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/contact_share_account"
android:padding="8dp"
android:src="@drawable/ic_share"
app:tint="?attr/colorOnSurfaceVariant" />
<ImageButton
android:id="@+id/btnCopyAccount"
android:layout_width="40dp"
+2
View File
@@ -335,8 +335,10 @@
<string name="contact_account_number">Account number</string>
<string name="contact_account_name">Account name</string>
<string name="contact_bank">Bank</string>
<string name="contact_qr">QR</string>
<string name="contact_delete_warning">This can\'t be undone.</string>
<string name="contact_copy_account">Copy account number</string>
<string name="contact_share_account">Share account details</string>
<string name="contact_account_copied">Account number copied</string>
<!-- Financing -->