add transfer recipt for bml and mib
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s

This commit is contained in:
2026-05-15 16:38:15 +05:00
parent 00a5edf539
commit c4379c42c8
18 changed files with 1002 additions and 30 deletions

View File

@@ -59,6 +59,7 @@ class TransferFragment : Fragment() {
// Resolved recipient info — set after successful lookup or prefill
private var resolvedAccountNumber = ""
private var resolvedRecipientName = ""
private var resolvedBankName = ""
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
@@ -268,6 +269,7 @@ class TransferFragment : Fragment() {
resolvedAccountNumber = info.accountNumber
resolvedRecipientName = info.accountName
resolvedBankName = info.bankId
binding.tvToAccountName.text = displayName
binding.tvToBankBic.text = "${info.accountNumber} · ${info.bankId}"
@@ -299,6 +301,8 @@ class TransferFragment : Fragment() {
) {
resolvedAccountNumber = accountNumber
resolvedRecipientName = displayName
val contacts = viewModel.contacts.value ?: emptyList()
resolvedBankName = contacts.firstOrNull { it.benefAccount == accountNumber }?.benefBankName ?: ""
binding.tvToAccountName.text = displayName
binding.tvToBankBic.text = subtitle
@@ -308,7 +312,6 @@ class TransferFragment : Fragment() {
binding.btnScanQr.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE
val contacts = viewModel.contacts.value ?: emptyList()
val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
if (contact != null) {
RecentsCache.save(requireContext(), RecentPick(
@@ -376,29 +379,28 @@ class TransferFragment : Fragment() {
}
val destDisplay = binding.tvToAccountName.text?.toString() ?: resolvedAccountNumber
val bankNameCapture = resolvedBankName
val capturedToAvatar = (binding.ivToPhoto.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
AlertDialog.Builder(requireContext())
.setTitle(R.string.transfer)
.setMessage("Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}")
.setPositiveButton(R.string.transfer_confirm) { _, _ ->
binding.btnTransfer.isEnabled = false
viewLifecycleOwner.lifecycleScope.launch {
val (ok, msg) = withContext(Dispatchers.IO) {
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
if (!isSrcBml) {
doMibTransfer(src, resolvedAccountNumber, resolvedRecipientName, amountStr, remarks)
doMibTransfer(src, resolvedAccountNumber, resolvedRecipientName, destDisplay, amountStr, remarks, bankNameCapture)
} else {
doBmlTransfer(src, resolvedAccountNumber, amount, remarks, isSrcCard, isDestMib, currency, allAccounts, allContacts)
doBmlTransfer(src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, isSrcCard, isDestMib, currency, allAccounts, allContacts)
}
}
binding.btnTransfer.isEnabled = true
if (ok) {
if (ok && receipt != null) {
clearForm()
(requireActivity() as HomeActivity).refreshBalances(src)
AlertDialog.Builder(requireContext())
.setTitle(R.string.transfer_success)
.setMessage(msg)
.setPositiveButton("OK", null)
.show()
} else {
val activity = requireActivity() as HomeActivity
activity.refreshBalances(src)
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
} else if (!ok) {
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
}
}
@@ -411,10 +413,12 @@ class TransferFragment : Fragment() {
src: MibAccount,
destAccount: String,
destName: String,
destDisplay: String,
amount: String,
remarks: String
): Pair<Boolean, String> {
val sess = session ?: return Pair(false, getString(R.string.transfer_session_unavailable))
remarks: String,
bankName: String
): Triple<Boolean, String, TransferReceiptData?> {
val sess = session ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
val app = requireActivity().application as BasedBankApp
// Switch to the profile that owns the source account
if (src.profileId.isNotBlank()) {
@@ -423,10 +427,19 @@ class TransferFragment : Fragment() {
}
val otp = CredentialStore(requireContext()).loadMibCredentials()?.otpSeed
?.let { Totp.generate(it) }
?: return Pair(false, "OTP unavailable")
?: return Triple(false, "OTP unavailable", null)
val currencyCode = if (src.currencyName == "USD") "840" else "462"
val currency = if (src.currencyName == "USD") "USD" else "MVR"
val isDestMib = destAccount.matches(Regex("^9\\d{16}$"))
val bankNo = if (isDestMib) 2 else 3
val toBank = when {
isDestMib -> "MIB"
else -> when (bankName.uppercase()) {
"MALBMVMV" -> "BML"
"MADVMVMV" -> "MIB"
else -> bankName.ifBlank { "LOCAL" }
}
}
return try {
val result = MibTransferClient().transfer(
session = sess,
@@ -440,32 +453,48 @@ class TransferFragment : Fragment() {
otp = otp
)
if (result.success) {
Pair(true, "Transaction ID: ${result.trxId}\n${result.date}")
val receipt = TransferReceiptData(
isMib = true,
amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0),
currency = currency,
fromLabel = src.accountBriefName,
fromColorHex = "#FE860E",
fromProfileImageHash = src.profileImageHash,
toLabel = destDisplay.ifBlank { destName },
toAccount = destAccount,
toBank = toBank,
remarks = remarks,
mibReferenceNo = result.trxId,
mibTransactionDate = result.date
)
Triple(true, "Transaction ID: ${result.trxId}\n${result.date}", receipt)
} else {
Pair(false, result.errorMessage.ifBlank { "Transfer failed" })
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
}
} catch (e: Exception) {
Pair(false, e.message ?: "Transfer failed")
Triple(false, e.message ?: "Transfer failed", null)
}
}
private fun doBmlTransfer(
src: MibAccount,
destAccount: String,
destDisplay: String,
amount: Double,
amountStr: String,
remarks: String,
isSrcCard: Boolean,
isDestMib: Boolean,
currency: String,
allAccounts: List<MibAccount>,
allContacts: List<MibBeneficiary>
): Pair<Boolean, String> {
val sess = bmlSession ?: return Pair(false, getString(R.string.transfer_session_unavailable))
): Triple<Boolean, String, TransferReceiptData?> {
val sess = bmlSession ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
val otp = CredentialStore(requireContext()).loadBmlCredentials()?.otpSeed
?.let { Totp.generate(it) }
?: return Pair(false, "OTP unavailable")
?: return Triple(false, "OTP unavailable", null)
val debitAccount = src.internalId.ifBlank {
return Pair(false, getString(R.string.transfer_missing_internal_id))
return Triple(false, getString(R.string.transfer_missing_internal_id), null)
}
// Determine type + credit account
@@ -485,19 +514,20 @@ class TransferFragment : Fragment() {
isDestMib -> {
// USD DOT: requires BML contact numeric ID
val contact = allContacts.firstOrNull { it.benefCategoryId == "BML" && it.benefAccount == destAccount }
?: return Pair(false, "BML contact not found for this account")
?: return Triple(false, "BML contact not found for this account", null)
Triple("DOT", contact.benefNo.removePrefix("bml_"), null as String?)
}
else -> Triple("IAT", destAccount, null as String?)
}
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
val bmlFlow = BmlLoginFlow()
// Step 1: initiate
val initiated = try {
bmlFlow.initiateTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, bank)
} catch (e: Exception) { return Pair(false, e.message ?: "Initiation failed") }
} catch (e: Exception) { return Triple(false, e.message ?: "Initiation failed", null) }
if (!initiated) return Pair(false, "Failed to initiate transfer — check your session")
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
// Step 2: confirm with fresh OTP
val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials()?.otpSeed
@@ -506,13 +536,27 @@ class TransferFragment : Fragment() {
return try {
val result = bmlFlow.confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
if (result.success) {
val receipt = TransferReceiptData(
isMib = false,
amount = "%.2f".format(amount),
currency = currency,
fromLabel = src.accountBriefName,
fromColorHex = "#0066A1",
toLabel = destDisplay.ifBlank { destAccount },
toAccount = destAccount,
toBank = toBank,
remarks = remarks,
bmlFromName = src.accountBriefName,
bmlReference = result.reference,
bmlTimestamp = result.timestamp
)
val time = result.timestamp.take(19).replace("T", " ")
Pair(true, "Reference: ${result.reference}\n$time")
Triple(true, "Reference: ${result.reference}\n$time", receipt)
} else {
Pair(false, result.errorMessage.ifBlank { "Transfer failed" })
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
}
} catch (e: Exception) {
Pair(false, e.message ?: "Transfer failed")
Triple(false, e.message ?: "Transfer failed", null)
}
}
@@ -524,6 +568,7 @@ class TransferFragment : Fragment() {
binding.etRemarks.setText("")
resolvedAccountNumber = ""
resolvedRecipientName = ""
resolvedBankName = ""
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE

View File

@@ -0,0 +1,21 @@
package sh.sar.basedbank.ui.home
data class TransferReceiptData(
val isMib: Boolean,
val amount: String,
val currency: String,
val fromLabel: String,
val fromColorHex: String,
val fromProfileImageHash: String? = null,
val toLabel: String,
val toAccount: String,
val toBank: String,
val remarks: String,
// MIB receipt fields
val mibReferenceNo: String = "",
val mibTransactionDate: String = "",
// BML receipt fields
val bmlFromName: String = "",
val bmlReference: String = "",
val bmlTimestamp: String = "",
)

View File

@@ -0,0 +1,319 @@
package sh.sar.basedbank.ui.home
import android.content.ContentValues
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.util.Base64
import android.view.LayoutInflater
import android.view.PixelCopy
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton
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.mib.MibContactsClient
import sh.sar.basedbank.databinding.FragmentReceiptBmlBinding
import sh.sar.basedbank.databinding.FragmentReceiptMibBinding
import java.io.File
import java.io.FileOutputStream
import java.text.NumberFormat
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
class TransferReceiptFragment : Fragment() {
private var _receiptCard: View? = null
private val receiptCard get() = _receiptCard!!
companion object {
private const val ARG_IS_MIB = "is_mib"
private const val ARG_AMOUNT = "amount"
private const val ARG_CURRENCY = "currency"
private const val ARG_FROM_LABEL = "from_label"
private const val ARG_FROM_COLOR = "from_color"
private const val ARG_FROM_PROFILE_HASH = "from_profile_hash"
private const val ARG_TO_LABEL = "to_label"
private const val ARG_TO_ACCOUNT = "to_account"
private const val ARG_TO_BANK = "to_bank"
private const val ARG_REMARKS = "remarks"
private const val ARG_MIB_REF = "mib_ref"
private const val ARG_MIB_DATE = "mib_date"
private const val ARG_BML_FROM_NAME = "bml_from_name"
private const val ARG_BML_REFERENCE = "bml_reference"
private const val ARG_BML_TIMESTAMP = "bml_timestamp"
// Holds the already-rendered to-avatar bitmap from TransferFragment
var pendingToAvatarBitmap: Bitmap? = null
fun newInstance(data: TransferReceiptData, toAvatarBitmap: Bitmap?) = TransferReceiptFragment().apply {
pendingToAvatarBitmap = toAvatarBitmap
arguments = Bundle().apply {
putBoolean(ARG_IS_MIB, data.isMib)
putString(ARG_AMOUNT, data.amount)
putString(ARG_CURRENCY, data.currency)
putString(ARG_FROM_LABEL, data.fromLabel)
putString(ARG_FROM_COLOR, data.fromColorHex)
putString(ARG_FROM_PROFILE_HASH, data.fromProfileImageHash)
putString(ARG_TO_LABEL, data.toLabel)
putString(ARG_TO_ACCOUNT, data.toAccount)
putString(ARG_TO_BANK, data.toBank)
putString(ARG_REMARKS, data.remarks)
putString(ARG_MIB_REF, data.mibReferenceNo)
putString(ARG_MIB_DATE, data.mibTransactionDate)
putString(ARG_BML_FROM_NAME, data.bmlFromName)
putString(ARG_BML_REFERENCE, data.bmlReference)
putString(ARG_BML_TIMESTAMP, data.bmlTimestamp)
}
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val isMib = arguments?.getBoolean(ARG_IS_MIB, true) ?: true
return if (isMib) {
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
bindMib(binding)
_receiptCard = binding.receiptCard
binding.root
} else {
val binding = FragmentReceiptBmlBinding.inflate(inflater, container, false)
bindBml(binding)
_receiptCard = binding.receiptCard
binding.root
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
parentFragmentManager.popBackStack()
}
view.findViewById<MaterialButton>(R.id.btnShare).setOnClickListener {
shareReceipt()
}
view.findViewById<MaterialButton>(R.id.btnSave).setOnClickListener {
saveReceipt()
}
}
// ── Data binding ──────────────────────────────────────────────────────────
private fun bindMib(binding: FragmentReceiptMibBinding) {
val args = requireArguments()
val fromLabel = args.getString(ARG_FROM_LABEL, "")
val fromColor = args.getString(ARG_FROM_COLOR, "#FE860E")
val fromProfileHash = args.getString(ARG_FROM_PROFILE_HASH)
val toLabel = args.getString(ARG_TO_LABEL, "")
val currency = args.getString(ARG_CURRENCY, "MVR")
val amount = args.getString(ARG_AMOUNT, "")
// From avatar: initials first, then load profile image if hash available
binding.ivFromAvatar.setImageBitmap(makeInitialsBitmap(fromLabel, fromColor))
binding.tvFromLabel.text = fromLabel
if (fromProfileHash != null) {
loadProfileImage(fromProfileHash, isProfile = true) { binding.ivFromAvatar.setImageBitmap(it) }
}
// To avatar: use already-rendered bitmap from TransferFragment if available
val toAvatar = pendingToAvatarBitmap
if (toAvatar != null) {
binding.ivToAvatar.setImageBitmap(toAvatar)
} else {
binding.ivToAvatar.setImageBitmap(makeInitialsBitmap(toLabel, "#607D8B"))
}
binding.tvToLabel.text = toLabel
binding.tvAmount.text = "$currency $amount"
binding.tvReferenceNo.text = args.getString(ARG_MIB_REF, "")
binding.tvToAccount.text = args.getString(ARG_TO_ACCOUNT, "")
binding.tvToBank.text = args.getString(ARG_TO_BANK, "")
binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "")
binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "")
binding.tvPurpose.text = args.getString(ARG_REMARKS, "").ifBlank { "-" }
}
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
val app = requireActivity().application as BasedBankApp
val sess = app.mibSession ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = if (isProfile) {
app.mibLoginFlow.fetchProfileImage(sess, hash)
} else {
MibContactsClient().fetchProfileImageBase64(sess, hash)
} ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
withContext(Dispatchers.Main) { if (_receiptCard != null) onLoaded(bitmap) }
} catch (_: Exception) { }
}
}
private fun bindBml(binding: FragmentReceiptBmlBinding) {
val args = requireArguments()
val currency = args.getString(ARG_CURRENCY, "MVR")
val amountStr = args.getString(ARG_AMOUNT, "")
// Format with thousands separator: "1000.00" → "1,000.00"
val formattedAmount = try {
val d = amountStr.toDouble()
val intFmt = NumberFormat.getNumberInstance(Locale.US).apply { maximumFractionDigits = 0 }
intFmt.format(d.toLong()) + "%.2f".format(d).takeLast(3)
} catch (_: Exception) { amountStr }
binding.tvAmountValue.text = formattedAmount
binding.tvAmountCurrency.text = currency
binding.tvMessageRow.text = "Thank you. Transfer transaction is successful."
binding.tvReference.text = args.getString(ARG_BML_REFERENCE, "")
binding.tvTransactionDate.text = formatBmlTimestamp(args.getString(ARG_BML_TIMESTAMP, ""))
binding.tvFrom.text = args.getString(ARG_BML_FROM_NAME, "").ifBlank {
args.getString(ARG_FROM_LABEL, "")
}.uppercase(Locale.US)
binding.tvToName.text = args.getString(ARG_TO_LABEL, "")
binding.tvToAccount.text = args.getString(ARG_TO_ACCOUNT, "")
binding.tvAmountRow.text = "$currency $formattedAmount"
val remarks = args.getString(ARG_REMARKS, "")
if (!remarks.isNullOrBlank()) {
binding.tvRemarks.text = remarks
binding.remarksDivider.visibility = View.VISIBLE
binding.remarksRow.visibility = View.VISIBLE
}
}
// ── Share / Save ──────────────────────────────────────────────────────────
private fun shareReceipt() {
captureReceiptBitmap { bitmap ->
if (bitmap == null) {
Toast.makeText(requireContext(), "Failed to render receipt", Toast.LENGTH_SHORT).show()
return@captureReceiptBitmap
}
try {
val dir = File(requireContext().cacheDir, "receipts").also { it.mkdirs() }
val file = File(dir, "receipt_${System.currentTimeMillis()}.png")
FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
val uri: Uri = FileProvider.getUriForFile(
requireContext(), "${requireContext().packageName}.fileprovider", file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "image/png"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(intent, "Share Receipt"))
} catch (e: Exception) {
Toast.makeText(requireContext(), "Share failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
private fun saveReceipt() {
captureReceiptBitmap { bitmap ->
if (bitmap == null) {
Toast.makeText(requireContext(), "Failed to render receipt", Toast.LENGTH_SHORT).show()
return@captureReceiptBitmap
}
val filename = "receipt_${System.currentTimeMillis()}.png"
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
}
val resolver = requireContext().contentResolver
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
?: throw Exception("Could not create media entry")
resolver.openOutputStream(uri)?.use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
} else {
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
dir.mkdirs()
val file = File(dir, filename)
FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
android.media.MediaScannerConnection.scanFile(
requireContext(), arrayOf(file.absolutePath), null, null
)
}
Toast.makeText(requireContext(), "Receipt saved to Photos", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(requireContext(), "Save failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Captures the receipt card using PixelCopy, which correctly handles
* hardware-accelerated views (avoids the black-square problem with view.draw()).
*/
private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) {
val view = _receiptCard ?: run { callback(null); return }
if (view.width == 0 || view.height == 0) { callback(null); return }
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
val location = IntArray(2)
view.getLocationInWindow(location)
val srcRect = Rect(location[0], location[1], location[0] + view.width, location[1] + view.height)
PixelCopy.request(requireActivity().window, srcRect, bitmap, { result ->
callback(if (result == PixelCopy.SUCCESS) bitmap else null)
}, Handler(Looper.getMainLooper()))
}
private fun formatBmlTimestamp(raw: String): String {
if (raw.isBlank()) return ""
return try {
OffsetDateTime.parse(raw).format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"))
} catch (_: Exception) {
raw.take(16)
}
}
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
val sizePx = (resources.displayMetrics.density * 52).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 = "Receipt"
}
override fun onDestroyView() {
super.onDestroyView()
_receiptCard = null
pendingToAvatarBitmap = null
}
}