Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbbfdd8537
|
||
|
|
169c3c144c
|
||
|
|
71ef7a5b55
|
||
|
|
32563ea398
|
||
|
|
41ce8a65ad
|
||
|
|
c7d3efa64e
|
||
|
|
2688118f52
|
||
|
|
e717360d00
|
||
|
|
2f26599931
|
||
|
|
f8f9d82440
|
||
|
|
a37354fc82
|
||
|
|
66568a464a
|
||
|
|
5210e6207f
|
||
|
|
369c6e4a37
|
||
|
|
fbedc631c5
|
||
|
|
7d6c192a63
|
||
|
|
e7f7590654
|
||
|
|
a048be939c
|
||
|
|
102a986201
|
||
|
|
440db13a0f
|
||
|
|
0dc81fef62
|
||
|
|
7e52b510a1
|
||
|
|
0d7833af5c
|
||
|
|
9e45e36c0a
|
||
|
|
c883b6b521
|
||
|
|
5dc37aadf0
|
||
|
|
1e3cf3420b
|
||
|
|
d98dcfd407
|
||
|
|
3b55fa30a8
|
||
|
|
7f0f2707a1
|
||
|
|
117733b766
|
||
|
|
d769df6f6b
|
||
|
|
caf0db60da
|
||
|
|
b48022d249
|
||
|
|
7574a5e8b0
|
||
|
|
992c8d4364
|
||
|
|
80fd238195
|
||
|
|
0357d7e0bc
|
||
|
|
71dd654edc
|
||
|
|
a75d8e420a
|
||
|
|
ca6ecc4283
|
||
|
|
f59c2be6c4
|
||
|
|
eb019a20a0
|
||
|
|
c356762baa
|
||
|
|
255f43db24
|
||
|
|
01cae559cf
|
||
|
|
015919a4ac
|
||
|
|
93a7c8bbde
|
||
|
|
8f4672f269
|
||
|
|
00e6b40ee0
|
@@ -27,24 +27,21 @@ jobs:
|
||||
- name: Rename APK
|
||||
run: |
|
||||
APP_NAME="${{ gitea.repository }}"
|
||||
APP_NAME="${APP_NAME##*/}"
|
||||
APP_NAME="${APP_NAME//\//-}"
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
find .build/release/release/ -maxdepth 1 -name "*.apk" | head -1 | xargs -I{} mv {} ".build/release/release/${APP_NAME}-${TAG}.apk"
|
||||
|
||||
- name: Extract release notes
|
||||
run: |
|
||||
echo "No release notes" > release_notes.md
|
||||
# VERSION="${{ gitea.ref_name }}"
|
||||
# VERSION="${VERSION#v}"
|
||||
#
|
||||
# awk -v ver="$VERSION" '
|
||||
# BEGIN { found=0 }
|
||||
# /^## \[/ {
|
||||
# if (found) exit
|
||||
# if ($0 ~ "\\[" ver "\\]") { found=1; next }
|
||||
# }
|
||||
# found { print }
|
||||
# ' CHANGELOG.md > release_notes.md
|
||||
VERSION_CODE=$(grep 'versionCode = ' app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
|
||||
CHANGELOG="fastlane/metadata/android/en-US/changelogs/${VERSION_CODE}.txt"
|
||||
|
||||
if [ -f "$CHANGELOG" ]; then
|
||||
cp "$CHANGELOG" release_notes.md
|
||||
else
|
||||
echo "No changelog found at $CHANGELOG"
|
||||
echo "No release notes" > release_notes.md
|
||||
fi
|
||||
|
||||
- name: Create Gitea Release
|
||||
env:
|
||||
@@ -53,7 +50,7 @@ jobs:
|
||||
GITEA_TOKEN: ${{ secrets.PAT_GITEA }}
|
||||
run: |
|
||||
APP_NAME="${{ gitea.repository }}"
|
||||
APP_NAME="${APP_NAME##*/}"
|
||||
APP_NAME="${APP_NAME//\//-}"
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
TITLE="${APP_NAME} ${TAG}"
|
||||
NOTES_FILE="release_notes.md"
|
||||
@@ -101,12 +98,12 @@ jobs:
|
||||
fi
|
||||
|
||||
APP_NAME="${{ gitea.repository }}"
|
||||
APP_NAME="${APP_NAME##*/}"
|
||||
APP_NAME="${APP_NAME//\//-}"
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
|
||||
CAPTION="${APP_NAME} ${TAG}"
|
||||
CAPTION="$(printf '%s\n\n%s' "${APP_NAME} ${TAG}" "$(cat release_notes.md)" | head -c 1024)"
|
||||
|
||||
curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument" \
|
||||
-F "chat_id=${TG_CHAT_ID}" \
|
||||
-F "document=@${ASSET_PATH}" \
|
||||
-F "caption=${CAPTION}"
|
||||
--form-string "caption=${CAPTION}"
|
||||
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "sh.sar.basedbank"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 22
|
||||
versionName = "1.0.21"
|
||||
versionCode = 29
|
||||
versionName = "1.0.28"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -14,9 +14,15 @@ internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Headers are sent in the order (and with the names) BML's own app uses. `accept` is not optional:
|
||||
* handled errors come back as JSON either way, but when a route throws (HTTP 500) the server
|
||||
* renders the Internet Banking HTML page — under HTTP 200 — unless the request asks for JSON.
|
||||
*/
|
||||
internal fun bmlApiRequest(session: BmlSession, url: String): Request =
|
||||
Request.Builder().url(url)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("accept", "application/json")
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("user-agent", BML_USER_AGENT)
|
||||
.header("authorization", "Bearer ${session.accessToken}")
|
||||
.build()
|
||||
|
||||
@@ -74,6 +74,13 @@ data class BmlQrPayInfo(
|
||||
val currency: String
|
||||
)
|
||||
|
||||
/**
|
||||
* The pay-request lookup answered `success: false` — [message] is BML's own wording, safe to show
|
||||
* (e.g. code 103 "The payment request has expired", 112 "Unsupported payment link"). Network and
|
||||
* parse failures stay plain exceptions so the generic message is used for those instead.
|
||||
*/
|
||||
class BmlQrPayLookupException(val code: Int, message: String) : Exception(message)
|
||||
|
||||
data class BmlQrPayResult(
|
||||
val success: Boolean,
|
||||
val merchant: String = "",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import android.util.Base64
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
@@ -10,17 +11,27 @@ 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).
|
||||
* Resolves a BML QR to merchant details. [payTarget] is the QR URL, or for POS QRs the bare
|
||||
* `35` → `20` → `01` reference.
|
||||
*
|
||||
* The key is Base64-encoded here without padding, as BML's own app sends it. The padded form
|
||||
* resolves too, so this is parity rather than a requirement.
|
||||
*/
|
||||
fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo {
|
||||
fun lookupPayRequest(session: BmlSession, payTarget: String): BmlQrPayInfo {
|
||||
val key = Base64.encodeToString(
|
||||
payTarget.toByteArray(Charsets.UTF_8), Base64.NO_WRAP or Base64.NO_PADDING)
|
||||
val request = bmlApiRequest(session,
|
||||
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url")
|
||||
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$key")
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string() ?: throw Exception("No response")
|
||||
// A server-side error renders an HTML page under HTTP 200 rather than an error JSON.
|
||||
if (!body.trimStart().startsWith("{"))
|
||||
throw Exception("Unexpected non-JSON response (HTTP ${response.code})")
|
||||
val json = JSONObject(body)
|
||||
if (!json.optBoolean("success"))
|
||||
throw Exception(json.optString("message").ifBlank { "Lookup failed" })
|
||||
throw BmlQrPayLookupException(
|
||||
json.optInt("code"),
|
||||
json.optString("message").ifBlank { "Lookup failed" })
|
||||
val payload = json.getJSONObject("payload")
|
||||
val addr2 = payload.optString("narrative2").trim()
|
||||
val addr3 = payload.optString("narrative3").trim()
|
||||
|
||||
@@ -234,6 +234,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
private fun setupAccountSearch() {
|
||||
binding.tilAccount.setEndIconOnClickListener { performLookup() }
|
||||
binding.etAccount.setOnEditorActionListener { _, actionId, _ ->
|
||||
if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH) {
|
||||
performLookup()
|
||||
true
|
||||
} else false
|
||||
}
|
||||
}
|
||||
|
||||
private fun performLookup() {
|
||||
@@ -387,7 +393,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
// Auto-fill alias with existing alias or name
|
||||
if (binding.etAlias.text.isNullOrBlank()) {
|
||||
binding.etAlias.setText(validation.name)
|
||||
binding.etAlias.setText(sh.sar.basedbank.util.bmlapi.BmlDashboardParser.toTitleCase(validation.name))
|
||||
}
|
||||
binding.etCurrency.setText(validation.currency)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
@@ -128,7 +127,6 @@ class BmlQrPayFragment : Fragment() {
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -141,7 +139,7 @@ class BmlQrPayFragment : Fragment() {
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val info = withContext(Dispatchers.IO) {
|
||||
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
|
||||
try { BmlQrPayClient().lookupPayRequest(session, qrUrl) }
|
||||
catch (_: Exception) { null }
|
||||
}
|
||||
if (_binding == null) return@launch
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
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
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.SheetContactDetailsBinding
|
||||
import sh.sar.basedbank.util.ContactDisplay
|
||||
import sh.sar.basedbank.util.ContactImageCache
|
||||
import sh.sar.basedbank.util.ContactListParser
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.TransferNetwork
|
||||
|
||||
/** Contact details drawer shown when a row in [ContactsFragment] is tapped. */
|
||||
class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
private var _binding: SheetContactDetailsBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
private val contactsFragment get() = parentFragment as? ContactsFragment
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = SheetContactDetailsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val args = requireArguments()
|
||||
val contactId = args.getString(ARG_CONTACT_ID)
|
||||
val profileId = args.getString(ARG_PROFILE_ID)
|
||||
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
|
||||
val contact = contacts.firstOrNull { it.benefNo == contactId && it.profileId == profileId }
|
||||
?.let { ContactListParser.from(it) }
|
||||
if (contact == null) dismissAllowingStateLoss() else bind(contact)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bind(contact: ContactDisplay) {
|
||||
val sizePx = resources.getDimensionPixelSize(android.R.dimen.app_icon_size).coerceAtLeast(96) * 2
|
||||
val photo = contact.imageHash?.let { ContactImageCache.load(requireContext(), it) }
|
||||
binding.ivPhoto.setImageBitmap(photo ?: contactInitialsBitmap(contact.name, contact.bankColor, sizePx))
|
||||
|
||||
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.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()
|
||||
}
|
||||
binding.btnDelete.setOnClickListener {
|
||||
val parent = contactsFragment
|
||||
dismiss()
|
||||
parent?.confirmDelete(contact)
|
||||
}
|
||||
|
||||
binding.tvAccount.text = contact.accountNumber
|
||||
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
|
||||
binding.rowRealName.visibility = if (showRealName) View.VISIBLE else View.GONE
|
||||
|
||||
binding.tvCurrency.text = contact.currency ?: ""
|
||||
binding.rowCurrency.visibility = if (contact.currency != null) View.VISIBLE else View.GONE
|
||||
|
||||
binding.tvBank.text = contact.bankName ?: ""
|
||||
binding.rowBank.visibility = if (contact.bankName != null) View.VISIBLE else View.GONE
|
||||
val bankLogo = bankLogoRes(contact)
|
||||
if (bankLogo != null) {
|
||||
binding.ivBankIcon.setImageResource(bankLogo)
|
||||
binding.ivBankIcon.imageTintList = null
|
||||
}
|
||||
|
||||
binding.sourceSection.visibility = View.GONE
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val appContext = requireContext().applicationContext
|
||||
val source = withContext(Dispatchers.IO) { sourceProfile(appContext, contact) }
|
||||
val b = _binding ?: return@launch
|
||||
if (source == null) return@launch
|
||||
b.ivSourceIcon.setImageResource(source.logoRes)
|
||||
b.tvSource.text = source.name
|
||||
b.tvSourceBank.setText(source.bankNameRes)
|
||||
b.sourceSection.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
private fun sourceProfile(context: Context, contact: ContactDisplay): SourceProfile? {
|
||||
val store = CredentialStore(context)
|
||||
return when (contact.network) {
|
||||
TransferNetwork.BML -> {
|
||||
// BML contacts carry either the loginId or a BML profileId
|
||||
val loginId = store.getBmlLoginIds().firstOrNull { loginId ->
|
||||
loginId == contact.profileId ||
|
||||
store.loadBmlProfiles(loginId).any { it.profileId == contact.profileId }
|
||||
} ?: return null
|
||||
val profile = store.loadBmlProfiles(loginId).firstOrNull { it.profileId == contact.profileId }
|
||||
val fullName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
|
||||
val name = (if (profile != null && profile.profileType == "business") profile.name else fullName ?: profile?.name)
|
||||
?: return null
|
||||
SourceProfile(R.drawable.bml_logo_vector, R.string.bml_name, name)
|
||||
}
|
||||
TransferNetwork.FAHIPAY -> {
|
||||
val name = store.getFahipayLoginIds().firstNotNullOfOrNull { loginId ->
|
||||
store.loadFahipayUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
|
||||
} ?: return null
|
||||
SourceProfile(R.drawable.fahipay_logo, R.string.fahipay_name, name)
|
||||
}
|
||||
else -> {
|
||||
val profile = store.getMibLoginIds().firstNotNullOfOrNull { loginId ->
|
||||
store.loadMibProfiles(loginId).firstOrNull { it.profileId == contact.profileId }
|
||||
} ?: return null
|
||||
SourceProfile(R.drawable.mib_logo, R.string.mib_name, profile.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
Toast.makeText(requireContext(), R.string.contact_account_copied, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_CONTACT_ID = "contact_id"
|
||||
private const val ARG_PROFILE_ID = "profile_id"
|
||||
|
||||
fun newInstance(contact: ContactDisplay) = ContactDetailsSheetFragment().apply {
|
||||
arguments = bundleOf(ARG_CONTACT_ID to contact.id, ARG_PROFILE_ID to contact.profileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,13 @@ import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.ItemContactBinding
|
||||
import sh.sar.basedbank.util.ContactDisplay
|
||||
|
||||
class ContactsAdapter(
|
||||
private val imageCache: MutableMap<String, Bitmap>,
|
||||
private val onImageNeeded: (hash: String) -> Unit,
|
||||
private val onDeleteClick: (ContactDisplay) -> Unit,
|
||||
private val onContactClick: (ContactDisplay) -> Unit,
|
||||
private val onTransferClick: (ContactDisplay) -> Unit
|
||||
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
|
||||
|
||||
@@ -66,12 +65,9 @@ class ContactsAdapter(
|
||||
val pos = holder.bindingAdapterPosition
|
||||
if (pos != RecyclerView.NO_POSITION) onTransferClick(displayed[pos])
|
||||
}
|
||||
binding.btnEditContact.setOnClickListener {
|
||||
Toast.makeText(it.context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
binding.btnDeleteContact.setOnClickListener {
|
||||
binding.root.setOnClickListener {
|
||||
val pos = holder.bindingAdapterPosition
|
||||
if (pos != RecyclerView.NO_POSITION) onDeleteClick(displayed[pos])
|
||||
if (pos != RecyclerView.NO_POSITION) onContactClick(displayed[pos])
|
||||
}
|
||||
binding.root.setOnLongClickListener {
|
||||
val pos = holder.bindingAdapterPosition
|
||||
@@ -107,37 +103,33 @@ class ContactsAdapter(
|
||||
if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
|
||||
binding.btnTransferContact.visibility =
|
||||
if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
|
||||
binding.btnEditContact.visibility =
|
||||
if (contact.canEdit) android.view.View.VISIBLE else android.view.View.GONE
|
||||
binding.btnDeleteContact.visibility =
|
||||
if (contact.canDelete) android.view.View.VISIBLE else android.view.View.GONE
|
||||
|
||||
if (photo != null) {
|
||||
binding.ivContactPhoto.setImageBitmap(photo)
|
||||
} else {
|
||||
val sizePx = binding.ivContactPhoto.context.resources
|
||||
.getDimensionPixelSize(android.R.dimen.app_icon_size)
|
||||
.coerceAtLeast(96)
|
||||
binding.ivContactPhoto.setImageBitmap(
|
||||
makeInitialsBitmap(contact.name, contact.bankColor)
|
||||
contactInitialsBitmap(contact.name, contact.bankColor, sizePx)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
|
||||
val sizePx = binding.ivContactPhoto.context.resources
|
||||
.getDimensionPixelSize(android.R.dimen.app_icon_size)
|
||||
.coerceAtLeast(96)
|
||||
val bgColor = try { Color.parseColor(colorHex) } catch (e: 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun contactInitialsBitmap(name: String, colorHex: String, sizePx: Int): Bitmap {
|
||||
val bgColor = try { Color.parseColor(colorHex) } catch (e: 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
|
||||
}
|
||||
|
||||
@@ -2,13 +2,20 @@ package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Typeface
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
@@ -59,7 +66,7 @@ class ContactsFragment : Fragment() {
|
||||
ContactsAdapter(
|
||||
imageCache = sharedImageCache,
|
||||
onImageNeeded = { hash -> fetchImage(hash) },
|
||||
onDeleteClick = { contact -> confirmDelete(contact) },
|
||||
onContactClick = { contact -> showDetails(contact) },
|
||||
onTransferClick = { contact -> openTransfer(contact) }
|
||||
).also { a ->
|
||||
a.setFilter(page.categoryId, currentSearch)
|
||||
@@ -170,7 +177,12 @@ class ContactsFragment : Fragment() {
|
||||
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
|
||||
}
|
||||
|
||||
private fun openTransfer(contact: ContactDisplay) {
|
||||
private fun showDetails(contact: ContactDisplay) {
|
||||
if (childFragmentManager.findFragmentByTag("contact_details") != null) return
|
||||
ContactDetailsSheetFragment.newInstance(contact).show(childFragmentManager, "contact_details")
|
||||
}
|
||||
|
||||
internal fun openTransfer(contact: ContactDisplay) {
|
||||
val fragment = TransferFragment.newInstance(
|
||||
accountNumber = contact.accountNumber,
|
||||
displayName = contact.name,
|
||||
@@ -181,10 +193,26 @@ class ContactsFragment : Fragment() {
|
||||
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||
}
|
||||
|
||||
private fun confirmDelete(contact: ContactDisplay) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
internal fun confirmDelete(contact: ContactDisplay) {
|
||||
val ctx = requireContext()
|
||||
val errorColor = MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorError)
|
||||
val icon = ContextCompat.getDrawable(ctx, R.drawable.ic_delete)?.mutate()?.apply { setTint(errorColor) }
|
||||
|
||||
val prompt = getString(R.string.contact_delete_message, contact.name)
|
||||
val message = SpannableStringBuilder(prompt).apply {
|
||||
val start = prompt.indexOf(contact.name)
|
||||
if (start >= 0) setSpan(StyleSpan(Typeface.BOLD), start, start + contact.name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
append("\n").append(contact.accountNumber)
|
||||
append("\n\n")
|
||||
val warnStart = length
|
||||
append(getString(R.string.contact_delete_warning))
|
||||
setSpan(ForegroundColorSpan(errorColor), warnStart, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(ctx, R.style.ThemeOverlay_BasedBank_DestructiveDialog)
|
||||
.setIcon(icon)
|
||||
.setTitle(R.string.contact_delete_title)
|
||||
.setMessage(getString(R.string.contact_delete_message, contact.name))
|
||||
.setMessage(message)
|
||||
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
|
||||
@@ -43,10 +43,10 @@ class DashboardFragment : Fragment() {
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||
if (bmlTarget != null) {
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
|
||||
)
|
||||
} else {
|
||||
val qr = PaymvQrParser.parse(raw)
|
||||
@@ -121,7 +121,8 @@ class DashboardFragment : Fragment() {
|
||||
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
||||
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
|
||||
.map { CardItem.Bml(it) }
|
||||
val all = bmlItems + mibItems
|
||||
val rank = credStore.loginRank()
|
||||
val all = (bmlItems + mibItems).sortedBy { rank(it.loginTag) }
|
||||
val defaultNum = credStore.getDefaultCardAccountNumber()
|
||||
val ordered = if (defaultNum != null) {
|
||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||
|
||||
@@ -44,9 +44,11 @@ class FinancingAdapter(mibDeals: List<MibFinanceDeal>) :
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>) {
|
||||
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>, mibFirst: Boolean = true) {
|
||||
expandedPositions.clear()
|
||||
items = mibDeals.map { Item.Mib(it) } + bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
|
||||
val mibItems = mibDeals.map { Item.Mib(it) }
|
||||
val bmlItems = bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
|
||||
items = if (mibFirst) mibItems + bmlItems else bmlItems + mibItems
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.databinding.FragmentFinancingBinding
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
|
||||
class FinancingFragment : Fragment() {
|
||||
|
||||
@@ -67,7 +68,11 @@ class FinancingFragment : Fragment() {
|
||||
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
|
||||
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
|
||||
|
||||
adapter.update(latestMibDeals, bmlLoans)
|
||||
// MIB deals carry no login tag, so order the MIB and BML blocks by each bank's first login
|
||||
val order = CredentialStore(requireContext()).getLoginOrder()
|
||||
val firstMib = order.indexOfFirst { it.startsWith("mib_") }
|
||||
val firstBml = order.indexOfFirst { it.startsWith("bml_") }
|
||||
adapter.update(latestMibDeals, bmlLoans, mibFirst = firstBml < 0 || (firstMib in 0 until firstBml))
|
||||
|
||||
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
|
||||
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
||||
|
||||
@@ -244,9 +244,9 @@ class HomeActivity : AppCompatActivity() {
|
||||
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
|
||||
val cachedMfaisa = AccountCache.loadMfaisa(this, store.getMfaisaLoginIds())
|
||||
val merged = cachedMib + cachedBml + cachedFahipay + cachedMfaisa
|
||||
if (merged.isNotEmpty()) viewModel.accounts.value = merged
|
||||
if (merged.isNotEmpty()) viewModel.accounts.value = merged.filterVisibleAccounts()
|
||||
val cachedCards = CardsCache.load(this)
|
||||
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
||||
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards.filterVisibleCards()
|
||||
val cachedFinancing = FinancingCache.load(this)
|
||||
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
|
||||
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
|
||||
@@ -528,9 +528,9 @@ fun applyNavLabelVisibility() {
|
||||
|
||||
private fun routeSharedQrText(text: String) {
|
||||
val store = CredentialStore(this)
|
||||
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text)
|
||||
if (text.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: text, store.getDefaultCardAccountNumber()))
|
||||
val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text)
|
||||
if (bmlTarget != null) {
|
||||
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber()))
|
||||
return
|
||||
}
|
||||
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
|
||||
@@ -774,47 +774,13 @@ fun applyNavLabelVisibility() {
|
||||
|
||||
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
|
||||
|
||||
val bmlClient = BmlAccountClient()
|
||||
// Hidden profiles are never fetched; unhiding one fetches it on its own (fetchEnabledBmlProfiles)
|
||||
val hiddenProfiles = store.getHiddenBmlProfileIds(loginId)
|
||||
for (profile in savedProfiles) {
|
||||
val saved = store.loadBmlProfileSession(profile.profileId)
|
||||
val refreshToken = store.loadBmlProfileRefreshToken(profile.profileId)
|
||||
if (saved == null) {
|
||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||
.filter { it.profileId == profile.profileId }
|
||||
continue
|
||||
}
|
||||
val expiresAt = store.loadBmlProfileExpiresAt(profile.profileId)
|
||||
val tokenKnownExpired = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
|
||||
|
||||
suspend fun fetchWithSession(session: BmlSession) {
|
||||
bmlClient.checkProfile(session)
|
||||
val accounts = bmlClient.fetchAccounts(session, loginTag, profile.name, profile.profileId)
|
||||
app.bmlSessions[profile.profileId] = session
|
||||
allAccounts += accounts
|
||||
}
|
||||
|
||||
suspend fun tryRefresh() {
|
||||
if (refreshToken == null) throw Exception("No refresh token")
|
||||
val oldSession = BmlSession(saved.first, saved.second, refreshToken)
|
||||
val newSession = app.bmlFlowFor(loginId).refreshSession(oldSession)
|
||||
store.saveBmlProfileSession(profile.profileId, newSession.accessToken, newSession.deviceId)
|
||||
if (newSession.refreshToken.isNotBlank())
|
||||
store.saveBmlProfileRefreshToken(profile.profileId, newSession.refreshToken)
|
||||
if (newSession.expiresAt > 0)
|
||||
store.saveBmlProfileExpiresAt(profile.profileId, newSession.expiresAt)
|
||||
fetchWithSession(newSession)
|
||||
}
|
||||
|
||||
if (profile.profileId in hiddenProfiles) continue
|
||||
try {
|
||||
if (tokenKnownExpired) {
|
||||
tryRefresh()
|
||||
} else {
|
||||
try {
|
||||
fetchWithSession(BmlSession(saved.first, saved.second))
|
||||
} catch (_: AuthExpiredException) {
|
||||
tryRefresh()
|
||||
}
|
||||
}
|
||||
allAccounts += fetchBmlProfileAccounts(store, loginId, profile)
|
||||
?: AccountCache.loadBml(this@HomeActivity, loginId).filter { it.profileId == profile.profileId }
|
||||
} catch (e: java.io.IOException) {
|
||||
refreshErrors.add("NO_INTERNET")
|
||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||
@@ -985,15 +951,17 @@ fun applyNavLabelVisibility() {
|
||||
refreshBmlLoanDetails()
|
||||
for ((loginId, session) in app.mibSessions) {
|
||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||
refreshMibCards(loginId, session, profiles)
|
||||
refreshMibCards(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Filters accounts whose profileId the user has hidden in settings. */
|
||||
/** Filters accounts whose profileId or account number the user has hidden in settings. */
|
||||
private fun List<BankAccount>.filterVisibleAccounts(): List<BankAccount> {
|
||||
val store = CredentialStore(this@HomeActivity)
|
||||
val hiddenAccountNumbers = store.getHiddenAccountNumbers()
|
||||
return filter { acc ->
|
||||
if (acc.accountNumber in hiddenAccountNumbers) return@filter false
|
||||
when (acc.bank) {
|
||||
"MIB" -> {
|
||||
val loginId = acc.loginTag.removePrefix("mib_")
|
||||
@@ -1022,10 +990,140 @@ fun applyNavLabelVisibility() {
|
||||
return filter { it.profileId !in hidden }
|
||||
}
|
||||
|
||||
/** Called by SettingsLoginsFragment after the user changes profile visibility. */
|
||||
fun applyProfileVisibility() {
|
||||
val current = viewModel.accounts.value ?: return
|
||||
viewModel.accounts.value = current.filterVisibleAccounts()
|
||||
/** Drops MIB cards belonging to profiles the user has hidden. */
|
||||
private fun List<sh.sar.basedbank.api.mib.MibCard>.filterVisibleCards(): List<sh.sar.basedbank.api.mib.MibCard> {
|
||||
val store = CredentialStore(this@HomeActivity)
|
||||
return filter { card ->
|
||||
card.profileId.isEmpty() || card.profileId !in store.getHiddenMibProfileIds(card.loginTag.removePrefix("mib_"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by SettingsLoginsFragment after the user hides/unhides profiles or accounts.
|
||||
* Re-filters the in-memory data only — no network. Newly unhidden profiles are fetched
|
||||
* separately via [fetchEnabledMibProfiles] / [fetchEnabledBmlProfiles].
|
||||
*/
|
||||
fun applyVisibility() {
|
||||
val app = application as BasedBankApp
|
||||
viewModel.accounts.value = (app.mibAccounts + app.bmlAccounts + app.fahipayAccounts + app.mfaisaAccounts).filterVisibleAccounts()
|
||||
viewModel.mibCards.value?.let { cards ->
|
||||
val visible = cards.filterVisibleCards()
|
||||
viewModel.mibCards.value = visible
|
||||
CardsCache.save(this, visible)
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches accounts, cards and financing for MIB profiles the user just unhid (hidden ones are skipped on refresh). */
|
||||
fun fetchEnabledMibProfiles(loginId: String, profileIds: Set<String>) {
|
||||
if (profileIds.isEmpty()) return
|
||||
val app = application as BasedBankApp
|
||||
val loginTag = "mib_$loginId"
|
||||
val enabled = (app.mibProfilesMap[loginId] ?: emptyList()).filter { it.profileId in profileIds }
|
||||
binding.refreshIndicator.visibility = View.VISIBLE
|
||||
lifecycleScope.launch {
|
||||
val fresh = withContext(Dispatchers.IO) {
|
||||
val session = app.mibSessions[loginId]
|
||||
if (session != null && enabled.isNotEmpty()) {
|
||||
try {
|
||||
val accounts = app.mibFlowFor(loginId).fetchAllProfiles(session, enabled, loginTag)
|
||||
if (accounts.isNotEmpty()) {
|
||||
return@withContext app.mibAccounts.filter { it.loginTag != loginTag || it.profileId !in profileIds } + accounts
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
// Session expired — log this one login in again (it now includes the unhidden profiles)
|
||||
val store = CredentialStore(this@HomeActivity)
|
||||
val creds = store.loadMibCredentials(loginId) ?: return@withContext null
|
||||
try {
|
||||
val flow = MibLoginFlow(store)
|
||||
val accounts = flow.login(creds.username, creds.passwordHash, creds.otpSeed)
|
||||
app.mibSessions[loginId] = flow.lastSession!!
|
||||
app.mibProfilesMap[loginId] = flow.lastProfiles
|
||||
app.mibLoginFlows[loginId] = flow
|
||||
store.saveMibProfiles(loginId, flow.lastProfiles)
|
||||
app.mibAccounts.filter { it.loginTag != loginTag } + accounts
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
binding.refreshIndicator.visibility = View.GONE
|
||||
if (fresh == null) return@launch
|
||||
app.mibAccounts = fresh
|
||||
AccountCache.save(this@HomeActivity, fresh)
|
||||
applyVisibility()
|
||||
val session = app.mibSessions[loginId] ?: return@launch
|
||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||
refreshMibCards(loginId, session, profiles.filter { it.profileId in profileIds })
|
||||
// Financing deals carry no profile id, so the login's visible profiles are fetched together
|
||||
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches accounts (and loan details / limits) for BML profiles the user just unhid. */
|
||||
fun fetchEnabledBmlProfiles(loginId: String, profileIds: Set<String>) {
|
||||
if (profileIds.isEmpty()) return
|
||||
val app = application as BasedBankApp
|
||||
val store = CredentialStore(this)
|
||||
val enabled = store.loadBmlProfiles(loginId).filter { it.profileId in profileIds }
|
||||
if (enabled.isEmpty()) return
|
||||
binding.refreshIndicator.visibility = View.VISIBLE
|
||||
lifecycleScope.launch {
|
||||
val fetched = withContext(Dispatchers.IO) {
|
||||
enabled.mapNotNull { profile ->
|
||||
try { fetchBmlProfileAccounts(store, loginId, profile)?.let { profile.profileId to it } } catch (_: Exception) { null }
|
||||
}
|
||||
}
|
||||
binding.refreshIndicator.visibility = View.GONE
|
||||
if (fetched.isEmpty()) return@launch
|
||||
val fetchedIds = fetched.map { it.first }.toSet()
|
||||
val loginTag = "bml_$loginId"
|
||||
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != loginTag || it.profileId !in fetchedIds } +
|
||||
fetched.flatMap { it.second }
|
||||
AccountCache.saveBml(this@HomeActivity, loginId, app.bmlAccounts.filter { it.loginTag == loginTag })
|
||||
applyVisibility()
|
||||
fetchedIds.forEach { id -> app.bmlSessions[id]?.let { refreshBmlLimits(it) } }
|
||||
refreshBmlLoanDetails()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one BML profile's accounts with its saved session, refreshing the token when expired.
|
||||
* Returns null when the profile has no saved session; throws on network/server errors.
|
||||
*/
|
||||
private fun fetchBmlProfileAccounts(store: CredentialStore, loginId: String, profile: BmlProfile): List<BankAccount>? {
|
||||
val app = application as BasedBankApp
|
||||
val saved = store.loadBmlProfileSession(profile.profileId) ?: return null
|
||||
val refreshToken = store.loadBmlProfileRefreshToken(profile.profileId)
|
||||
val expiresAt = store.loadBmlProfileExpiresAt(profile.profileId)
|
||||
val tokenKnownExpired = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
|
||||
val bmlClient = BmlAccountClient()
|
||||
|
||||
fun fetchWithSession(session: BmlSession): List<BankAccount> {
|
||||
bmlClient.checkProfile(session)
|
||||
val accounts = bmlClient.fetchAccounts(session, "bml_$loginId", profile.name, profile.profileId)
|
||||
app.bmlSessions[profile.profileId] = session
|
||||
return accounts
|
||||
}
|
||||
|
||||
fun tryRefresh(): List<BankAccount> {
|
||||
if (refreshToken == null) throw Exception("No refresh token")
|
||||
val oldSession = BmlSession(saved.first, saved.second, refreshToken)
|
||||
val newSession = app.bmlFlowFor(loginId).refreshSession(oldSession)
|
||||
store.saveBmlProfileSession(profile.profileId, newSession.accessToken, newSession.deviceId)
|
||||
if (newSession.refreshToken.isNotBlank())
|
||||
store.saveBmlProfileRefreshToken(profile.profileId, newSession.refreshToken)
|
||||
if (newSession.expiresAt > 0)
|
||||
store.saveBmlProfileExpiresAt(profile.profileId, newSession.expiresAt)
|
||||
return fetchWithSession(newSession)
|
||||
}
|
||||
|
||||
return if (tokenKnownExpired) {
|
||||
tryRefresh()
|
||||
} else {
|
||||
try {
|
||||
fetchWithSession(BmlSession(saved.first, saved.second))
|
||||
} catch (_: AuthExpiredException) {
|
||||
tryRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshBmlLimits(session: BmlSession) {
|
||||
@@ -1259,7 +1357,7 @@ fun applyNavLabelVisibility() {
|
||||
val app = application as BasedBankApp
|
||||
for ((loginId, session) in app.mibSessions) {
|
||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||
refreshMibCards(loginId, session, profiles)
|
||||
refreshMibCards(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1284,7 +1382,8 @@ fun applyNavLabelVisibility() {
|
||||
}
|
||||
if (cards.isNotEmpty()) {
|
||||
val existing = viewModel.mibCards.value?.toMutableList() ?: mutableListOf()
|
||||
existing.removeAll { it.loginTag == "mib_$loginId" }
|
||||
val fetchedIds = profiles.map { it.profileId }.toSet()
|
||||
existing.removeAll { it.loginTag == "mib_$loginId" && (it.profileId in fetchedIds || it.profileId.isEmpty()) }
|
||||
existing += cards
|
||||
viewModel.mibCards.postValue(existing)
|
||||
CardsCache.save(this@HomeActivity, existing)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
||||
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
@@ -9,14 +10,21 @@ import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.api.models.BankContactCategory
|
||||
import sh.sar.basedbank.api.mib.MibCard
|
||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
|
||||
sealed class CardItem {
|
||||
data class Mib(val card: MibCard) : CardItem()
|
||||
data class Bml(val account: BankAccount) : CardItem()
|
||||
abstract val loginTag: String
|
||||
data class Mib(val card: MibCard) : CardItem() { override val loginTag get() = card.loginTag }
|
||||
data class Bml(val account: BankAccount) : CardItem() { override val loginTag get() = account.loginTag }
|
||||
}
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
val accounts = MutableLiveData<List<BankAccount>>(emptyList())
|
||||
class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
/** Always kept in the user's login order (Settings > Logins), so every screen lists accounts the same way. */
|
||||
val accounts: MutableLiveData<List<BankAccount>> = object : MutableLiveData<List<BankAccount>>(emptyList()) {
|
||||
override fun setValue(value: List<BankAccount>?) {
|
||||
super.setValue(value?.let { list -> loginRank().let { rank -> list.sortedBy { rank(it.loginTag) } } })
|
||||
}
|
||||
}
|
||||
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
|
||||
/** BML loan details keyed by account internalId. */
|
||||
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
|
||||
@@ -26,7 +34,19 @@ class HomeViewModel : ViewModel() {
|
||||
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
||||
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
||||
|
||||
val mibCards = MutableLiveData<List<MibCard>?>(null)
|
||||
val mibCards: MutableLiveData<List<MibCard>?> = object : MutableLiveData<List<MibCard>?>(null) {
|
||||
override fun setValue(value: List<MibCard>?) {
|
||||
super.setValue(value?.let { list -> loginRank().let { rank -> list.sortedBy { rank(it.loginTag) } } })
|
||||
}
|
||||
}
|
||||
|
||||
fun loginRank(): (String) -> Int = CredentialStore(getApplication()).loginRank()
|
||||
|
||||
/** Re-applies the login order after the user changes it. */
|
||||
fun resortByLoginOrder() {
|
||||
accounts.value = accounts.value
|
||||
mibCards.value = mibCards.value
|
||||
}
|
||||
|
||||
val hideAmounts = MutableLiveData<Boolean>(false)
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||
import sh.sar.basedbank.api.mib.MibProfileClient
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
@@ -32,7 +34,7 @@ class OtpFragment : Fragment() {
|
||||
private var _binding: FragmentOtpBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private data class OtpEntry(val label: String, val seed: String)
|
||||
private data class OtpEntry(val bank: String, val name: String?, val seed: String)
|
||||
|
||||
private inner class OtpAdapter(private val entries: List<OtpEntry>) :
|
||||
RecyclerView.Adapter<OtpAdapter.VH>() {
|
||||
@@ -45,18 +47,17 @@ class OtpFragment : Fragment() {
|
||||
VH(ItemOtpCardBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
holder.b.tvOtpLabel.text = entries[position].label
|
||||
update(holder.b, entries[position].seed)
|
||||
holder.b.root.setOnClickListener {
|
||||
val code = holder.b.tvOtpCode.text.toString().replace(" ", "")
|
||||
if (code.isNotEmpty()) {
|
||||
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
Toast.makeText(it.context, "OTP copied", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
val entry = entries[position]
|
||||
val b = holder.b
|
||||
b.tvOtpBank.text = entry.bank
|
||||
b.tvOtpLabel.text = entry.name ?: "Authenticator"
|
||||
b.ivBankLogo.setImageResource(
|
||||
if (entry.bank == "BML") R.drawable.bml_logo_vector else R.drawable.mib_logo
|
||||
)
|
||||
update(b, entry.seed)
|
||||
b.root.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
|
||||
b.btnCopyOtp.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
|
||||
b.btnCopyNextOtp.setOnClickListener { copyCode(it.context, b.tvNextOtpCode.text, "Next OTP copied") }
|
||||
}
|
||||
|
||||
fun tick() {
|
||||
@@ -71,9 +72,32 @@ class OtpFragment : Fragment() {
|
||||
val secondsInPeriod = (epochSeconds % 30).toInt()
|
||||
val remaining = 30 - secondsInPeriod
|
||||
val code = try { Totp.generate(seed) } catch (_: Exception) { "------" }
|
||||
val next = try { Totp.generate(seed, periodOffset = 1) } catch (_: Exception) { "------" }
|
||||
b.tvOtpCode.text = "${code.take(3)} ${code.drop(3)}"
|
||||
b.tvNextOtpCode.text = "${next.take(3)} ${next.drop(3)}"
|
||||
b.otpProgress.progress = remaining
|
||||
b.tvOtpCountdown.text = "Refreshes in $remaining second${if (remaining == 1) "" else "s"}"
|
||||
b.tvOtpCountdown.text = remaining.toString()
|
||||
|
||||
// Turn the ring and code red in the last few seconds of the window
|
||||
val expiring = remaining <= 5
|
||||
val accent = MaterialColors.getColor(b.root,
|
||||
if (expiring) com.google.android.material.R.attr.colorError else com.google.android.material.R.attr.colorPrimary)
|
||||
b.otpProgress.setIndicatorColor(accent)
|
||||
b.tvOtpCode.setTextColor(accent)
|
||||
b.tvOtpCountdown.setTextColor(
|
||||
if (expiring) accent
|
||||
else MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnSurfaceVariant)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyCode(context: Context, text: CharSequence, message: String) {
|
||||
val code = text.toString().replace(" ", "")
|
||||
if (code.isEmpty() || code.contains('-')) return
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,21 +110,24 @@ class OtpFragment : Fragment() {
|
||||
val store = CredentialStore(requireContext())
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
|
||||
val entries = mutableListOf<OtpEntry>()
|
||||
val rank = store.loginRank()
|
||||
val tagged = mutableListOf<Pair<String, OtpEntry>>()
|
||||
for (loginId in store.getMibLoginIds()) {
|
||||
val creds = store.loadMibCredentials(loginId) ?: continue
|
||||
val name = store.loadMibFullName(loginId)
|
||||
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
|
||||
tagged.add(CredentialStore.loginKey("mib", loginId) to OtpEntry("MIB", name, creds.otpSeed))
|
||||
}
|
||||
for (loginId in store.getBmlLoginIds()) {
|
||||
val creds = store.loadBmlCredentials(loginId) ?: continue
|
||||
val name = store.loadBmlUserProfile(loginId)?.fullName
|
||||
entries.add(OtpEntry(if (!name.isNullOrBlank()) "BML · $name" else "BML", creds.otpSeed))
|
||||
tagged.add(CredentialStore.loginKey("bml", loginId) to OtpEntry("BML", name?.takeIf { it.isNotBlank() }, creds.otpSeed))
|
||||
}
|
||||
val entries = tagged.sortedBy { rank(it.first) }.map { it.second }.toMutableList()
|
||||
|
||||
val adapter = OtpAdapter(entries)
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.emptyState.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE
|
||||
|
||||
// Fetch real names in background if not yet cached, then refresh labels
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
@@ -122,7 +149,7 @@ class OtpFragment : Fragment() {
|
||||
))
|
||||
val seed = store.loadMibCredentials(loginId)?.otpSeed
|
||||
val idx = entries.indexOfFirst { it.seed == seed }
|
||||
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "MIB · ${profile.fullName}"); changed = true }
|
||||
if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,7 +170,7 @@ class OtpFragment : Fragment() {
|
||||
))
|
||||
val seed = store.loadBmlCredentials(loginId)?.otpSeed
|
||||
val idx = entries.indexOfFirst { it.seed == seed }
|
||||
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "BML · ${info.fullName}"); changed = true }
|
||||
if (idx >= 0) { entries[idx] = entries[idx].copy(name = info.fullName); changed = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.text.TextPaint
|
||||
import android.text.TextUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
@@ -48,6 +51,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 +64,28 @@ 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, qrHolderName(it), it.bank) }
|
||||
|
||||
/** Name printed on the card and put in the payload (tag 59). */
|
||||
private fun qrHolderName(account: BankAccount): String = when {
|
||||
// Fahipay's brief name is the generic "Fahipay Wallet"; the holder's name is on the profile
|
||||
account.bank == "FAHIPAY" -> account.profileName.takeIf { it.isNotBlank() && it != "Fahipay" }
|
||||
?: CredentialStore(requireContext())
|
||||
.loadFahipayUserProfile(sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag))
|
||||
?.fullName?.takeIf { it.isNotBlank() }
|
||||
?: account.accountBriefName
|
||||
else -> account.accountBriefName
|
||||
}
|
||||
|
||||
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 +94,15 @@ class PayMvQrFragment : Fragment() {
|
||||
v.updatePadding(bottom = basePaddingBottom + navBarBottom)
|
||||
insets
|
||||
}
|
||||
setupDropdown()
|
||||
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() }
|
||||
@@ -83,6 +117,7 @@ class PayMvQrFragment : Fragment() {
|
||||
val eligible = accounts.filter {
|
||||
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" &&
|
||||
it.bank != "MIB" && // TODO: MIB does not support PayMV QR
|
||||
it.bank != "MFAISA" && // TODO: M-Faisa PayMV QR not implemented yet
|
||||
!(it.bank == "BML" && it.currencyName.contains("USD", ignoreCase = true)) // TODO: BML USD not supported by MMA
|
||||
}
|
||||
val adapter = QrAccountAdapter(requireContext(), eligible)
|
||||
@@ -118,24 +153,26 @@ 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"
|
||||
else -> "MADVMVMV"
|
||||
}
|
||||
val amountFormatted = binding.etAmount.text?.toString()?.trim()
|
||||
?.replace(",", "")
|
||||
val amountRaw = binding.etAmount.text?.toString()?.trim()?.replace(",", "")
|
||||
val amountFormatted = amountRaw
|
||||
?.toDoubleOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
?.let { "%.2f".format(it) }
|
||||
// BML shows the amount on the card as typed (no forced decimals)
|
||||
val amountDisplay = amountRaw?.takeIf { amountFormatted != null }
|
||||
|
||||
val ctx = requireContext()
|
||||
val includePhone = binding.switchIncludePhone.isChecked
|
||||
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
|
||||
val store = CredentialStore(ctx)
|
||||
val mobile = if (includePhone) {
|
||||
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)
|
||||
when (account.bank) {
|
||||
"BML" -> store.loadBmlUserProfile(loginId)?.mobile
|
||||
"FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile
|
||||
@@ -144,17 +181,28 @@ class PayMvQrFragment : Fragment() {
|
||||
when {
|
||||
m.startsWith("+") -> m
|
||||
m.length == 7 -> "+960$m"
|
||||
m.length == 10 && m.startsWith("960") -> "+$m" // Fahipay stores 960XXXXXXX
|
||||
else -> m
|
||||
}
|
||||
}
|
||||
} else null
|
||||
|
||||
val purpose = binding.etReference.text?.toString()?.trim()
|
||||
?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
// The reference (62/05) is also printed vertically beside the QR, as each bank does
|
||||
val reference = when (target.bank) {
|
||||
// BML: base-32 account number followed by the amount as typed
|
||||
"BML" -> ((target.accountNumber.toBigIntegerOrNull()?.toString(32)?.uppercase() ?: "") +
|
||||
(amountDisplay ?: "")).take(25).ifEmpty { generateReference(9) }
|
||||
"FAHIPAY" -> "P" + generateReference(9) // Fahipay's own references are P + 9 chars
|
||||
else -> generateReference(9)
|
||||
}
|
||||
|
||||
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, reference, target.bank)
|
||||
if (target.bank == "FAHIPAY") renderFahipayQrCard(ctx, target, payload, reference)
|
||||
else renderQrCard(ctx, target, payload, reference)
|
||||
}
|
||||
if (_binding == null) return
|
||||
generatedBitmap = bmp
|
||||
@@ -173,14 +221,19 @@ class PayMvQrFragment : Fragment() {
|
||||
acquirer: String,
|
||||
amountStr: String?,
|
||||
mobile: String?,
|
||||
purpose: String
|
||||
purpose: String?,
|
||||
ref: String,
|
||||
bank: String
|
||||
): String {
|
||||
fun tlv(tag: String, value: String): String {
|
||||
val len = value.length
|
||||
return tag + (if (len < 10) "0$len" else "$len") + value
|
||||
}
|
||||
val format = tlv("00", "01")
|
||||
val poi = tlv("01", "11")
|
||||
// Fahipay's own QRs are dynamic (12) when they carry an amount and mask the amount
|
||||
// as "***" when they don't; its scanner may reject QRs that differ
|
||||
val fahipay = bank == "FAHIPAY"
|
||||
val poi = tlv("01", if (fahipay && !amountStr.isNullOrBlank()) "12" else "11")
|
||||
val sub00 = tlv("00", "mv.favara.mpqr")
|
||||
val sub01 = tlv("01", acquirer)
|
||||
val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app
|
||||
@@ -190,21 +243,28 @@ class PayMvQrFragment : Fragment() {
|
||||
val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10)
|
||||
val mcc = tlv("52", "0000")
|
||||
val currency = tlv("53", "462")
|
||||
val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else ""
|
||||
val amountTLV = when {
|
||||
!amountStr.isNullOrBlank() -> tlv("54", amountStr)
|
||||
fahipay -> tlv("54", "***")
|
||||
else -> ""
|
||||
}
|
||||
val country = tlv("58", "MV")
|
||||
val name = tlv("59", accountName.take(25))
|
||||
val ref = generateReference()
|
||||
val addlData = tlv("62", tlv("05", ref) + tlv("08", purpose))
|
||||
val name = tlv("59", accountName.uppercase().take(25))
|
||||
// Fahipay's QRs always carry a city ("LD" + 4 digits) and default the purpose to PAYMENT
|
||||
val city = if (fahipay) tlv("60", "LD" + (0..9999).random().toString().padStart(4, '0')) else ""
|
||||
val purposeText = purpose?.takeIf { it.isNotBlank() } ?: if (fahipay) "PAYMENT" else null
|
||||
val purposeTLV = if (purposeText != null) tlv("08", purposeText) else ""
|
||||
val addlData = tlv("62", tlv("05", ref) + purposeTLV)
|
||||
val timestamp = java.time.LocalDateTime.now()
|
||||
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.00000"))
|
||||
val tag80 = tlv("80", tlv("00", "mv.favara.mpqr") + tlv("01", timestamp))
|
||||
val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + addlData + tag80 + "6304"
|
||||
val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + city + addlData + tag80 + "6304"
|
||||
return prefix + crc16(prefix)
|
||||
}
|
||||
|
||||
private fun generateReference(): String {
|
||||
private fun generateReference(length: Int): String {
|
||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
return (1..9).map { chars.random() }.joinToString("")
|
||||
return (1..length).map { chars.random() }.joinToString("")
|
||||
}
|
||||
|
||||
private fun crc16(data: String): String {
|
||||
@@ -221,88 +281,127 @@ class PayMvQrFragment : Fragment() {
|
||||
|
||||
// ── QR card rendering ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Replicates the BML app's ReceiveCard (React Native, v2.1.47) 1:1. All measurements are in
|
||||
* dp, as in BML's StyleSheet, laid out for BML's reference screen width and drawn at
|
||||
* [PX_PER_DP] pixels per dp.
|
||||
*/
|
||||
private fun renderQrCard(
|
||||
ctx: Context,
|
||||
account: BankAccount,
|
||||
target: QrTarget,
|
||||
qrPayload: String,
|
||||
amountStr: String?
|
||||
qrId: String
|
||||
): Bitmap {
|
||||
val W = 900
|
||||
val H = 1080
|
||||
val outerCorner = 48f
|
||||
val boxBlue = Color.parseColor("#2272B7")
|
||||
val footerBlue = Color.parseColor("#1A5799")
|
||||
val boxL = 24f; val boxT = 110f; val boxR = 876f; val boxB = 962f
|
||||
val sw = SCREEN_WIDTH_DP
|
||||
fun px(dp: Float) = dp * PX_PER_DP
|
||||
val mmaBlue = Color.parseColor("#0E5CA4")
|
||||
|
||||
val bm = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888)
|
||||
val cardW = sw - 48f // screen's horizontal margins, spacing[5] each side
|
||||
val qrSize = sw * 0.5f
|
||||
val railW = sw / 1.85f
|
||||
|
||||
val namePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
textSize = px(14f)
|
||||
typeface = Typeface.DEFAULT
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
val footerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
textSize = px(sw * 0.046f)
|
||||
typeface = ResourcesCompat.getFont(ctx, R.font.sofia_pro_bold) ?: Typeface.DEFAULT_BOLD
|
||||
letterSpacing = 1.2f / (sw * 0.046f) // RN letterSpacing is in dp, Paint's is in em
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
// Android RN Text lines include font padding: line height = bottom - top
|
||||
fun lineHeight(p: Paint) = p.fontMetrics.let { it.bottom - it.top } / PX_PER_DP
|
||||
|
||||
// --- Vertical layout (dp, card-local) ---
|
||||
val topCardTop = 2f
|
||||
val brandTop = topCardTop + 32f // brandRow marginTop spacing[6]
|
||||
val logoBoxW = sw * 0.38f // bml-logo-paymv box: 0.38·sw wide
|
||||
val logoBoxH = logoBoxW * 0.1116751269035533f
|
||||
val payMvBoxW = sw * 0.2f // paymv-logo box: 0.2·sw wide
|
||||
val payMvBoxH = payMvBoxW * 0.17333333333333334f
|
||||
val brandH = maxOf(logoBoxH, payMvBoxH)
|
||||
val nameText = target.name.uppercase()
|
||||
val hasName = nameText.isNotBlank()
|
||||
val qrCardTop = brandTop + brandH + if (hasName) 24f else 32f
|
||||
val nameTop = qrCardTop + 8f + 12f // qrCard paddingTop spacing[2], name marginTop spacing[3]
|
||||
val nameH = if (hasName) lineHeight(namePaint) else 0f
|
||||
val qrTop = if (hasName) nameTop + nameH + 16f else qrCardTop + 37f
|
||||
val qrCardBottom = qrTop + qrSize + 37f // paddingBottom spacing[6] + 5
|
||||
val topCardBottom = qrCardBottom + 24f + 8f // qrCard marginBottom, topCard paddingBottom
|
||||
val footerLineH = lineHeight(footerPaint)
|
||||
val cardH = topCardBottom + 12f + footerLineH + 12f + 2f
|
||||
|
||||
val bm = Bitmap.createBitmap(px(cardW).toInt(), px(cardH).toInt(), Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bm)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
// Clip to outer rounded card shape
|
||||
val outerPath = Path()
|
||||
outerPath.addRoundRect(RectF(0f, 0f, W.toFloat(), H.toFloat()), outerCorner, outerCorner, Path.Direction.CW)
|
||||
canvas.clipPath(outerPath)
|
||||
canvas.drawColor(Color.WHITE)
|
||||
|
||||
// --- Bank logo top-left ---
|
||||
val logoRes = when (account.bank) {
|
||||
"BML" -> R.drawable.bml_logo_vector
|
||||
"MIB" -> R.drawable.mib_faisanet_logo
|
||||
else -> R.drawable.fahipay_logo_long
|
||||
// captureWrapper: mmaBlue, radius 20 — shows as a 2dp border around the white top card
|
||||
val outerPath = Path().apply {
|
||||
addRoundRect(RectF(0f, 0f, px(cardW), px(cardH)), px(20f), px(20f), Path.Direction.CW)
|
||||
}
|
||||
canvas.clipPath(outerPath)
|
||||
canvas.drawColor(mmaBlue)
|
||||
|
||||
// topCard: white, 2dp inset, top corners 18
|
||||
paint.color = Color.WHITE
|
||||
val r = px(18f)
|
||||
canvas.drawPath(Path().apply {
|
||||
addRoundRect(
|
||||
RectF(px(2f), px(topCardTop), px(cardW - 2f), px(topCardBottom)),
|
||||
floatArrayOf(r, r, r, r, 0f, 0f, 0f, 0f), Path.Direction.CW
|
||||
)
|
||||
}, paint)
|
||||
|
||||
// --- brandRow: "BANK OF MALDIVES" wordmark left, "PayMV QR" right, 40dp side margins ---
|
||||
val rowL = 2f + 40f
|
||||
val rowR = cardW - 2f - 40f
|
||||
val rowCenterY = brandTop + brandH / 2
|
||||
val logoRes = if (target.bank == "BML") R.drawable.bml_logo_paymv else R.drawable.mib_faisanet_logo
|
||||
AppCompatResources.getDrawable(ctx, logoRes)?.let { d ->
|
||||
val nW = d.intrinsicWidth.coerceAtLeast(1)
|
||||
val nH = d.intrinsicHeight.coerceAtLeast(1)
|
||||
val maxW = 180f; val maxH = 76f
|
||||
val scale = minOf(maxW / nW, maxH / nH)
|
||||
// resizeMode "contain" inside the logo box, left-aligned in the row
|
||||
val scale = minOf(px(logoBoxW) / nW, px(logoBoxH) / nH)
|
||||
val lW = (nW * scale).toInt()
|
||||
val lH = (nH * scale).toInt()
|
||||
val lTop = ((boxT - lH) / 2).toInt().coerceAtLeast(10)
|
||||
d.setBounds(24, lTop, 24 + lW, lTop + lH)
|
||||
val lLeft = (px(rowL) + (px(logoBoxW) - lW) / 2f).toInt()
|
||||
val lTop = (px(rowCenterY) - lH / 2f).toInt()
|
||||
d.setBounds(lLeft, lTop, lLeft + lW, lTop + lH)
|
||||
d.draw(canvas)
|
||||
}
|
||||
|
||||
// --- "PayMV QR" top-right ---
|
||||
paint.color = Color.parseColor("#1A1A2E")
|
||||
paint.textSize = 36f
|
||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
// BML draws the paymv-logo image (fills its box exactly), nudged down 1dp
|
||||
paint.color = mmaBlue
|
||||
paint.typeface = footerPaint.typeface
|
||||
paint.textAlign = Paint.Align.RIGHT
|
||||
canvas.drawText("PayMV QR", W - 28f, 66f, paint)
|
||||
paint.textSize = px(payMvBoxH)
|
||||
paint.textSize *= px(payMvBoxW) / paint.measureText("PayMV QR")
|
||||
val payMvBounds = Rect().also { paint.getTextBounds("PayMV QR", 0, 8, it) }
|
||||
canvas.drawText(
|
||||
"PayMV QR", px(rowR),
|
||||
px(rowCenterY + 1f) - payMvBounds.exactCenterY(), paint
|
||||
)
|
||||
|
||||
// --- Blue rounded box ---
|
||||
paint.color = boxBlue
|
||||
paint.textAlign = Paint.Align.LEFT
|
||||
canvas.drawRoundRect(RectF(boxL, boxT, boxR, boxB), 36f, 36f, paint)
|
||||
// --- qrCard: mmaBlue, radius 16, 40dp side margins ---
|
||||
val qrCardL = 2f + 40f
|
||||
val qrCardR = cardW - 2f - 40f
|
||||
paint.color = mmaBlue
|
||||
canvas.drawRoundRect(RectF(px(qrCardL), px(qrCardTop), px(qrCardR), px(qrCardBottom)), px(16f), px(16f), paint)
|
||||
|
||||
// Account name (white, bold, uppercase, auto-scaled to fit)
|
||||
paint.color = Color.WHITE
|
||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
paint.textAlign = Paint.Align.CENTER
|
||||
val nameText = account.accountBriefName.uppercase()
|
||||
paint.textSize = 36f
|
||||
val maxNameW = boxR - boxL - 48f
|
||||
if (paint.measureText(nameText) > maxNameW) {
|
||||
paint.textSize = 36f * maxNameW / paint.measureText(nameText)
|
||||
}
|
||||
val nameBaseline = boxT + 68f
|
||||
canvas.drawText(nameText, W / 2f, nameBaseline, paint)
|
||||
|
||||
// Optional amount below name
|
||||
val qrTopY: Float
|
||||
if (!amountStr.isNullOrBlank()) {
|
||||
paint.textSize = 28f
|
||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
|
||||
val amtBaseline = nameBaseline + 42f
|
||||
canvas.drawText("MVR $amountStr", W / 2f, amtBaseline, paint)
|
||||
qrTopY = amtBaseline + 20f
|
||||
} else {
|
||||
qrTopY = nameBaseline + 26f
|
||||
if (hasName) {
|
||||
val maxNameW = px(qrCardR - qrCardL)
|
||||
if (namePaint.measureText(nameText) > maxNameW) {
|
||||
namePaint.textSize *= maxNameW / namePaint.measureText(nameText)
|
||||
}
|
||||
canvas.drawText(nameText, px(cardW / 2), px(nameTop) - namePaint.fontMetrics.top, namePaint)
|
||||
}
|
||||
|
||||
// QR code — white modules on the same blue as the box background
|
||||
val availH = boxB - qrTopY - 24f
|
||||
val qrPx = minOf(availH, boxR - boxL - 48f).toInt().coerceAtMost(700).coerceAtLeast(200)
|
||||
val qrLeft = ((W - qrPx) / 2).toFloat()
|
||||
// QR — white modules on mmaBlue, ECL M, no quiet zone (react-native-qrcode-svg defaults)
|
||||
val qrPx = px(qrSize).toInt()
|
||||
val qrLeft = px(cardW / 2) - qrPx / 2f
|
||||
try {
|
||||
val hints = mapOf(
|
||||
EncodeHintType.MARGIN to 0,
|
||||
@@ -312,23 +411,150 @@ class PayMvQrFragment : Fragment() {
|
||||
val pixels = IntArray(qrPx * qrPx)
|
||||
for (y in 0 until qrPx) {
|
||||
for (x in 0 until qrPx) {
|
||||
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else boxBlue
|
||||
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else mmaBlue
|
||||
}
|
||||
}
|
||||
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
|
||||
canvas.drawBitmap(qrBm, qrLeft, qrTopY, null)
|
||||
canvas.drawBitmap(qrBm, qrLeft, px(qrTop), null)
|
||||
qrBm.recycle()
|
||||
} catch (_: Exception) { /* skip if encoding fails */ }
|
||||
|
||||
// --- Dark blue footer ---
|
||||
paint.color = footerBlue
|
||||
paint.textAlign = Paint.Align.LEFT
|
||||
canvas.drawRect(RectF(0f, 970f, W.toFloat(), H.toFloat()), paint)
|
||||
// qrIdRail: the reference, rotated -90°, beside the QR's right edge
|
||||
if (qrId.isNotEmpty()) {
|
||||
val idPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.BLACK
|
||||
alpha = (255 * 0.8f).toInt()
|
||||
textSize = px(10f)
|
||||
typeface = Typeface.DEFAULT
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
val qrRight = cardW / 2 + qrSize / 2
|
||||
val cx = px(qrRight + railW / 1.37f - railW / 2)
|
||||
val cy = px(qrTop + (qrSize + railW / 1.5f) / 2)
|
||||
val idText = TextUtils.ellipsize(qrId, TextPaint(idPaint), px(railW), TextUtils.TruncateAt.END).toString()
|
||||
canvas.save()
|
||||
canvas.rotate(-90f, cx, cy)
|
||||
val fm = idPaint.fontMetrics
|
||||
canvas.drawText(idText, cx, cy - (fm.bottom + fm.top) / 2, idPaint)
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
// --- footer: SofiaPro-Bold, letterSpacing 1.2 ---
|
||||
canvas.drawText(
|
||||
"MALDIVES NATIONAL QR", px(cardW / 2),
|
||||
px(topCardBottom + 12f) - footerPaint.fontMetrics.top, footerPaint
|
||||
)
|
||||
|
||||
return bm
|
||||
}
|
||||
|
||||
/**
|
||||
* Replicates the card Fahipay's server renders for PayMV QR (api/app/qr/), measured
|
||||
* in pixels on its 1240×1322 image. Fonts follow the BML card (Sofia Pro Bold, Roboto),
|
||||
* except the vertical reference, which is Montserrat as on Fahipay's.
|
||||
*/
|
||||
private fun renderFahipayQrCard(
|
||||
ctx: Context,
|
||||
target: QrTarget,
|
||||
qrPayload: String,
|
||||
reference: String
|
||||
): Bitmap {
|
||||
val w = 1240f
|
||||
val h = 1322f
|
||||
val blue = Color.parseColor("#005DA3")
|
||||
val sofiaBold = ResourcesCompat.getFont(ctx, R.font.sofia_pro_bold) ?: Typeface.DEFAULT_BOLD
|
||||
val montserrat = ResourcesCompat.getFont(ctx, R.font.montserrat_regular) ?: Typeface.DEFAULT
|
||||
|
||||
val bm = Bitmap.createBitmap(w.toInt(), h.toInt(), Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bm)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
// Blue card with a 6px border around the white area; footer is the blue below it
|
||||
canvas.clipPath(Path().apply {
|
||||
addRoundRect(RectF(0f, 0f, w, h), 50f, 50f, Path.Direction.CW)
|
||||
})
|
||||
canvas.drawColor(blue)
|
||||
paint.color = Color.WHITE
|
||||
paint.textSize = 32f
|
||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
paint.textAlign = Paint.Align.CENTER
|
||||
canvas.drawText("MALDIVES NATIONAL QR", W / 2f, 1038f, paint)
|
||||
canvas.drawPath(Path().apply {
|
||||
addRoundRect(
|
||||
RectF(6f, 6f, w - 6f, 1174f),
|
||||
floatArrayOf(44f, 44f, 44f, 44f, 0f, 0f, 0f, 0f), Path.Direction.CW
|
||||
)
|
||||
}, paint)
|
||||
|
||||
// Square app icon, then the "FahiPay" wordmark, in one row
|
||||
AppCompatResources.getDrawable(ctx, R.drawable.fahipay_logo)?.let { d ->
|
||||
d.setBounds(94, 94, 163, 163)
|
||||
d.draw(canvas)
|
||||
}
|
||||
AppCompatResources.getDrawable(ctx, R.drawable.fahipay_logo_long)?.let { d ->
|
||||
val lH = 48
|
||||
val lW = (lH * d.intrinsicWidth.toFloat() / d.intrinsicHeight.coerceAtLeast(1)).toInt()
|
||||
d.setBounds(178, 104, 178 + lW, 104 + lH)
|
||||
d.draw(canvas)
|
||||
}
|
||||
|
||||
// Sized so capitals match the reference's cap heights; positions below are cap tops
|
||||
fun textPaint(tf: Typeface, capH: Float, spacingEm: Float, align: Paint.Align) =
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
typeface = tf
|
||||
letterSpacing = spacingEm
|
||||
textAlign = align
|
||||
textSize = 100f
|
||||
val h = Rect().also { getTextBounds("H", 0, 1, it) }.height().coerceAtLeast(1)
|
||||
textSize = 100f * capH / h
|
||||
}
|
||||
fun Paint.capHeight() = Rect().also { getTextBounds("H", 0, 1, it) }.height()
|
||||
|
||||
// "PayMV QR" top-right
|
||||
val payMvPaint = textPaint(sofiaBold, 30f, 0f, Paint.Align.RIGHT).apply { color = blue }
|
||||
canvas.drawText("PayMV QR", 1147f, 101f + 30f, payMvPaint)
|
||||
|
||||
// Blue QR panel
|
||||
paint.color = blue
|
||||
canvas.drawRoundRect(RectF(166f, 218f, 1074f, 1126f), 55f, 55f, paint)
|
||||
|
||||
// Account name
|
||||
val nameText = target.name.uppercase()
|
||||
if (nameText.isNotBlank()) {
|
||||
val namePaint = textPaint(Typeface.DEFAULT, 30f, 0f, Paint.Align.CENTER).apply { color = Color.WHITE }
|
||||
val maxNameW = 1074f - 166f - 80f
|
||||
if (namePaint.measureText(nameText) > maxNameW) {
|
||||
namePaint.textSize *= maxNameW / namePaint.measureText(nameText)
|
||||
}
|
||||
canvas.drawText(nameText, 619f, 314f + namePaint.capHeight(), namePaint)
|
||||
}
|
||||
|
||||
// QR — white modules on blue, no quiet zone
|
||||
val qrPx = 562
|
||||
try {
|
||||
val hints = mapOf(
|
||||
EncodeHintType.MARGIN to 0,
|
||||
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
|
||||
)
|
||||
val matrix = QRCodeWriter().encode(qrPayload, BarcodeFormat.QR_CODE, qrPx, qrPx, hints)
|
||||
val pixels = IntArray(qrPx * qrPx)
|
||||
for (y in 0 until qrPx) {
|
||||
for (x in 0 until qrPx) {
|
||||
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else blue
|
||||
}
|
||||
}
|
||||
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
|
||||
canvas.drawBitmap(qrBm, 338f, 417f, null)
|
||||
qrBm.recycle()
|
||||
} catch (_: Exception) { /* skip if encoding fails */ }
|
||||
|
||||
// Reference, blue, reading bottom-to-top in the white margin right of the panel,
|
||||
// starting level with y=1087 and with its baseline at x=1171
|
||||
val refPaint = textPaint(montserrat, 27f, 0.005f, Paint.Align.LEFT).apply { color = blue }
|
||||
canvas.save()
|
||||
canvas.rotate(-90f, 1171f, 1087f)
|
||||
canvas.drawText(reference, 1171f, 1087f, refPaint)
|
||||
canvas.restore()
|
||||
|
||||
// Footer
|
||||
val footerPaint = textPaint(sofiaBold, 55.5f, 1.2f / 25.76f, Paint.Align.CENTER).apply { color = Color.WHITE }
|
||||
canvas.drawText("MALDIVES NATIONAL QR", w / 2, 1220f + 55.5f, footerPaint)
|
||||
|
||||
return bm
|
||||
}
|
||||
@@ -337,13 +563,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(
|
||||
@@ -365,11 +591,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 {
|
||||
@@ -411,6 +637,25 @@ 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"
|
||||
|
||||
/** BML's layout reference: the screen width (dp) its ReceiveCard sizes were captured at. */
|
||||
private const val SCREEN_WIDTH_DP = 560f
|
||||
private const val PX_PER_DP = 2f
|
||||
|
||||
/** 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(
|
||||
|
||||
@@ -77,10 +77,10 @@ class CardsFragment : Fragment() {
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||
if (bmlTarget != null) {
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
|
||||
)
|
||||
} else {
|
||||
val qr = PaymvQrParser.parse(raw)
|
||||
@@ -904,7 +904,9 @@ class CardsFragment : Fragment() {
|
||||
val bmlInactive = bmlItems.filter { !it.account.statusDesc.equals("Active", ignoreCase = true) }
|
||||
val mibActive = mibItems.filter { isMibCardActive(it.card.cardStatus) }
|
||||
val mibInactive = mibItems.filter { !isMibCardActive(it.card.cardStatus) }
|
||||
val all: List<CardItem> = bmlActive + mibActive + bmlInactive + mibInactive
|
||||
val rank = store.loginRank()
|
||||
val all: List<CardItem> = (bmlActive + mibActive).sortedBy { rank(it.loginTag) } +
|
||||
(bmlInactive + mibInactive).sortedBy { rank(it.loginTag) }
|
||||
// Move default BML card to front
|
||||
cards = if (defaultNum != null) {
|
||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||
|
||||
@@ -10,8 +10,11 @@ import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.Gravity
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
@@ -30,6 +33,7 @@ import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlProfile
|
||||
import sh.sar.basedbank.api.mib.MibProfile
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.TransactionCache
|
||||
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
||||
import sh.sar.basedbank.ui.login.LoginActivity
|
||||
@@ -359,65 +363,86 @@ class SettingsLoginsFragment : Fragment() {
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private data class LoginEntry(val key: String, val logoRes: Int, val displayName: String, val onClick: () -> Unit)
|
||||
|
||||
private fun buildLoginsSection() {
|
||||
val ctx = requireContext()
|
||||
val store = CredentialStore(ctx)
|
||||
val container = binding.loginsContainer
|
||||
container.removeAllViews()
|
||||
|
||||
val mibLoginIds = store.getMibLoginIds()
|
||||
val bmlLoginIds = store.getBmlLoginIds()
|
||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||
val mfaisaLoginIds = store.getMfaisaLoginIds()
|
||||
val entries = mutableListOf<LoginEntry>()
|
||||
|
||||
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty() || mfaisaLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
|
||||
|
||||
for (loginId in mibLoginIds) {
|
||||
for (loginId in store.getMibLoginIds()) {
|
||||
val profile = store.loadMibUserProfile(loginId)
|
||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
||||
val mibProfiles = store.loadMibProfiles(loginId)
|
||||
addLoginRow(container, R.drawable.mib_logo, displayName) {
|
||||
entries += LoginEntry(CredentialStore.loginKey("mib", loginId), R.drawable.mib_logo, displayName) {
|
||||
showMibLoginDetails(store, loginId, profile, mibProfiles)
|
||||
}
|
||||
}
|
||||
|
||||
for (loginId in bmlLoginIds) {
|
||||
for (loginId in store.getBmlLoginIds()) {
|
||||
val profile = store.loadBmlUserProfile(loginId)
|
||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
||||
val bmlProfiles = store.loadBmlProfiles(loginId)
|
||||
addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
|
||||
entries += LoginEntry(CredentialStore.loginKey("bml", loginId), R.drawable.bml_logo_vector, displayName) {
|
||||
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
|
||||
}
|
||||
}
|
||||
|
||||
for (loginId in fahipayLoginIds) {
|
||||
for (loginId in store.getFahipayLoginIds()) {
|
||||
val profile = store.loadFahipayUserProfile(loginId)
|
||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
||||
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
|
||||
entries += LoginEntry(CredentialStore.loginKey("fahipay", loginId), R.drawable.fahipay_logo, displayName) {
|
||||
showFahipayLoginDetails(store, loginId, profile)
|
||||
}
|
||||
}
|
||||
|
||||
for (loginId in mfaisaLoginIds) {
|
||||
for (loginId in store.getMfaisaLoginIds()) {
|
||||
val profile = store.loadMfaisaUserProfile(loginId)
|
||||
val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name)
|
||||
addLoginRow(container, R.drawable.ooredoo_logo, displayName) {
|
||||
entries += LoginEntry(CredentialStore.loginKey("mfaisa", loginId), R.drawable.ooredoo_logo, displayName) {
|
||||
showMfaisaLoginDetails(store, loginId, profile)
|
||||
}
|
||||
}
|
||||
|
||||
val rank = store.loginRank()
|
||||
val sorted = entries.sortedBy { rank(it.key) }
|
||||
val draggable = sorted.size > 1
|
||||
for (entry in sorted) {
|
||||
addLoginRow(
|
||||
container, entry.logoRes, entry.displayName, entry.onClick,
|
||||
dragTag = entry.key.takeIf { draggable },
|
||||
onReordered = { order ->
|
||||
store.setLoginOrder(order)
|
||||
viewModel.resortByLoginOrder()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
binding.tvLoginsTitle.visibility = if (entries.isNotEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun addLoginRow(container: LinearLayout, logoRes: Int, displayName: String, onClick: () -> Unit) {
|
||||
private fun addLoginRow(
|
||||
container: LinearLayout,
|
||||
logoRes: Int,
|
||||
displayName: String,
|
||||
onClick: () -> Unit,
|
||||
dragTag: String? = null,
|
||||
onReordered: (List<String>) -> Unit = {}
|
||||
) {
|
||||
val ctx = requireContext()
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val row = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(0, (12 * dp).toInt(), 0, (12 * dp).toInt())
|
||||
setPadding((12 * dp).toInt(), (12 * dp).toInt(), (12 * dp).toInt(), (12 * dp).toInt())
|
||||
isClickable = true; isFocusable = true
|
||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||
background = ta.getDrawable(0); ta.recycle()
|
||||
setOnClickListener { onClick() }
|
||||
tag = dragTag
|
||||
}
|
||||
val logo = ImageView(ctx).apply {
|
||||
setImageResource(logoRes)
|
||||
@@ -430,9 +455,195 @@ class SettingsLoginsFragment : Fragment() {
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
row.addView(logo); row.addView(tvName)
|
||||
if (dragTag != null) {
|
||||
val handle = ImageView(ctx).apply {
|
||||
setImageResource(R.drawable.ic_reorder_lines)
|
||||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||||
contentDescription = getString(R.string.drag_to_reorder)
|
||||
layoutParams = LinearLayout.LayoutParams((40 * dp).toInt(), (40 * dp).toInt()).apply { marginStart = (4 * dp).toInt() }
|
||||
}
|
||||
row.addView(handle)
|
||||
attachDragHandle(handle, row, container, onReordered)
|
||||
}
|
||||
container.addView(row)
|
||||
}
|
||||
|
||||
/** Drag [row] vertically within [group] by its [handle]; rows are identified by their String tag. */
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun attachDragHandle(handle: View, row: View, group: LinearLayout, onReordered: (List<String>) -> Unit) {
|
||||
val ctx = handle.context
|
||||
val lift = 8 * ctx.resources.displayMetrics.density
|
||||
val dragBg = android.graphics.drawable.ColorDrawable(
|
||||
com.google.android.material.color.MaterialColors.getColor(
|
||||
ctx, com.google.android.material.R.attr.colorSurfaceContainerHigh, android.graphics.Color.LTGRAY))
|
||||
var lastY = 0f
|
||||
var startIndex = 0
|
||||
var savedBg: android.graphics.drawable.Drawable? = null
|
||||
|
||||
handle.setOnTouchListener { v, ev ->
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
v.parent.requestDisallowInterceptTouchEvent(true)
|
||||
lastY = ev.rawY
|
||||
startIndex = group.indexOfChild(row)
|
||||
row.animate().cancel()
|
||||
savedBg = row.background
|
||||
row.background = dragBg
|
||||
row.translationZ = lift
|
||||
v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
row.translationY += ev.rawY - lastY
|
||||
lastY = ev.rawY
|
||||
var index = group.indexOfChild(row)
|
||||
while (index < group.childCount - 1) {
|
||||
val next = group.getChildAt(index + 1)
|
||||
if (row.translationY <= next.height / 2f) break
|
||||
group.removeView(next); group.addView(next, index)
|
||||
row.translationY -= next.height
|
||||
next.translationY = row.height.toFloat()
|
||||
next.animate().translationY(0f).setDuration(150).start()
|
||||
index++
|
||||
}
|
||||
while (index > 0) {
|
||||
val prev = group.getChildAt(index - 1)
|
||||
if (row.translationY >= -prev.height / 2f) break
|
||||
group.removeView(prev); group.addView(prev, index)
|
||||
row.translationY += prev.height
|
||||
prev.translationY = -row.height.toFloat()
|
||||
prev.animate().translationY(0f).setDuration(150).start()
|
||||
index--
|
||||
}
|
||||
if (index == 0) row.translationY = row.translationY.coerceAtLeast(0f)
|
||||
if (index == group.childCount - 1) row.translationY = row.translationY.coerceAtMost(0f)
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
v.parent.requestDisallowInterceptTouchEvent(false)
|
||||
row.animate().translationY(0f).translationZ(0f).setDuration(150)
|
||||
.withEndAction { row.background = savedBg }
|
||||
.start()
|
||||
if (group.indexOfChild(row) != startIndex) {
|
||||
onReordered((0 until group.childCount).mapNotNull { group.getChildAt(it).tag as? String })
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/** A single account row with a visibility toggle; [indent] nests it under a parent profile row (tree view). */
|
||||
private fun addAccountRow(
|
||||
ctx: Context,
|
||||
container: LinearLayout,
|
||||
dp: Float,
|
||||
acc: BankAccount,
|
||||
hiddenAccounts: MutableSet<String>,
|
||||
indent: Boolean
|
||||
): Pair<BankAccount, MaterialSwitch> {
|
||||
val row = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||
it.bottomMargin = (4 * dp).toInt()
|
||||
if (indent) it.marginStart = (28 * dp).toInt()
|
||||
}
|
||||
}
|
||||
val textCol = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
val nameAppearance = if (indent) com.google.android.material.R.style.TextAppearance_Material3_BodySmall
|
||||
else com.google.android.material.R.style.TextAppearance_Material3_BodyMedium
|
||||
textCol.addView(TextView(ctx).apply {
|
||||
text = acc.accountBriefName.ifBlank { acc.accountTypeName.ifBlank { acc.accountNumber } }
|
||||
setTextAppearance(nameAppearance)
|
||||
})
|
||||
val typeLabel = sh.sar.basedbank.util.AccountListParser.from(acc)?.typeLabel
|
||||
?: if (acc.bank == "BML") sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(acc.accountTypeName)
|
||||
else acc.accountTypeName.trim()
|
||||
textCol.addView(TextView(ctx).apply {
|
||||
text = listOfNotNull(acc.accountNumber, typeLabel.takeIf { it.isNotBlank() }, acc.currencyName.takeIf { it.isNotBlank() })
|
||||
.joinToString(" · ")
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||
alpha = 0.6f
|
||||
})
|
||||
val toggle = MaterialSwitch(ctx).apply {
|
||||
isChecked = acc.accountNumber !in hiddenAccounts
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
marginStart = (4 * dp).toInt()
|
||||
}
|
||||
}
|
||||
row.addView(textCol)
|
||||
row.addView(toggle)
|
||||
container.addView(row)
|
||||
return acc to toggle
|
||||
}
|
||||
|
||||
/** Nests [accounts] directly under their parent profile row, without a header — the tree's leaves. */
|
||||
private fun addNestedAccountRows(
|
||||
ctx: Context,
|
||||
container: LinearLayout,
|
||||
dp: Float,
|
||||
accounts: List<BankAccount>,
|
||||
hiddenAccounts: MutableSet<String>
|
||||
): List<Pair<BankAccount, MaterialSwitch>> =
|
||||
accounts.map { addAccountRow(ctx, container, dp, it, hiddenAccounts, indent = true) }
|
||||
|
||||
/** Builds a headered, flat "Accounts" section for accounts with no profile to nest under. */
|
||||
private fun addAccountsSection(
|
||||
ctx: Context,
|
||||
container: LinearLayout,
|
||||
dp: Float,
|
||||
accounts: List<BankAccount>,
|
||||
hiddenAccounts: MutableSet<String>,
|
||||
showDivider: Boolean
|
||||
): List<Pair<BankAccount, MaterialSwitch>> {
|
||||
if (accounts.isEmpty()) return emptyList()
|
||||
if (showDivider) {
|
||||
container.addView(View(ctx).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
|
||||
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
|
||||
}
|
||||
setBackgroundColor(0x1F000000)
|
||||
})
|
||||
}
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = getString(R.string.accounts)
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||
it.bottomMargin = (8 * dp).toInt()
|
||||
}
|
||||
})
|
||||
return accounts.map { addAccountRow(ctx, container, dp, it, hiddenAccounts, indent = false) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Unchecks and disables account toggles whose parent profile is hidden, and restores the
|
||||
* account's own choice once the profile is shown again. [hiddenAccounts] is left untouched —
|
||||
* the account listeners ignore changes made while a toggle is disabled.
|
||||
*/
|
||||
private fun syncAccountToggles(
|
||||
accountRows: List<Pair<BankAccount, MaterialSwitch>>,
|
||||
hiddenAccounts: Set<String>,
|
||||
isParentHidden: (BankAccount) -> Boolean
|
||||
) {
|
||||
accountRows.forEach { (acc, toggle) ->
|
||||
if (isParentHidden(acc)) {
|
||||
toggle.isEnabled = false
|
||||
toggle.isChecked = false
|
||||
} else {
|
||||
toggle.isChecked = acc.accountNumber !in hiddenAccounts
|
||||
toggle.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Merges this login's account-hide choices into the global hidden-accounts set, leaving other logins untouched. */
|
||||
private fun persistHiddenAccounts(store: CredentialStore, scopedAccounts: List<BankAccount>, hiddenAccounts: Set<String>) {
|
||||
val scopedNumbers = scopedAccounts.map { it.accountNumber }.toSet()
|
||||
store.setHiddenAccountNumbers((store.getHiddenAccountNumbers() - scopedNumbers) + hiddenAccounts)
|
||||
}
|
||||
|
||||
private fun showMibLoginDetails(
|
||||
store: CredentialStore,
|
||||
loginId: String,
|
||||
@@ -443,6 +654,10 @@ class SettingsLoginsFragment : Fragment() {
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val originalHidden = store.getHiddenMibProfileIds(loginId)
|
||||
val hidden = originalHidden.toMutableSet()
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val loginAccounts = app.mibAccounts.filter { it.loginTag == "mib_$loginId" }
|
||||
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
|
||||
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
|
||||
|
||||
val scroll = android.widget.ScrollView(ctx)
|
||||
val container = LinearLayout(ctx).apply {
|
||||
@@ -485,7 +700,10 @@ class SettingsLoginsFragment : Fragment() {
|
||||
})
|
||||
}
|
||||
|
||||
// Build toggle rows — wired up after dialog.show() so we can reference the Save button
|
||||
// Build toggle rows — wired up after dialog.show() so we can reference the Save button.
|
||||
// Each profile's own accounts nest directly beneath it (tree view), since one profile
|
||||
// can have multiple accounts.
|
||||
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
|
||||
val toggleRows = mibProfiles.map { p ->
|
||||
val row = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
@@ -526,16 +744,25 @@ class SettingsLoginsFragment : Fragment() {
|
||||
row.addView(pencil)
|
||||
row.addView(toggle)
|
||||
container.addView(row)
|
||||
accountRows += addNestedAccountRows(ctx, container, dp, loginAccounts.filter { it.profileId == p.profileId }, hiddenAccounts)
|
||||
p to toggle
|
||||
}
|
||||
|
||||
// Accounts that don't belong to any known profile still need to be reachable.
|
||||
val unassignedAccounts = loginAccounts.filter { acc -> mibProfiles.none { it.profileId == acc.profileId } }
|
||||
accountRows += addAccountsSection(
|
||||
ctx, container, dp, unassignedAccounts, hiddenAccounts,
|
||||
showDivider = mibProfiles.isNotEmpty() || profile != null
|
||||
)
|
||||
|
||||
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||
val visibleCount = mibProfiles.count { it.profileId !in hidden }
|
||||
toggleRows.forEach { (p, toggle) ->
|
||||
// Disable the sole remaining visible toggle so it can't be turned off
|
||||
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||
}
|
||||
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
||||
syncAccountToggles(accountRows, hiddenAccounts) { it.profileId in hidden }
|
||||
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
|
||||
}
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
@@ -559,11 +786,22 @@ class SettingsLoginsFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
accountRows.forEach { (acc, toggle) ->
|
||||
toggle.setOnCheckedChangeListener { _, checked ->
|
||||
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden parent profile
|
||||
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
|
||||
updateToggleStates(saveBtn)
|
||||
}
|
||||
}
|
||||
|
||||
saveBtn.setOnClickListener {
|
||||
store.setHiddenMibProfileIds(loginId, hidden)
|
||||
clearAllCaches(ctx)
|
||||
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
|
||||
dialog.dismiss()
|
||||
(activity as? HomeActivity)?.relogin()
|
||||
// Hiding is applied offline; only profiles that were just unhidden get fetched
|
||||
val home = activity as? HomeActivity
|
||||
home?.applyVisibility()
|
||||
home?.fetchEnabledMibProfiles(loginId, originalHidden - hidden)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,6 +823,10 @@ class SettingsLoginsFragment : Fragment() {
|
||||
if (hidden.add(id)) store.setHiddenBmlProfileIds(loginId, hidden)
|
||||
}
|
||||
val originalHidden = hidden.toSet()
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val loginAccounts = app.bmlAccounts.filter { it.loginTag == "bml_$loginId" }
|
||||
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
|
||||
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
|
||||
|
||||
val scroll = android.widget.ScrollView(ctx)
|
||||
val container = LinearLayout(ctx).apply {
|
||||
@@ -630,6 +872,9 @@ class SettingsLoginsFragment : Fragment() {
|
||||
})
|
||||
}
|
||||
|
||||
// Each profile's own accounts nest directly beneath it (tree view), since one profile
|
||||
// can have multiple accounts.
|
||||
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
|
||||
val toggleRows = bmlProfiles.map { p ->
|
||||
val avatarIv = makeCircleAvatarView(ctx, 36)
|
||||
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
|
||||
@@ -677,15 +922,24 @@ class SettingsLoginsFragment : Fragment() {
|
||||
row.addView(pencil)
|
||||
row.addView(toggle)
|
||||
container.addView(row)
|
||||
accountRows += addNestedAccountRows(ctx, container, dp, loginAccounts.filter { it.profileId == p.profileId }, hiddenAccounts)
|
||||
p to toggle
|
||||
}
|
||||
|
||||
// Accounts that don't belong to any known profile still need to be reachable.
|
||||
val unassignedAccounts = loginAccounts.filter { acc -> bmlProfiles.none { it.profileId == acc.profileId } }
|
||||
accountRows += addAccountsSection(
|
||||
ctx, container, dp, unassignedAccounts, hiddenAccounts,
|
||||
showDivider = bmlProfiles.isNotEmpty() || profile != null
|
||||
)
|
||||
|
||||
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||
val visibleCount = bmlProfiles.count { it.profileId !in hidden }
|
||||
toggleRows.forEach { (_, toggle) ->
|
||||
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||
}
|
||||
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
||||
syncAccountToggles(accountRows, hiddenAccounts) { it.profileId in hidden }
|
||||
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
|
||||
}
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
@@ -720,11 +974,22 @@ class SettingsLoginsFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
accountRows.forEach { (acc, toggle) ->
|
||||
toggle.setOnCheckedChangeListener { _, checked ->
|
||||
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden parent profile
|
||||
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
|
||||
updateToggleStates(saveBtn)
|
||||
}
|
||||
}
|
||||
|
||||
saveBtn.setOnClickListener {
|
||||
store.setHiddenBmlProfileIds(loginId, hidden)
|
||||
clearAllCaches(ctx)
|
||||
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
|
||||
dialog.dismiss()
|
||||
(activity as? HomeActivity)?.relogin()
|
||||
// Hiding is applied offline; only profiles that were just unhidden get fetched
|
||||
val home = activity as? HomeActivity
|
||||
home?.applyVisibility()
|
||||
home?.fetchEnabledBmlProfiles(loginId, originalHidden - hidden)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -947,6 +1212,10 @@ class SettingsLoginsFragment : Fragment() {
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val masked = "••••••"
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val loginAccounts = app.fahipayAccounts.filter { it.loginTag == "fahipay_$loginId" }
|
||||
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
|
||||
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
|
||||
|
||||
val scroll = android.widget.ScrollView(ctx)
|
||||
val container = LinearLayout(ctx).apply {
|
||||
@@ -997,14 +1266,37 @@ class SettingsLoginsFragment : Fragment() {
|
||||
})
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
val accountRows = addAccountsSection(ctx, container, dp, loginAccounts, hiddenAccounts, showDivider = true)
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(getString(R.string.fahipay_name))
|
||||
.setView(scroll)
|
||||
.setPositiveButton(R.string.close, null)
|
||||
.setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
|
||||
.apply {
|
||||
if (loginAccounts.isNotEmpty()) setPositiveButton(R.string.save, null)
|
||||
setNeutralButton(R.string.close, null)
|
||||
setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
|
||||
}
|
||||
}
|
||||
.show()
|
||||
|
||||
if (loginAccounts.isNotEmpty()) {
|
||||
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||
saveBtn.isEnabled = false
|
||||
|
||||
accountRows.forEach { (acc, toggle) ->
|
||||
toggle.setOnCheckedChangeListener { _, checked ->
|
||||
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
|
||||
saveBtn.isEnabled = hiddenAccounts != originalHiddenAccounts
|
||||
}
|
||||
}
|
||||
|
||||
saveBtn.setOnClickListener {
|
||||
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
|
||||
dialog.dismiss()
|
||||
(activity as? HomeActivity)?.applyVisibility()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
||||
@@ -1087,6 +1379,8 @@ class SettingsLoginsFragment : Fragment() {
|
||||
val pockets = sh.sar.basedbank.util.AccountCache.loadMfaisa(ctx, loginId)
|
||||
val hidden = store.getHiddenMfaisaPocketIds(loginId).toMutableSet()
|
||||
val originalHidden = hidden.toSet()
|
||||
val originalHiddenAccounts = pockets.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
|
||||
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
|
||||
|
||||
// The user-visible "profiles" are: M-Faisa (every non-PayPal pocket) and PayPal (if linked).
|
||||
// Each toggle covers the set of pocket account numbers that belong to that profile.
|
||||
@@ -1142,6 +1436,9 @@ class SettingsLoginsFragment : Fragment() {
|
||||
})
|
||||
}
|
||||
|
||||
// Each group's own pockets nest directly beneath it (tree view), since a group
|
||||
// ("M-Faisa" / "PayPal") can hold multiple pocket accounts.
|
||||
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
|
||||
val toggleRows = profileRows.map { row ->
|
||||
val v = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
@@ -1164,6 +1461,7 @@ class SettingsLoginsFragment : Fragment() {
|
||||
v.addView(label)
|
||||
v.addView(toggle)
|
||||
container.addView(v)
|
||||
accountRows += addNestedAccountRows(ctx, container, dp, pockets.filter { it.accountNumber in row.pocketIds }, hiddenAccounts)
|
||||
row to toggle
|
||||
}
|
||||
|
||||
@@ -1172,7 +1470,8 @@ class SettingsLoginsFragment : Fragment() {
|
||||
toggleRows.forEach { (_, toggle) ->
|
||||
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||
}
|
||||
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
||||
syncAccountToggles(accountRows, hiddenAccounts) { it.accountNumber in hidden }
|
||||
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
|
||||
}
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
@@ -1199,11 +1498,20 @@ class SettingsLoginsFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
accountRows.forEach { (acc, toggle) ->
|
||||
toggle.setOnCheckedChangeListener { _, checked ->
|
||||
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden pocket group
|
||||
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
|
||||
updateToggleStates(saveBtn)
|
||||
}
|
||||
}
|
||||
|
||||
saveBtn.setOnClickListener {
|
||||
// All pockets come back in one login response, so hiding/unhiding never needs a request
|
||||
store.setHiddenMfaisaPocketIds(loginId, hidden)
|
||||
clearAllCaches(ctx)
|
||||
persistHiddenAccounts(store, pockets, hiddenAccounts)
|
||||
dialog.dismiss()
|
||||
(activity as? HomeActivity)?.relogin()
|
||||
(activity as? HomeActivity)?.applyVisibility()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,8 @@ data class TransferReceiptData(
|
||||
// MIB receipt fields
|
||||
val mibReferenceNo: String = "",
|
||||
val mibTransactionDate: String = "",
|
||||
val mibFromProfileName: String = "",
|
||||
val mibTransactionType: String = "", // "Own Transfer", "MIB Transfer", "Quick Transfer"
|
||||
// BML receipt fields
|
||||
val bmlFromName: String = "",
|
||||
val bmlReference: String = "",
|
||||
|
||||
@@ -11,23 +11,20 @@ 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.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.button.MaterialButton
|
||||
@@ -37,6 +34,8 @@ 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.DialogReceiptFullscreenBmlBinding
|
||||
import sh.sar.basedbank.databinding.DialogReceiptFullscreenMibBinding
|
||||
import sh.sar.basedbank.databinding.FragmentReceiptBmlBinding
|
||||
import sh.sar.basedbank.databinding.FragmentReceiptMfaisaBinding
|
||||
import sh.sar.basedbank.databinding.FragmentReceiptMibBinding
|
||||
@@ -65,6 +64,8 @@ class TransferReceiptFragment : Fragment() {
|
||||
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_MIB_FROM_PROFILE = "mib_from_profile"
|
||||
private const val ARG_MIB_TXN_TYPE = "mib_txn_type"
|
||||
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"
|
||||
@@ -93,6 +94,8 @@ class TransferReceiptFragment : Fragment() {
|
||||
putString(ARG_REMARKS, data.remarks)
|
||||
putString(ARG_MIB_REF, data.mibReferenceNo)
|
||||
putString(ARG_MIB_DATE, data.mibTransactionDate)
|
||||
putString(ARG_MIB_FROM_PROFILE, data.mibFromProfileName)
|
||||
putString(ARG_MIB_TXN_TYPE, data.mibTransactionType)
|
||||
putString(ARG_BML_FROM_NAME, data.bmlFromName)
|
||||
putString(ARG_BML_REFERENCE, data.bmlReference)
|
||||
putString(ARG_BML_TIMESTAMP, data.bmlTimestamp)
|
||||
@@ -159,9 +162,6 @@ class TransferReceiptFragment : Fragment() {
|
||||
}
|
||||
})
|
||||
|
||||
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
|
||||
parentFragmentManager.popBackStack()
|
||||
}
|
||||
view.findViewById<MaterialButton>(R.id.btnShare).setOnClickListener {
|
||||
shareReceipt()
|
||||
}
|
||||
@@ -175,43 +175,64 @@ class TransferReceiptFragment : Fragment() {
|
||||
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.ivFromAvatar.setImageBitmap(makeMibInitialsBitmap(fromLabel))
|
||||
binding.tvFromLabel.text = fromLabel
|
||||
if (fromProfileHash != null) {
|
||||
loadProfileImage(fromProfileHash, isProfile = true) { binding.ivFromAvatar.setImageBitmap(it) }
|
||||
loadProfileImage(fromProfileHash, isProfile = true) { binding.ivFromAvatar.setImageBitmap(circleCrop(it)) }
|
||||
}
|
||||
|
||||
// To avatar: use already-rendered bitmap from TransferFragment if available
|
||||
val toAvatar = pendingToAvatarBitmap
|
||||
if (toAvatar != null) {
|
||||
binding.ivToAvatar.setImageBitmap(toAvatar)
|
||||
binding.ivToAvatar.setImageBitmap(circleCrop(toAvatar))
|
||||
} else {
|
||||
binding.ivToAvatar.setImageBitmap(makeInitialsBitmap(toLabel, "#607D8B"))
|
||||
binding.ivToAvatar.setImageBitmap(makeMibInitialsBitmap(toLabel))
|
||||
}
|
||||
binding.tvToLabel.text = toLabel
|
||||
|
||||
binding.tvAmount.text = "$currency $amount"
|
||||
|
||||
val toBank = args.getString(ARG_TO_BANK, "")
|
||||
val rawDate = args.getString(ARG_MIB_DATE, "")
|
||||
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.tvFromName.text = args.getString(ARG_MIB_FROM_PROFILE, "").ifBlank { fromLabel }
|
||||
binding.tvToAccount.text = listOf(toLabel, args.getString(ARG_TO_ACCOUNT, ""))
|
||||
.filter { it.isNotBlank() }.joinToString("\n")
|
||||
binding.tvToBank.text = toBank
|
||||
// Receipts saved before the type was recorded fall back to a guess from the bank
|
||||
binding.tvTransactionType.text = args.getString(ARG_MIB_TXN_TYPE, "").ifBlank {
|
||||
if (toBank == "MIB") "MIB Transfer" else "Quick Transfer"
|
||||
}
|
||||
binding.tvTransactionDate.text = formatMibDate(rawDate, "dd MMM yyyy HH:mm")
|
||||
binding.tvValueDate.text = formatMibDate(rawDate, "dd MMM yyyy")
|
||||
binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
|
||||
.takeUnless { it.isNullOrBlank() || it.trim() == "-" } ?: "N/A"
|
||||
|
||||
copyOnLongClick(
|
||||
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount,
|
||||
binding.tvReferenceNo, binding.tvToAccount, binding.tvToBank,
|
||||
binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
|
||||
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount, binding.tvStatus,
|
||||
binding.tvReferenceNo, binding.tvFromName, binding.tvToAccount, binding.tvToBank,
|
||||
binding.tvTransactionType, binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
|
||||
)
|
||||
}
|
||||
|
||||
/** Reformats the MIB transfer date ("2026-05-16 15:10:25") to [pattern]; raw text if unparseable. */
|
||||
private fun formatMibDate(raw: String, pattern: String): String {
|
||||
if (raw.isBlank()) return ""
|
||||
val out = DateTimeFormatter.ofPattern(pattern, Locale.US)
|
||||
for (inPattern in listOf("yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "dd MMM yyyy HH:mm")) {
|
||||
try {
|
||||
return java.time.LocalDateTime.parse(raw.trim(), DateTimeFormatter.ofPattern(inPattern, Locale.US)).format(out)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val sess = app.anyMibSession() ?: return
|
||||
@@ -377,21 +398,45 @@ class TransferReceiptFragment : Fragment() {
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Captures the receipt card using PixelCopy, which correctly handles
|
||||
* hardware-accelerated views (avoids the black-square problem with view.draw()).
|
||||
* Draws the receipt card to an offscreen bitmap at its natural (unscaled)
|
||||
* dimensions, so the captured image isn't affected by the on-screen scale
|
||||
* applied to fit small viewports and doesn't pick up overlapping siblings.
|
||||
*/
|
||||
private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) {
|
||||
val view = _receiptCard ?: run { callback(null); return }
|
||||
if (view.width == 0 || view.height == 0) { callback(null); return }
|
||||
val shown = _receiptCard ?: run { callback(null); return }
|
||||
if (shown.width == 0 || shown.height == 0) { callback(null); return }
|
||||
|
||||
// BML: the preview follows the app theme, but shared/saved images are always light
|
||||
val view = if (arguments?.getString(ARG_BANK, "MIB") == "BML") {
|
||||
inflateLightBmlCard(shown.width)
|
||||
} else shown
|
||||
|
||||
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)
|
||||
val canvas = Canvas(bitmap)
|
||||
canvas.drawColor(Color.WHITE)
|
||||
view.draw(canvas)
|
||||
callback(bitmap)
|
||||
}
|
||||
|
||||
PixelCopy.request(requireActivity().window, srcRect, bitmap, { result ->
|
||||
callback(if (result == PixelCopy.SUCCESS) bitmap else null)
|
||||
}, Handler(Looper.getMainLooper()))
|
||||
/** Inflates and lays out an offscreen BML receipt card with light-mode resources. */
|
||||
private fun inflateLightBmlCard(widthPx: Int): View {
|
||||
val config = android.content.res.Configuration(resources.configuration).apply {
|
||||
uiMode = (uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK.inv()) or
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||
}
|
||||
val lightCtx = android.view.ContextThemeWrapper(requireContext(), R.style.Theme_BasedBank).apply {
|
||||
applyOverrideConfiguration(config)
|
||||
}
|
||||
val binding = FragmentReceiptBmlBinding.inflate(LayoutInflater.from(lightCtx))
|
||||
bindBml(binding)
|
||||
val card = binding.receiptCard
|
||||
(card.parent as? ViewGroup)?.removeView(card)
|
||||
card.measure(
|
||||
View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY),
|
||||
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
||||
)
|
||||
card.layout(0, 0, card.measuredWidth, card.measuredHeight)
|
||||
return card
|
||||
}
|
||||
|
||||
private fun formatBmlTimestamp(raw: String): String {
|
||||
@@ -403,26 +448,46 @@ class TransferReceiptFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
|
||||
/** Center-crops [src] to a square and masks it to a circle. */
|
||||
private fun circleCrop(src: Bitmap): Bitmap {
|
||||
val size = minOf(src.width, src.height)
|
||||
val out = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply {
|
||||
shader = android.graphics.BitmapShader(src, android.graphics.Shader.TileMode.CLAMP, android.graphics.Shader.TileMode.CLAMP).apply {
|
||||
setLocalMatrix(android.graphics.Matrix().apply {
|
||||
setTranslate(-(src.width - size) / 2f, -(src.height - size) / 2f)
|
||||
})
|
||||
}
|
||||
}
|
||||
Canvas(out).drawCircle(size / 2f, size / 2f, size / 2f, paint)
|
||||
return out
|
||||
}
|
||||
|
||||
/** MIB receipt placeholder: up to two initials in #1168F3 on a #C6E1FD circle. */
|
||||
private fun makeMibInitialsBitmap(name: 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
|
||||
paint.color = Color.parseColor("#C6E1FD")
|
||||
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
||||
paint.color = Color.WHITE
|
||||
paint.textSize = sizePx * 0.42f
|
||||
paint.color = Color.parseColor("#1168F3")
|
||||
paint.textSize = sizePx * 0.36f
|
||||
paint.textAlign = Paint.Align.CENTER
|
||||
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
paint.typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||||
val initials = name.split(Regex("\\s+"))
|
||||
.mapNotNull { word -> word.firstOrNull { it.isLetterOrDigit() }?.uppercaseChar() }
|
||||
.take(2).joinToString("").ifEmpty { "?" }
|
||||
val metrics = paint.fontMetrics
|
||||
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
||||
canvas.drawText(initials, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
||||
return bm
|
||||
}
|
||||
|
||||
private fun showFullScreenReceipt() {
|
||||
val ctx = requireContext()
|
||||
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
||||
if (bank == "BML") { showBmlFullScreenReceipt(); return }
|
||||
if (bank == "MIB") { showMibFullScreenReceipt(); return }
|
||||
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
|
||||
|
||||
val scrollView = android.widget.ScrollView(ctx).apply {
|
||||
@@ -475,6 +540,113 @@ class TransferReceiptFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BML full-screen receipt: status bar stays visible, top bar with back button,
|
||||
* edge-to-edge card right under it, BML-styled Save/Share buttons directly below the card.
|
||||
* Follows the app theme (light/dark).
|
||||
*/
|
||||
private fun showBmlFullScreenReceipt() {
|
||||
val ctx = requireContext()
|
||||
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
|
||||
val page = DialogReceiptFullscreenBmlBinding.inflate(layoutInflater)
|
||||
|
||||
val card = FragmentReceiptBmlBinding.inflate(layoutInflater).also { bindBml(it) }.receiptCard
|
||||
(card.parent as? ViewGroup)?.removeView(card)
|
||||
page.cardHolder.addView(card, 0, android.widget.LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
))
|
||||
|
||||
page.btnBack.setOnClickListener { dialog.dismiss() }
|
||||
page.btnSaveFull.setOnClickListener { saveReceipt() }
|
||||
page.btnShareFull.setOnClickListener { shareReceipt() }
|
||||
|
||||
val topBasePadding = page.topBar.paddingTop
|
||||
val bottomBasePadding = page.bottomBar.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(page.root) { _, insets ->
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
page.topBar.updatePadding(top = topBasePadding + bars.top)
|
||||
page.bottomBar.updatePadding(bottom = bottomBasePadding + bars.bottom)
|
||||
page.root.updatePadding(left = bars.left, right = bars.right)
|
||||
insets
|
||||
}
|
||||
|
||||
dialog.setContentView(page.root)
|
||||
dialog.window?.let { win ->
|
||||
win.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
|
||||
@Suppress("DEPRECATION")
|
||||
win.statusBarColor = Color.TRANSPARENT
|
||||
@Suppress("DEPRECATION")
|
||||
win.navigationBarColor = Color.TRANSPARENT
|
||||
val isLight = (resources.configuration.uiMode and
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||
androidx.core.view.WindowInsetsControllerCompat(win, win.decorView).apply {
|
||||
isAppearanceLightStatusBars = isLight
|
||||
isAppearanceLightNavigationBars = isLight
|
||||
}
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* MIB full-screen receipt: edge-to-edge card whose green header runs under the
|
||||
* (visible) status bar, a floating close button top-right just below the status bar,
|
||||
* and MIB-styled Share/Save buttons pinned to the bottom. Follows the app theme.
|
||||
*/
|
||||
private fun showMibFullScreenReceipt() {
|
||||
val ctx = requireContext()
|
||||
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
|
||||
val page = DialogReceiptFullscreenMibBinding.inflate(layoutInflater)
|
||||
|
||||
val cardBinding = FragmentReceiptMibBinding.inflate(layoutInflater).also { bindMib(it) }
|
||||
val card = cardBinding.receiptCard
|
||||
(card.parent as? ViewGroup)?.removeView(card)
|
||||
page.cardHolder.addView(card, ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
))
|
||||
|
||||
page.btnClose.setOnClickListener { dialog.dismiss() }
|
||||
page.btnShareFull.setOnClickListener { shareReceipt() }
|
||||
page.btnSaveFull.setOnClickListener { saveReceipt() }
|
||||
|
||||
val header = cardBinding.receiptHeader
|
||||
val headerBaseHeight = header.layoutParams.height
|
||||
val headerBasePadding = header.paddingTop
|
||||
val closeBaseMargin = (page.btnClose.layoutParams as ViewGroup.MarginLayoutParams).topMargin
|
||||
val bottomBasePadding = page.bottomBar.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(page.root) { _, insets ->
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
// Grow the green header under the status bar, keeping its content below it
|
||||
header.layoutParams = header.layoutParams.apply { height = headerBaseHeight + bars.top }
|
||||
header.updatePadding(top = headerBasePadding + bars.top)
|
||||
page.btnClose.layoutParams = (page.btnClose.layoutParams as ViewGroup.MarginLayoutParams)
|
||||
.apply { topMargin = closeBaseMargin + bars.top }
|
||||
page.bottomBar.updatePadding(bottom = bottomBasePadding + bars.bottom)
|
||||
page.root.updatePadding(left = bars.left, right = bars.right)
|
||||
insets
|
||||
}
|
||||
|
||||
dialog.setContentView(page.root)
|
||||
dialog.window?.let { win ->
|
||||
win.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
|
||||
@Suppress("DEPRECATION")
|
||||
win.statusBarColor = Color.TRANSPARENT
|
||||
@Suppress("DEPRECATION")
|
||||
win.navigationBarColor = Color.TRANSPARENT
|
||||
val isLight = (resources.configuration.uiMode and
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||
androidx.core.view.WindowInsetsControllerCompat(win, win.decorView).apply {
|
||||
// Status bar sits over the green header, so always use light icons
|
||||
isAppearanceLightStatusBars = false
|
||||
isAppearanceLightNavigationBars = isLight
|
||||
}
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun copyOnLongClick(vararg views: android.widget.TextView) {
|
||||
for (tv in views) {
|
||||
tv.setOnLongClickListener {
|
||||
@@ -494,13 +666,9 @@ class TransferReceiptFragment : Fragment() {
|
||||
(activity as? HomeActivity)?.setBottomNavVisible(false)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
(activity as? HomeActivity)?.setBottomNavVisible(true)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
(activity as? HomeActivity)?.setBottomNavVisible(true)
|
||||
_receiptCard = null
|
||||
pendingToAvatarBitmap = null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,739 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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.BmlAccountClient
|
||||
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.BmlQrPayLookupException
|
||||
import sh.sar.basedbank.api.bml.BmlQrPayResult
|
||||
import sh.sar.basedbank.api.bml.BmlSession
|
||||
import sh.sar.basedbank.api.bml.BmlTransferClient
|
||||
import sh.sar.basedbank.api.bml.BmlValidateClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||
import sh.sar.basedbank.ui.home.HomeActivity
|
||||
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||
import sh.sar.basedbank.ui.home.TransferFragment
|
||||
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.PaymvQrParser
|
||||
import sh.sar.basedbank.util.RecentPick
|
||||
import sh.sar.basedbank.util.RecentsCache
|
||||
import sh.sar.basedbank.util.Totp
|
||||
|
||||
/**
|
||||
* Owns the BML-only parts of the Transfer screen: session lookup, the CASA/card transfer itself,
|
||||
* the business-profile OTP state machine (channel pick → initiate → verify), and merchant QR
|
||||
* payment (card/gateway QR lookup + pre-initiate/initiate/confirm).
|
||||
*
|
||||
* Lives alongside [TransferFragment], which dispatches to it whenever the selected source's
|
||||
* [BankAccount.bank] is "BML", and mirrors [MfaisaTransferHandler]'s shape: the fragment keeps
|
||||
* the shared confirm dialog, the "To" lookup and the form state; the handler keeps everything
|
||||
* BML-specific.
|
||||
*
|
||||
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
|
||||
* [androidx.fragment.app.Fragment.viewLifecycleOwner] and Context) — and must be re-created when
|
||||
* the view is recreated.
|
||||
*/
|
||||
class BmlTransferHandler(
|
||||
private val fragment: TransferFragment,
|
||||
private val binding: FragmentTransferBinding,
|
||||
private val viewModel: HomeViewModel,
|
||||
/** Reads the fragment's currently-selected source account. */
|
||||
private val currentSource: () -> BankAccount?,
|
||||
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
||||
private val selectSource: (BankAccount) -> Unit,
|
||||
/** Hook called whenever handler state changes in a way that affects the Send button. */
|
||||
private val onStateChanged: () -> Unit,
|
||||
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes balances. */
|
||||
private val onTransferSuccess: (TransferReceiptData, Bitmap?) -> Unit,
|
||||
) {
|
||||
|
||||
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||
private val ctx get() = fragment.requireContext()
|
||||
private val host get() = fragment.activity as? HomeActivity
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Business-profile OTP flow. NONE means the Send button behaves normally. */
|
||||
private enum class OtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
|
||||
private var otpState = OtpState.NONE
|
||||
private var otpChannel: String? = null
|
||||
|
||||
private data class PendingTransfer(
|
||||
val src: BankAccount,
|
||||
val debitAccount: String,
|
||||
val creditAccount: String,
|
||||
val amount: Double,
|
||||
val amountStr: String,
|
||||
val remarks: String,
|
||||
val transferType: String,
|
||||
val currency: String,
|
||||
val bank: String?,
|
||||
val destDisplay: String,
|
||||
val destAccount: String,
|
||||
val toBank: String,
|
||||
val toAvatar: Bitmap?
|
||||
)
|
||||
private var pendingTransfer: PendingTransfer? = null
|
||||
|
||||
/** Merchant QR payment mode (set when navigated from a card/gateway QR scan). */
|
||||
var qrInfo: BmlQrPayInfo? = null
|
||||
private set
|
||||
/** True for pay.bml.com.mv QRs, which need an extra pre-initiate step. */
|
||||
private var gatewayQr = false
|
||||
/** Prevents re-running the lookup after the user clears the merchant. */
|
||||
var qrLookupAttempted = false
|
||||
private set
|
||||
|
||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||
|
||||
/** Whether the business OTP flow is mid-way — the fragment freezes the Send button while it is. */
|
||||
val isOtpFlowActive: Boolean get() = otpState != OtpState.NONE
|
||||
|
||||
/** Whether the Send button should verify an OTP instead of starting a new transfer. */
|
||||
val isAwaitingOtp: Boolean get() = otpState == OtpState.AWAITING_OTP
|
||||
|
||||
/** Whether a merchant QR is loaded — counts as a resolved recipient for the Send button. */
|
||||
val hasQrMerchant: Boolean get() = qrInfo != null
|
||||
|
||||
fun sessionFor(account: BankAccount?): BmlSession? =
|
||||
account?.let { app.bmlSessionFor(it) } ?: app.anyBmlSession()
|
||||
|
||||
fun isBusinessProfile(account: BankAccount): Boolean {
|
||||
val loginId = account.loginTag.removePrefix("bml_")
|
||||
val profiles = app.bmlProfilesMap[loginId] ?: return false
|
||||
return profiles.firstOrNull { it.profileId == account.profileId }?.profileType == "business"
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a destination through BML — `verifyMibAccount` when [verifyAsMib], otherwise the
|
||||
* regular alias/account validation. Blocking; call from IO. Returns null when BML can't
|
||||
* resolve it, so the caller can fall back to the MIB IPS lookup.
|
||||
*
|
||||
* The result is mapped onto [MibIpsAccountInfo] because that is the Transfer screen's common
|
||||
* "resolved recipient" shape, whichever bank did the resolving. Note that the MIB-verify
|
||||
* endpoint leaves `currency` blank — the caller enriches it via
|
||||
* [MibTransferHandler.lookupCurrency] when a MIB session is available.
|
||||
*/
|
||||
fun validateDestination(
|
||||
session: BmlSession,
|
||||
accountNumber: String,
|
||||
verifyAsMib: Boolean
|
||||
): MibIpsAccountInfo? {
|
||||
val result = try {
|
||||
if (verifyAsMib) BmlValidateClient().verifyMibAccount(session, accountNumber)
|
||||
else BmlValidateClient().validateAccount(session, accountNumber)
|
||||
} catch (_: Exception) { null } ?: return null
|
||||
val bankId = when (result.trnType) {
|
||||
"IAT" -> "MALBMVMV"
|
||||
else -> result.agnt ?: result.account
|
||||
}
|
||||
return MibIpsAccountInfo(
|
||||
accountName = result.name,
|
||||
accountNumber = result.account,
|
||||
bankId = bankId,
|
||||
currency = result.currency
|
||||
)
|
||||
}
|
||||
|
||||
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
|
||||
fun clearQrMerchant() {
|
||||
if (qrInfo == null) return
|
||||
qrInfo = null
|
||||
gatewayQr = false
|
||||
binding.tilAmount.isEnabled = true
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
binding.etAmount.setText("")
|
||||
}
|
||||
|
||||
/** Called when the view tears down — an in-flight OTP flow cannot sensibly resume. */
|
||||
fun clearState() {
|
||||
otpState = OtpState.NONE
|
||||
otpChannel = null
|
||||
pendingTransfer = null
|
||||
}
|
||||
|
||||
// ─── Merchant QR ─────────────────────────────────────────────────────────
|
||||
|
||||
fun lookupQrMerchant(qrUrl: String) {
|
||||
qrLookupAttempted = true
|
||||
// Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount and
|
||||
// need the extra pre-initiate POST; ebanking qrpay URLs do not.
|
||||
gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
|
||||
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
|
||||
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
|
||||
host?.setRefreshing(true)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
|
||||
}
|
||||
host?.setRefreshing(false)
|
||||
val info = result.getOrNull()
|
||||
if (info == null) {
|
||||
// An expired or rejected QR is BML telling us something specific — show its own
|
||||
// wording and stay put with the To row restored, rather than bouncing the user out
|
||||
// of the screen they just scanned from.
|
||||
val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message
|
||||
?: ctx.getString(R.string.bml_qr_lookup_failed)
|
||||
Toast.makeText(ctx, message, Toast.LENGTH_LONG).show()
|
||||
fragment.resetToFieldVisibility()
|
||||
onStateChanged()
|
||||
return@launch
|
||||
}
|
||||
qrInfo = info
|
||||
if (info.amount == 0.0) {
|
||||
RecentsCache.save(ctx, RecentPick(
|
||||
accountNumber = "bmlqr:$qrUrl",
|
||||
displayName = info.merchantName,
|
||||
subtitle = info.merchantAddress.ifBlank { "BML Merchant" },
|
||||
colorHex = "#0066A1",
|
||||
imageHash = null,
|
||||
isProfileImage = false
|
||||
))
|
||||
}
|
||||
|
||||
// Auto-select the user's default BML card if no card was pre-selected
|
||||
if (currentSource() == null) {
|
||||
val defaultNum = CredentialStore(ctx).getDefaultCardAccountNumber()
|
||||
if (defaultNum != null) {
|
||||
val allAccounts = viewModel.accounts.value ?: emptyList()
|
||||
val defaultCard = allAccounts.firstOrNull {
|
||||
it.accountNumber == defaultNum && isCard(it) &&
|
||||
it.statusDesc.equals("Active", ignoreCase = true)
|
||||
}
|
||||
if (defaultCard != null) selectSource(defaultCard)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.tvToAccountDetails.visibility = View.GONE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
binding.ivToPhoto.setImageBitmap(fragment.makeInitialsBitmap(info.merchantName, "#0066A1"))
|
||||
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
|
||||
|
||||
onStateChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog and
|
||||
* reports the outcome inside it — there is no receipt screen for merchant payments.
|
||||
*/
|
||||
fun submitQrPayment() {
|
||||
val info = qrInfo ?: return
|
||||
val src = currentSource() ?: run {
|
||||
Toast.makeText(ctx, 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(ctx, R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val fromTypeLabel = sh.sar.basedbank.util.AccountListParser.from(src)?.typeLabel
|
||||
?: sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(src.accountTypeName)
|
||||
val fromDetail = listOfNotNull("BML", fromTypeLabel.ifBlank { null }).joinToString(" · ")
|
||||
val confirmView = fragment.buildTransferConfirmView(
|
||||
amountCurrency = info.currency,
|
||||
amountValue = "%.2f".format(amount),
|
||||
fromName = src.accountBriefName,
|
||||
fromNumber = src.accountNumber,
|
||||
fromDetail = fromDetail,
|
||||
toName = info.merchantName,
|
||||
toNumber = "",
|
||||
toDetail = info.merchantAddress.ifBlank { "BML Merchant" }
|
||||
)
|
||||
fragment.showConfirmWithBiometric(
|
||||
title = ctx.getString(R.string.transfer),
|
||||
customView = confirmView,
|
||||
biometricSubtitle = "${info.currency} ${"%.2f".format(amount)} → ${info.merchantName}",
|
||||
onConfirmed = { dialog, frame ->
|
||||
fragment.showProcessingInDialog(dialog, frame)
|
||||
executeQrPayment(src, debitAccount, info, amount, dialog, frame)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun executeQrPayment(
|
||||
src: BankAccount,
|
||||
debitAccount: String,
|
||||
info: BmlQrPayInfo,
|
||||
amount: Double,
|
||||
dialog: AlertDialog,
|
||||
frame: android.widget.FrameLayout
|
||||
) {
|
||||
val loginId = src.loginTag.removePrefix("bml_")
|
||||
val session = sessionFor(src) ?: run {
|
||||
dialog.dismiss()
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val otp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) }
|
||||
?: run { dialog.dismiss(); Toast.makeText(ctx, "OTP unavailable", Toast.LENGTH_SHORT).show(); return }
|
||||
|
||||
binding.btnTransfer.isEnabled = false
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (gatewayQr) {
|
||||
val preOk = BmlQrPayClient().preInitiatePayment(
|
||||
session, debitAccount, info.requestId, amount, info.currency)
|
||||
if (!preOk) return@withContext null
|
||||
}
|
||||
val initiated = BmlQrPayClient().initiatePayment(
|
||||
session, debitAccount, info.requestId, amount, info.currency)
|
||||
if (!initiated) return@withContext null
|
||||
val confirmOtp = CredentialStore(ctx).loadBmlCredentials(loginId)
|
||||
?.otpSeed?.let { Totp.generate(it) } ?: otp
|
||||
BmlQrPayClient().confirmPayment(
|
||||
session, debitAccount, info.requestId, amount, info.currency, confirmOtp)
|
||||
} catch (e: Exception) {
|
||||
BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
|
||||
}
|
||||
}
|
||||
if (fragment.view == null) return@launch
|
||||
|
||||
if (result == null) {
|
||||
dialog.dismiss()
|
||||
binding.btnTransfer.isEnabled = true
|
||||
Toast.makeText(ctx, "Failed to initiate payment", Toast.LENGTH_LONG).show()
|
||||
return@launch
|
||||
}
|
||||
if (result.success) {
|
||||
fragment.showSuccessInDialog(
|
||||
dialog, frame,
|
||||
amountCurrency = result.currency.ifBlank { info.currency },
|
||||
amountValue = result.amount.ifBlank { "%.2f".format(amount) },
|
||||
fromName = src.accountBriefName,
|
||||
toName = result.merchant.ifBlank { info.merchantName }
|
||||
) {
|
||||
fragment.clearForm()
|
||||
host?.triggerRefresh()
|
||||
}
|
||||
} else {
|
||||
dialog.dismiss()
|
||||
binding.btnTransfer.isEnabled = true
|
||||
Toast.makeText(ctx, result.errorMessage, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Personal-profile transfer (token OTP, no user interaction) ──────────
|
||||
|
||||
/**
|
||||
* Runs initiate + confirm on the calling (IO) thread and returns
|
||||
* `(ok, message, receipt)`. Message is "CONNECTIVITY" when the network was unreachable.
|
||||
*/
|
||||
fun doTransfer(
|
||||
src: BankAccount,
|
||||
destAccount: String,
|
||||
destDisplay: String,
|
||||
amount: Double,
|
||||
amountStr: String,
|
||||
remarks: String,
|
||||
isSrcCard: Boolean,
|
||||
isDestMib: Boolean,
|
||||
currency: String,
|
||||
allAccounts: List<BankAccount>,
|
||||
allContacts: List<BankContact>
|
||||
): Triple<Boolean, String, TransferReceiptData?> {
|
||||
val loginId = src.loginTag.removePrefix("bml_")
|
||||
val sess = sessionFor(src) ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
|
||||
val otp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) }
|
||||
?: return Triple(false, "OTP unavailable", null)
|
||||
val debitAccount = src.internalId.ifBlank {
|
||||
return Triple(false, ctx.getString(R.string.transfer_missing_internal_id), null)
|
||||
}
|
||||
|
||||
val routed = routeTransfer(destAccount, isSrcCard, isDestMib, currency, allAccounts, allContacts)
|
||||
?: return Triple(false, "BML contact not found for this account", null)
|
||||
val (transferType, creditAccount, bank) = routed
|
||||
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
|
||||
|
||||
// Step 1: initiate
|
||||
val initiated = try {
|
||||
BmlTransferClient().initiateTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, bank)
|
||||
} catch (e: Exception) { return Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Initiation failed"), null) }
|
||||
|
||||
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
|
||||
|
||||
// Step 2: confirm with fresh OTP
|
||||
val confirmOtp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) } ?: otp
|
||||
|
||||
return try {
|
||||
val result = BmlTransferClient().confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
|
||||
if (result.success) {
|
||||
val receipt = TransferReceiptData(
|
||||
bank = "BML",
|
||||
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,
|
||||
bmlMessage = result.message
|
||||
)
|
||||
val time = result.timestamp.take(19).replace("T", " ")
|
||||
Triple(true, "Reference: ${result.reference}\n$time", receipt)
|
||||
} else {
|
||||
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Business profile OTP flow ───────────────────────────────────────────
|
||||
|
||||
fun startBusinessOtpFlow(
|
||||
src: BankAccount,
|
||||
destAccount: String,
|
||||
destDisplay: String,
|
||||
amount: Double,
|
||||
amountStr: String,
|
||||
remarks: String,
|
||||
isSrcCard: Boolean,
|
||||
isDestMib: Boolean,
|
||||
currency: String,
|
||||
allAccounts: List<BankAccount>,
|
||||
allContacts: List<BankContact>,
|
||||
toAvatar: Bitmap?
|
||||
) {
|
||||
val debitAccount = src.internalId.ifBlank {
|
||||
Toast.makeText(ctx, ctx.getString(R.string.transfer_missing_internal_id), Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val routed = routeTransfer(destAccount, isSrcCard, isDestMib, currency, allAccounts, allContacts)
|
||||
if (routed == null) {
|
||||
Toast.makeText(ctx, "BML contact not found for this account", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val (transferType, creditAccount, bank) = routed
|
||||
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
|
||||
|
||||
pendingTransfer = PendingTransfer(
|
||||
src = src,
|
||||
debitAccount = debitAccount,
|
||||
creditAccount = creditAccount,
|
||||
amount = amount,
|
||||
amountStr = amountStr,
|
||||
remarks = remarks,
|
||||
transferType = transferType,
|
||||
currency = currency,
|
||||
bank = bank,
|
||||
destDisplay = destDisplay,
|
||||
destAccount = destAccount,
|
||||
toBank = toBank,
|
||||
toAvatar = toAvatar
|
||||
)
|
||||
|
||||
otpState = OtpState.SELECTING_CHANNEL
|
||||
binding.btnTransfer.isEnabled = false
|
||||
host?.setRefreshing(true)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val sess = sessionFor(src)
|
||||
val channels = if (sess != null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try { BmlAccountClient().fetchTransferChannels(sess) }
|
||||
catch (_: Exception) { emptyList() }
|
||||
}
|
||||
} else emptyList<BmlOtpChannel>()
|
||||
|
||||
host?.setRefreshing(false)
|
||||
|
||||
if (channels.isEmpty()) {
|
||||
Toast.makeText(ctx, "Could not load OTP channels", Toast.LENGTH_SHORT).show()
|
||||
resetOtpState()
|
||||
onStateChanged()
|
||||
return@launch
|
||||
}
|
||||
|
||||
showChannelSelection(channels)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showChannelSelection(channels: List<BmlOtpChannel>) {
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
binding.containerBmlChannels.removeAllViews()
|
||||
|
||||
for (channel in channels) {
|
||||
val iconRes = when (channel.channel) {
|
||||
"email" -> R.drawable.ic_channel_email
|
||||
"mobile" -> R.drawable.ic_channel_sms
|
||||
else -> R.drawable.ic_channel_sms
|
||||
}
|
||||
val iconSize = (24 * dp).toInt()
|
||||
|
||||
val textCol = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
|
||||
marginStart = (12 * dp).toInt()
|
||||
}
|
||||
}
|
||||
textCol.addView(TextView(ctx).apply {
|
||||
text = channel.description
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
||||
})
|
||||
textCol.addView(TextView(ctx).apply {
|
||||
text = channel.masked
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||
alpha = 0.6f
|
||||
})
|
||||
|
||||
val row = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||
background = ta.getDrawable(0); ta.recycle()
|
||||
isClickable = true; isFocusable = true
|
||||
val hp = (16 * dp).toInt(); val vp = (12 * dp).toInt()
|
||||
setPadding(hp, vp, hp, vp)
|
||||
}
|
||||
row.addView(ImageView(ctx).apply { setImageResource(iconRes) },
|
||||
LinearLayout.LayoutParams(iconSize, iconSize))
|
||||
row.addView(textCol)
|
||||
row.setOnClickListener { selectOtpChannel(channel) }
|
||||
binding.containerBmlChannels.addView(row)
|
||||
}
|
||||
|
||||
disableTransferFields()
|
||||
binding.layoutBmlChannelSelection.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun selectOtpChannel(channel: BmlOtpChannel) {
|
||||
otpChannel = channel.channel
|
||||
binding.layoutBmlChannelSelection.visibility = View.GONE
|
||||
|
||||
val pending = pendingTransfer ?: return
|
||||
val sess = sessionFor(pending.src) ?: run {
|
||||
Toast.makeText(ctx, ctx.getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
|
||||
resetOtpState()
|
||||
onStateChanged()
|
||||
return
|
||||
}
|
||||
|
||||
binding.btnTransfer.isEnabled = false
|
||||
host?.setRefreshing(true)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val initiated = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
BmlTransferClient().initiateTransfer(
|
||||
sess, pending.debitAccount, pending.creditAccount,
|
||||
pending.amount, pending.transferType, pending.currency,
|
||||
pending.bank, channel.channel
|
||||
)
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
host?.setRefreshing(false)
|
||||
|
||||
if (!initiated) {
|
||||
Toast.makeText(ctx, "Failed to initiate transfer — check your session", Toast.LENGTH_SHORT).show()
|
||||
resetOtpState()
|
||||
onStateChanged()
|
||||
return@launch
|
||||
}
|
||||
|
||||
otpState = OtpState.AWAITING_OTP
|
||||
binding.tvBmlOtpSentVia.text = "OTP code sent via: ${channel.description} (${channel.masked})"
|
||||
binding.tvBmlOtpSentVia.visibility = View.VISIBLE
|
||||
binding.tilBmlOtp.visibility = View.VISIBLE
|
||||
binding.etBmlOtp.requestFocus()
|
||||
binding.btnTransfer.text = ctx.getString(R.string.transfer_verify_payment)
|
||||
binding.btnTransfer.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Send-button action while [isAwaitingOtp]: confirms the pending transfer with the typed code. */
|
||||
fun verifyOtp() {
|
||||
val otp = binding.etBmlOtp.text?.toString()?.trim() ?: ""
|
||||
if (otp.isEmpty()) {
|
||||
binding.tilBmlOtp.error = "Enter the verification code"
|
||||
return
|
||||
}
|
||||
binding.tilBmlOtp.error = null
|
||||
val pending = pendingTransfer ?: return
|
||||
val channel = otpChannel ?: return
|
||||
val sess = sessionFor(pending.src) ?: run {
|
||||
Toast.makeText(ctx, ctx.getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
binding.btnTransfer.isEnabled = false
|
||||
host?.setRefreshing(true)
|
||||
|
||||
val capturedToAvatar = pending.toAvatar
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val result = BmlTransferClient().confirmTransfer(
|
||||
sess, pending.debitAccount, pending.creditAccount,
|
||||
pending.amount, pending.transferType, pending.currency,
|
||||
otp, pending.remarks, pending.bank, channel
|
||||
)
|
||||
if (result.success) {
|
||||
val r = TransferReceiptData(
|
||||
bank = "BML",
|
||||
amount = "%.2f".format(pending.amount),
|
||||
currency = pending.currency,
|
||||
fromLabel = pending.src.accountBriefName,
|
||||
fromColorHex = "#0066A1",
|
||||
toLabel = pending.destDisplay.ifBlank { pending.destAccount },
|
||||
toAccount = pending.destAccount,
|
||||
toBank = pending.toBank,
|
||||
remarks = pending.remarks,
|
||||
bmlFromName = pending.src.accountBriefName,
|
||||
bmlReference = result.reference,
|
||||
bmlTimestamp = result.timestamp,
|
||||
bmlMessage = result.message
|
||||
)
|
||||
Triple(true, "", r)
|
||||
} else {
|
||||
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null as TransferReceiptData?)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null as TransferReceiptData?)
|
||||
}
|
||||
}
|
||||
host?.setRefreshing(false)
|
||||
|
||||
if (ok && receipt != null) {
|
||||
resetOtpState()
|
||||
onTransferSuccess(receipt, capturedToAvatar)
|
||||
} else {
|
||||
binding.btnTransfer.isEnabled = true
|
||||
if (msg == "CONNECTIVITY") {
|
||||
host?.showConnectivityBanner(ctx.getString(R.string.connectivity_no_internet))
|
||||
} else {
|
||||
binding.tilBmlOtp.error = msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Tears down the OTP UI and re-enables the form. Safe to call when no flow is running. */
|
||||
fun resetOtpState() {
|
||||
otpState = OtpState.NONE
|
||||
otpChannel = null
|
||||
pendingTransfer = null
|
||||
if (fragment.view == null) return
|
||||
binding.layoutBmlChannelSelection.visibility = View.GONE
|
||||
binding.tvBmlOtpSentVia.visibility = View.GONE
|
||||
binding.tilBmlOtp.visibility = View.GONE
|
||||
binding.etBmlOtp.setText("")
|
||||
binding.tilBmlOtp.error = null
|
||||
enableTransferFields()
|
||||
binding.btnTransfer.text = ctx.getString(R.string.transfer)
|
||||
}
|
||||
|
||||
private fun disableTransferFields() {
|
||||
binding.tilAmount.isEnabled = false
|
||||
binding.tilRemarks.isEnabled = false
|
||||
binding.cardFromInfo.alpha = 0.5f
|
||||
binding.btnClearFromInfo.isEnabled = false
|
||||
binding.cardToInfo.alpha = 0.5f
|
||||
binding.btnClearToInfo.isEnabled = false
|
||||
}
|
||||
|
||||
private fun enableTransferFields() {
|
||||
binding.tilAmount.isEnabled = true
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.cardFromInfo.alpha = 1f
|
||||
binding.btnClearFromInfo.isEnabled = true
|
||||
binding.cardToInfo.alpha = 1f
|
||||
binding.btnClearToInfo.isEnabled = true
|
||||
}
|
||||
|
||||
// ─── Shared routing ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Picks the BML transfer type and the credit-account identifier the API expects.
|
||||
* Returns null only for the USD → MIB case with no matching saved BML contact, which both
|
||||
* callers report as "BML contact not found".
|
||||
*/
|
||||
private fun routeTransfer(
|
||||
destAccount: String,
|
||||
isSrcCard: Boolean,
|
||||
isDestMib: Boolean,
|
||||
currency: String,
|
||||
allAccounts: List<BankAccount>,
|
||||
allContacts: List<BankContact>
|
||||
): Triple<String, String, String?>? {
|
||||
val isDestMyCard = allAccounts.any { isCard(it) && it.accountNumber == destAccount }
|
||||
return when {
|
||||
isSrcCard -> {
|
||||
// CAD: card → own BML account
|
||||
val destBml = allAccounts.firstOrNull { it.accountNumber == destAccount && it.profileType == "BML" }
|
||||
Triple("CAD", destBml?.internalId?.ifBlank { destAccount } ?: destAccount, null)
|
||||
}
|
||||
isDestMyCard -> {
|
||||
// CPA: BML CASA → own card top-up
|
||||
val card = allAccounts.first { isCard(it) && it.accountNumber == destAccount }
|
||||
Triple("CPA", card.internalId.ifBlank { destAccount }, null)
|
||||
}
|
||||
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
|
||||
isDestMib -> {
|
||||
// USD DOT: requires BML contact numeric ID
|
||||
val contact = allContacts.firstOrNull { it.benefCategoryId == "BML" && it.benefAccount == destAccount }
|
||||
?: return null
|
||||
Triple("DOT", contact.benefNo.removePrefix("bml_"), null)
|
||||
}
|
||||
else -> Triple("IAT", destAccount, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCard(account: BankAccount) =
|
||||
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.view.View
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
|
||||
import sh.sar.basedbank.api.fahipay.OoredooClient
|
||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||
import sh.sar.basedbank.ui.home.TransferFragment
|
||||
import sh.sar.basedbank.util.AccountInputParser
|
||||
|
||||
/**
|
||||
* A service a Fahipay wallet can pay out to. The carrier lookup decides which of these apply to
|
||||
* a given number; [label] names it in the recipient card, [destinationLabel] in the confirm
|
||||
* dialog's "To" block.
|
||||
*
|
||||
* Wallet-to-wallet Fahipay transfer is not here yet — there is no send path for it (see the
|
||||
* class KDoc on [FahipayTransferHandler]). Add it as a constant once that lands, and the
|
||||
* exhaustive `when`s over this enum will point at every site that needs updating.
|
||||
*/
|
||||
enum class FahipayService(val label: String, val destinationLabel: String) {
|
||||
RAASTAS("Raastas", "Ooredoo · Raastas"),
|
||||
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay"),
|
||||
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload"),
|
||||
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay"),
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the Fahipay-only parts of the Transfer screen: the carrier lookup that turns a phone
|
||||
* number into a set of payable services, the chip picker shown when more than one applies, and
|
||||
* the selected service that the confirm dialog labels the destination with.
|
||||
*
|
||||
* Mirrors [BmlTransferHandler] / [MfaisaTransferHandler]: the fragment keeps the shared confirm
|
||||
* dialog, the recipient card and the form state; the handler keeps everything Fahipay-specific.
|
||||
*
|
||||
* **There is no send path yet.** A Fahipay source currently falls through to the MIB branch of
|
||||
* `initiateTransfer`, which signs the request with a MIB session. When the real payout API is
|
||||
* wired up it belongs here, as a `doTransfer(...)` alongside the lookup — same shape as the
|
||||
* other handlers.
|
||||
*
|
||||
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment]
|
||||
* (for `viewLifecycleOwner` and Context) — and must be re-created when the view is recreated.
|
||||
*/
|
||||
class FahipayTransferHandler(
|
||||
private val fragment: TransferFragment,
|
||||
private val binding: FragmentTransferBinding,
|
||||
private val viewModel: HomeViewModel,
|
||||
) {
|
||||
|
||||
private val ctx get() = fragment.requireContext()
|
||||
|
||||
/** The service picked for the current recipient; null until a lookup resolves one. */
|
||||
var service: FahipayService? = null
|
||||
private set
|
||||
|
||||
/** How the confirm dialog names the destination, or "" when nothing is selected. */
|
||||
val destinationLabel: String get() = service?.destinationLabel.orEmpty()
|
||||
|
||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolves a destination for a Fahipay source. Only phone numbers are payable, so anything
|
||||
* else is rejected inline on the "To" field.
|
||||
*/
|
||||
fun lookupRecipient(rawInput: String) {
|
||||
if (AccountInputParser.detect(rawInput) != AccountInputParser.InputType.PHONE) {
|
||||
binding.tilTo.error = ctx.getString(R.string.transfer_fahipay_phone_only)
|
||||
return
|
||||
}
|
||||
lookupCarrier(rawInput)
|
||||
}
|
||||
|
||||
/** Clears the selected service and hides the chip picker. */
|
||||
fun clearState() {
|
||||
service = null
|
||||
binding.layoutServiceSelector.visibility = View.INVISIBLE
|
||||
}
|
||||
|
||||
// ─── Carrier lookup ──────────────────────────────────────────────────────
|
||||
|
||||
private data class CarrierResult(
|
||||
val dhiraagu: DhiraaguClient.Result,
|
||||
val ooredoo: OoredooClient.CustType
|
||||
)
|
||||
|
||||
private fun lookupCarrier(number: String) {
|
||||
fragment.startLookupLoading()
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) { queryCarriers(number) }
|
||||
fragment.stopLookupLoading()
|
||||
|
||||
val dhiraaguName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() }
|
||||
val services = servicesFor(result)
|
||||
|
||||
if (services.isEmpty()) return@launch
|
||||
|
||||
// Only one option — auto-select, no chip UI needed
|
||||
if (services.size == 1) {
|
||||
selectService(services[0], number, dhiraaguName)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Multiple options (Ooredoo HYBRID) — show chips
|
||||
showServiceChips(services, number, dhiraaguName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the likelier carrier first based on the leading digit and only falls back to the
|
||||
* other when the first says it doesn't know the number. Blocking — call from IO.
|
||||
*/
|
||||
private fun queryCarriers(number: String): CarrierResult =
|
||||
if (number.startsWith("7")) {
|
||||
// Dhiraagu first, fall back to Ooredoo
|
||||
val d = dhiraagu(number)
|
||||
val o = if (d.type == DhiraaguClient.CustType.UNSUPPORTED) ooredoo(number)
|
||||
else OoredooClient.CustType.UNSUPPORTED
|
||||
CarrierResult(d, o)
|
||||
} else {
|
||||
// Ooredoo first, fall back to Dhiraagu
|
||||
val o = ooredoo(number)
|
||||
val d = if (o == OoredooClient.CustType.UNSUPPORTED) dhiraagu(number)
|
||||
else DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED)
|
||||
CarrierResult(d, o)
|
||||
}
|
||||
|
||||
private fun dhiraagu(number: String) =
|
||||
try { DhiraaguClient().validateNumber(number) }
|
||||
catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) }
|
||||
|
||||
private fun ooredoo(number: String) =
|
||||
try { OoredooClient().validateNumber(number) }
|
||||
catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED }
|
||||
|
||||
private fun servicesFor(result: CarrierResult): List<FahipayService> = buildList {
|
||||
if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add(FahipayService.DHIRAAGU_RELOAD)
|
||||
if (result.dhiraagu.type == DhiraaguClient.CustType.BILL_PAY) add(FahipayService.DHIRAAGU_BILL)
|
||||
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.RAASTAS)
|
||||
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.OOREDOO_BILL)
|
||||
}
|
||||
|
||||
// ─── Service picker ──────────────────────────────────────────────────────
|
||||
|
||||
private fun showServiceChips(
|
||||
services: List<FahipayService>,
|
||||
number: String,
|
||||
dhiraaguName: String?
|
||||
) {
|
||||
binding.chipDhiraaguReload.visibility = visibilityFor(FahipayService.DHIRAAGU_RELOAD in services)
|
||||
binding.chipDhiraaguBill.visibility = visibilityFor(FahipayService.DHIRAAGU_BILL in services)
|
||||
binding.chipRaastas.visibility = visibilityFor(FahipayService.RAASTAS in services)
|
||||
binding.chipOoredooBill.visibility = visibilityFor(FahipayService.OOREDOO_BILL in services)
|
||||
binding.layoutServiceSelector.visibility = View.VISIBLE
|
||||
binding.chipGroupService.clearCheck()
|
||||
|
||||
// Dhiraagu is the only carrier that hands back an owner name, so the Ooredoo chips
|
||||
// resolve their display name from saved contacts instead.
|
||||
bindChip(binding.chipDhiraaguReload, FahipayService.DHIRAAGU_RELOAD, number, dhiraaguName)
|
||||
bindChip(binding.chipDhiraaguBill, FahipayService.DHIRAAGU_BILL, number, dhiraaguName)
|
||||
bindChip(binding.chipRaastas, FahipayService.RAASTAS, number, null)
|
||||
bindChip(binding.chipOoredooBill, FahipayService.OOREDOO_BILL, number, null)
|
||||
}
|
||||
|
||||
private fun bindChip(
|
||||
chip: com.google.android.material.chip.Chip,
|
||||
picked: FahipayService,
|
||||
number: String,
|
||||
ownerName: String?
|
||||
) {
|
||||
chip.setOnCheckedChangeListener { _, checked ->
|
||||
if (checked) {
|
||||
selectService(picked, number, ownerName)
|
||||
binding.layoutServiceSelector.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun visibilityFor(shown: Boolean) = if (shown) View.VISIBLE else View.GONE
|
||||
|
||||
private fun selectService(picked: FahipayService, number: String, ownerName: String?) {
|
||||
service = picked
|
||||
val contacts = viewModel.contacts.value ?: emptyList()
|
||||
val displayName = ownerName
|
||||
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
|
||||
?: number
|
||||
fragment.prefillToDirectly(
|
||||
accountNumber = number,
|
||||
displayName = displayName,
|
||||
subtitle = "${picked.label} · $number",
|
||||
colorHex = "#FF6B00",
|
||||
imageHash = null
|
||||
)
|
||||
fragment.focusAmount()
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -22,6 +23,7 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaInvalidOtpException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaRecipientNotFoundException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient
|
||||
@@ -37,16 +39,25 @@ import sh.sar.basedbank.util.RecentsCache
|
||||
|
||||
/**
|
||||
* Owns the M-Faisa-only parts of the Transfer screen: phone-based recipient lookup,
|
||||
* initiate-with-OTP, and confirm-with-OTP. Lives alongside [sh.sar.basedbank.ui.home.TransferFragment]
|
||||
* which dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source.
|
||||
* initiate-with-OTP and confirm-with-OTP for wallet transfers, plus merchant QR pay
|
||||
* (qrCodeId lookup + initiate/confirm purchase). Lives alongside [TransferFragment] which
|
||||
* dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source, or whenever
|
||||
* an M-Faisa merchant QR is loaded.
|
||||
*
|
||||
* Mirrors [BmlTransferHandler]: the fragment keeps the shared confirm dialog, the recipient card
|
||||
* and the form state; the handler keeps everything M-Faisa-specific.
|
||||
*
|
||||
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
|
||||
* [Fragment.viewLifecycleOwner] and Context) — and must be re-created when the view is recreated.
|
||||
*/
|
||||
class MfaisaTransferHandler(
|
||||
private val fragment: Fragment,
|
||||
private val fragment: TransferFragment,
|
||||
private val binding: FragmentTransferBinding,
|
||||
private val viewModel: HomeViewModel,
|
||||
/** Reads the fragment's currently-selected source account. */
|
||||
private val currentSource: () -> BankAccount?,
|
||||
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
||||
private val selectSource: (BankAccount) -> Unit,
|
||||
/** Hook called when M-Faisa successfully resolves or clears a recipient — fragment uses this to update Send-button state. */
|
||||
private val onRecipientChanged: () -> Unit,
|
||||
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes account balances. */
|
||||
@@ -55,11 +66,16 @@ class MfaisaTransferHandler(
|
||||
|
||||
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||
private val ctx get() = fragment.requireContext()
|
||||
private val host get() = fragment.activity as? HomeActivity
|
||||
|
||||
/** Set to the resolved recipient after a successful search; null otherwise. */
|
||||
var recipient: MfaisaTransferClient.Recipient? = null
|
||||
private set
|
||||
|
||||
/** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */
|
||||
var qrInfo: MfaisaQrPayClient.QrMerchant? = null
|
||||
private set
|
||||
|
||||
private var lookupInFlight = false
|
||||
|
||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||
@@ -80,7 +96,7 @@ class MfaisaTransferHandler(
|
||||
}
|
||||
binding.tilTo.error = null
|
||||
|
||||
val source = currentSource() ?: return
|
||||
val source = mfaisaSource() ?: return
|
||||
val session = app.mfaisaSessionFor(source) ?: run {
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
@@ -105,6 +121,7 @@ class MfaisaTransferHandler(
|
||||
} else {
|
||||
recipient = result
|
||||
showResolvedRecipient(result)
|
||||
fragment.focusAmount()
|
||||
}
|
||||
} catch (_: MfaisaRecipientNotFoundException) {
|
||||
binding.tilTo.error = "No M-Faisa wallet found for this number"
|
||||
@@ -122,7 +139,7 @@ class MfaisaTransferHandler(
|
||||
|
||||
/** Triggered when the user taps the Send button (and source bank is MFAISA). */
|
||||
fun submit() {
|
||||
val source = currentSource() ?: return
|
||||
val source = mfaisaSource() ?: return
|
||||
val r = recipient ?: run {
|
||||
Toast.makeText(ctx, "Search for a recipient first", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
@@ -153,20 +170,232 @@ class MfaisaTransferHandler(
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a merchant QR is loaded — counts as a resolved recipient for the Send button. */
|
||||
val hasQrMerchant: Boolean get() = qrInfo != null
|
||||
|
||||
/** Called when the source account changes away from M-Faisa (or the view tears down). */
|
||||
fun clearState() {
|
||||
recipient = null
|
||||
lookupInFlight = false
|
||||
}
|
||||
|
||||
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
|
||||
fun clearQrMerchant() {
|
||||
if (qrInfo == null) return
|
||||
qrInfo = null
|
||||
binding.tilAmount.isEnabled = true
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
binding.etAmount.setText("")
|
||||
}
|
||||
|
||||
// ─── Merchant QR ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolves an M-Faisa qrCodeId to a merchant, paints it in the "To" card, auto-selects an
|
||||
* M-Faisa source if none is selected (or the current one is the wrong bank), and pre-fills
|
||||
* the amount if the QR is dynamic.
|
||||
*/
|
||||
fun lookupQrMerchant(qrCodeId: String) {
|
||||
val source = mfaisaSource() ?: run {
|
||||
Toast.makeText(ctx, "No M-Faisa account available", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val session = app.mfaisaSessionFor(source) ?: run {
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually
|
||||
if (currentSource()?.bank != "MFAISA") selectSource(source)
|
||||
|
||||
// Lock the "To" input row while loading
|
||||
binding.tilTo.visibility = View.GONE
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
host?.setRefreshing(true)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val merchant = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
MfaisaQrPayClient().fetchQrDetails(session, qrCodeId)
|
||||
} catch (_: MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||
?: return@withContext null
|
||||
try { MfaisaQrPayClient().fetchQrDetails(fresh, qrCodeId) }
|
||||
catch (_: Exception) { null }
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
host?.setRefreshing(false)
|
||||
if (merchant == null) {
|
||||
Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
|
||||
fragment.resetToFieldVisibility()
|
||||
return@launch
|
||||
}
|
||||
qrInfo = merchant
|
||||
|
||||
// Static QRs (no preset amount) make sense to keep in Recents — the merchant is
|
||||
// reusable. Dynamic QRs are one-off so we skip them, same rule as BML QR pay.
|
||||
if (merchant.txnAmount.isNullOrBlank()) {
|
||||
RecentsCache.save(ctx, RecentPick(
|
||||
accountNumber = "mfaisaqr:${merchant.qrCodeId}",
|
||||
displayName = merchant.merchantName,
|
||||
subtitle = "M-Faisa merchant · ${merchant.merchantMsisdn}",
|
||||
colorHex = "#ED1C24",
|
||||
imageHash = null,
|
||||
isProfileImage = false,
|
||||
bank = "MFAISA"
|
||||
))
|
||||
}
|
||||
|
||||
// Show merchant in the "To" card — clear button is the only way to back out
|
||||
binding.tvToAccountName.text = merchant.merchantName
|
||||
binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}"
|
||||
binding.tvToAccountDetails.visibility = View.GONE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
|
||||
binding.cardToInfo.visibility = View.VISIBLE
|
||||
|
||||
// Pre-fill + lock amount if the QR is dynamic
|
||||
val dynamicAmount = merchant.txnAmount?.toDoubleOrNull()
|
||||
if (dynamicAmount != null && dynamicAmount > 0.0) {
|
||||
binding.etAmount.setText("%.2f".format(dynamicAmount))
|
||||
binding.tilAmount.isEnabled = false
|
||||
}
|
||||
|
||||
onRecipientChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog —
|
||||
* the /initiateNewBuy + /confirmNewBuy pair does NOT require OTP for wallet QR pay
|
||||
* (2FARequired=NONE), so unlike [submit] there is no code to enter.
|
||||
*/
|
||||
fun submitQrPayment() {
|
||||
val merchant = qrInfo ?: return
|
||||
val src = currentSource() ?: run {
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
if (src.bank != "MFAISA") {
|
||||
Toast.makeText(ctx, "Switch to an M-Faisa account to pay this QR", 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 remarks = binding.etRemarks.text?.toString()?.trim().orEmpty()
|
||||
val amountValue = "%.2f".format(amount)
|
||||
|
||||
val confirmView = fragment.buildTransferConfirmView(
|
||||
amountCurrency = merchant.currencyCode,
|
||||
amountValue = amountValue,
|
||||
fromName = src.accountBriefName,
|
||||
fromNumber = src.accountNumber,
|
||||
fromDetail = "M-Faisa",
|
||||
toName = merchant.merchantName,
|
||||
toNumber = merchant.merchantMsisdn,
|
||||
toDetail = "Ooredoo M-Faisa merchant"
|
||||
)
|
||||
fragment.showConfirmWithBiometric(
|
||||
title = ctx.getString(R.string.transfer),
|
||||
customView = confirmView,
|
||||
biometricSubtitle = "${merchant.currencyCode} $amountValue → ${merchant.merchantName}",
|
||||
onConfirmed = { dialog, frame ->
|
||||
fragment.showProcessingInDialog(dialog, frame)
|
||||
executeQrPayment(src, merchant, amountValue, remarks, dialog)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun executeQrPayment(
|
||||
src: BankAccount,
|
||||
merchant: MfaisaQrPayClient.QrMerchant,
|
||||
amountStr: String,
|
||||
remarks: String,
|
||||
dialog: AlertDialog
|
||||
) {
|
||||
val loginId = src.loginTag.removePrefix("mfaisa_")
|
||||
val initialSession = app.mfaisaSessionFor(src) ?: run {
|
||||
dialog.dismiss()
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
// M-Faisa expects the user's MSISDN with the "960" country prefix (the session stores the
|
||||
// bare 7-digit form). The pocket itself is identified by [BankAccount.accountNumber].
|
||||
val sourceMdn = "960${initialSession.msisdn}"
|
||||
|
||||
binding.btnTransfer.isEnabled = false
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val outcome = withContext(Dispatchers.IO) {
|
||||
val client = MfaisaQrPayClient()
|
||||
try {
|
||||
val refId = try {
|
||||
client.initiatePurchase(initialSession, src.accountNumber, sourceMdn, merchant, amountStr, remarks)
|
||||
} catch (_: MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(loginId)
|
||||
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||
client.initiatePurchase(fresh, src.accountNumber, "960${fresh.msisdn}", merchant, amountStr, remarks)
|
||||
}
|
||||
val confirmSession = app.mfaisaSessionFor(src) ?: initialSession
|
||||
try {
|
||||
client.confirmPurchase(confirmSession, refId)
|
||||
} catch (_: MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(loginId)
|
||||
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||
client.confirmPurchase(fresh, refId)
|
||||
}
|
||||
Result.success(refId)
|
||||
} catch (e: Exception) {
|
||||
Result.failure<String>(e)
|
||||
}
|
||||
}
|
||||
if (fragment.view == null) return@launch
|
||||
|
||||
outcome.fold(
|
||||
onSuccess = { _ ->
|
||||
val receipt = TransferReceiptData(
|
||||
bank = "MFAISA",
|
||||
amount = amountStr,
|
||||
currency = merchant.currencyCode,
|
||||
fromLabel = src.accountBriefName,
|
||||
fromColorHex = "#ED1C24",
|
||||
toLabel = merchant.merchantName,
|
||||
toAccount = merchant.merchantMsisdn,
|
||||
toBank = "Ooredoo M-Faisa",
|
||||
remarks = remarks,
|
||||
mfaisaTransactionType = "Merchant payment",
|
||||
mfaisaFromName = src.accountBriefName,
|
||||
mfaisaFromMsisdn = src.accountNumber,
|
||||
mfaisaToMsisdn = merchant.merchantMsisdn,
|
||||
mfaisaTimestamp = System.currentTimeMillis()
|
||||
)
|
||||
dialog.dismiss()
|
||||
onTransferSuccess(receipt, null)
|
||||
},
|
||||
onFailure = { e ->
|
||||
dialog.dismiss()
|
||||
binding.btnTransfer.isEnabled = true
|
||||
showError(e, fallback = "Payment failed")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal ────────────────────────────────────────────────────────────
|
||||
|
||||
private fun currentSource(): BankAccount? =
|
||||
viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" && it.accountNumber == sourceAccountNumberFromCard() }
|
||||
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" } // fallback if from-card field isn't easily readable
|
||||
|
||||
private fun sourceAccountNumberFromCard(): String =
|
||||
binding.tvFromAccountNumber.text?.toString().orEmpty()
|
||||
/**
|
||||
* The M-Faisa account to act on: the selected source when it is one, otherwise the first
|
||||
* M-Faisa account available. The fallback is what lets a scanned merchant QR auto-switch
|
||||
* the source away from another bank.
|
||||
*/
|
||||
private fun mfaisaSource(): BankAccount? =
|
||||
currentSource()?.takeIf { it.bank == "MFAISA" }
|
||||
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" }
|
||||
|
||||
private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) {
|
||||
// Reuse the same recipient card the fragment uses for other banks. The fragment owns the
|
||||
@@ -241,7 +470,6 @@ class MfaisaTransferHandler(
|
||||
refId: String,
|
||||
errorMsg: String?
|
||||
) {
|
||||
val tf = fragment as? TransferFragment ?: return
|
||||
val view = fragment.view ?: return
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val colorMuted = MaterialColors.getColor(
|
||||
@@ -250,7 +478,7 @@ class MfaisaTransferHandler(
|
||||
view, com.google.android.material.R.attr.colorOutlineVariant, Color.LTGRAY)
|
||||
|
||||
val amountValue = try { "%.2f".format(amountStr.toDouble()) } catch (_: Exception) { amountStr }
|
||||
val confirmView = tf.buildTransferConfirmView(
|
||||
val confirmView = fragment.buildTransferConfirmView(
|
||||
amountCurrency = "MVR",
|
||||
amountValue = amountValue,
|
||||
fromName = source.accountBriefName,
|
||||
@@ -415,11 +643,11 @@ class MfaisaTransferHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private fun showError(e: Exception) {
|
||||
private fun showError(e: Throwable, fallback: String = "Transfer failed") {
|
||||
val msg = when {
|
||||
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
|
||||
!e.message.isNullOrBlank() -> e.message!!
|
||||
else -> "Transfer failed"
|
||||
else -> fallback
|
||||
}
|
||||
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.fragment.app.Fragment
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
||||
import sh.sar.basedbank.api.mib.MibLookupException
|
||||
import sh.sar.basedbank.api.mib.MibSession
|
||||
import sh.sar.basedbank.api.mib.MibTransferClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||
import sh.sar.basedbank.util.AccountInputParser
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.Totp
|
||||
|
||||
/**
|
||||
* Owns the MIB-only parts of the Transfer screen: session resolution, IPS destination lookup,
|
||||
* the transfer itself (profile switch + TOTP + `MibTransferClient`), and the profile/contact
|
||||
* avatar API that every bank's "To" card borrows.
|
||||
*
|
||||
* Unlike [BmlTransferHandler] and [MfaisaTransferHandler] this one holds no view binding and
|
||||
* paints nothing — every method is either a session accessor or a blocking API call meant for
|
||||
* `Dispatchers.IO`. [sh.sar.basedbank.ui.home.TransferFragment] keeps the coroutine plumbing and
|
||||
* the view updates, so this handler can outlive a view recreation.
|
||||
*/
|
||||
class MibTransferHandler(
|
||||
private val fragment: Fragment,
|
||||
/** Reads the fragment's currently-selected source account. */
|
||||
private val currentSource: () -> BankAccount?,
|
||||
) {
|
||||
|
||||
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||
private val ctx get() = fragment.requireContext()
|
||||
|
||||
/** The selected source's MIB session, falling back to any logged-in MIB session. */
|
||||
val session: MibSession?
|
||||
get() = currentSource()?.let { app.mibSessionFor(it) } ?: app.anyMibSession()
|
||||
|
||||
/** Any logged-in MIB session, ignoring which source is selected. */
|
||||
val anySession: MibSession? get() = app.anyMibSession()
|
||||
|
||||
// ─── Destination lookup ──────────────────────────────────────────────────
|
||||
|
||||
/** Either a resolved destination or the message to show the user — never both. */
|
||||
data class LookupOutcome(val info: MibIpsAccountInfo?, val error: String?)
|
||||
|
||||
/**
|
||||
* IPS lookup for a destination account/alias. Blocking — call from IO.
|
||||
* A missing MIB session is reported as "account not found", same as a failed lookup.
|
||||
*/
|
||||
fun lookupDestination(accountNumber: String): LookupOutcome {
|
||||
val sess = session ?: return LookupOutcome(null, notFound())
|
||||
return try {
|
||||
LookupOutcome(MibTransferClient().lookup(sess, accountNumber), null)
|
||||
} catch (e: MibLookupException) {
|
||||
LookupOutcome(null, e.message)
|
||||
} catch (_: Exception) {
|
||||
LookupOutcome(null, notFound())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Currency-only lookup, used to enrich a BML "verify MIB account" result — that endpoint
|
||||
* resolves the name but not the currency. Blocking; returns "" when it can't be determined.
|
||||
*/
|
||||
fun lookupCurrency(accountNumber: String): String {
|
||||
val sess = session ?: return ""
|
||||
return try { MibTransferClient().lookup(sess, accountNumber).currency }
|
||||
catch (_: Exception) { "" }
|
||||
}
|
||||
|
||||
private fun notFound() = ctx.getString(R.string.transfer_account_not_found)
|
||||
|
||||
// ─── Avatars ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetches and decodes a profile image (P41, [isProfile] true) or a saved contact's image.
|
||||
* Blocking — call from IO. Returns null on any failure, including a missing image.
|
||||
*
|
||||
* The session is passed in rather than read from [session] because callers differ: the "To"
|
||||
* card uses the selected source's session, the account dropdown uses [anySession].
|
||||
*/
|
||||
fun fetchAvatar(session: MibSession, hash: String, isProfile: Boolean): Bitmap? = try {
|
||||
val base64 = if (isProfile) {
|
||||
app.anyMibFlow()?.fetchProfileImage(session, hash)
|
||||
} else {
|
||||
MibContactsClient().fetchProfileImageBase64(session, hash)
|
||||
}
|
||||
if (base64 == null) null else {
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
// ─── Transfer ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Switches to the profile that owns [src], then runs the transfer with a freshly generated
|
||||
* TOTP. Blocking — call from IO. Returns `(ok, message, receipt)`; the message is
|
||||
* "CONNECTIVITY" when the network was unreachable.
|
||||
*/
|
||||
fun doTransfer(
|
||||
src: BankAccount,
|
||||
destAccount: String,
|
||||
destName: String,
|
||||
destDisplay: String,
|
||||
amount: String,
|
||||
remarks: String,
|
||||
bankName: String
|
||||
): Triple<Boolean, String, TransferReceiptData?> {
|
||||
val sess = session ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
|
||||
val loginId = src.loginTag.removePrefix("mib_")
|
||||
val otp = CredentialStore(ctx).loadMibCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) }
|
||||
?: 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 = AccountInputParser.detect(destAccount) == AccountInputParser.InputType.MIB_ACCOUNT
|
||||
val bankNo = if (isDestMib) 2 else 3
|
||||
val toBank = when {
|
||||
isDestMib -> "MIB"
|
||||
else -> when (bankName.uppercase()) {
|
||||
"MALBMVMV" -> "BML"
|
||||
"MADVMVMV" -> "MIB"
|
||||
else -> bankName.ifBlank { "LOCAL" }
|
||||
}
|
||||
}
|
||||
val isOwnAccount = isDestMib && app.mibAccounts.any {
|
||||
it.accountNumber == destAccount && it.loginTag == src.loginTag &&
|
||||
(src.profileId.isBlank() || it.profileId == src.profileId)
|
||||
}
|
||||
val transactionType = when {
|
||||
isOwnAccount -> "Own Transfer"
|
||||
isDestMib -> "MIB Transfer"
|
||||
else -> "Quick Transfer"
|
||||
}
|
||||
return try {
|
||||
// Switch to the profile that owns the source account
|
||||
if (src.profileId.isNotBlank()) {
|
||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||
val profile = profiles.firstOrNull { it.profileId == src.profileId }
|
||||
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
|
||||
}
|
||||
val result = MibTransferClient().transfer(
|
||||
session = sess,
|
||||
fromAccount = src.accountNumber,
|
||||
toAccount = destAccount,
|
||||
amount = amount,
|
||||
currencyCode = currencyCode,
|
||||
benefName = destName.ifBlank { "Recipient" },
|
||||
bankNo = bankNo,
|
||||
purpose = remarks,
|
||||
otp = otp
|
||||
)
|
||||
if (result.success) {
|
||||
val receipt = TransferReceiptData(
|
||||
bank = "MIB",
|
||||
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,
|
||||
mibFromProfileName = src.profileName,
|
||||
mibTransactionType = transactionType
|
||||
)
|
||||
Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt)
|
||||
} else {
|
||||
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,7 @@ class CredentialsFragment : Fragment() {
|
||||
val remaining = 30 - secondsInPeriod
|
||||
|
||||
binding.tvOtpCode.text = otp
|
||||
binding.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
|
||||
binding.otpTimer.max = 30
|
||||
binding.otpTimer.progress = remaining
|
||||
binding.cardOtp.visibility = View.VISIBLE
|
||||
|
||||
@@ -13,6 +13,8 @@ data class ContactDisplay(
|
||||
val network: TransferNetwork,
|
||||
val bankColor: String,
|
||||
val detail: String?, // pre-formatted "Name · CCY · Bank" line; null = hide row
|
||||
val bankName: String?, // shown in the contact details sheet; null = hide row
|
||||
val currency: String?, // shown in the contact details sheet; null = hide row
|
||||
val imageHash: String?,
|
||||
val profileId: String, // MIB profile ID or BML loginTag (needed by ContactManager)
|
||||
val transferSubtitle: String, // "Bank · accountNumber" shown in transfer screen
|
||||
|
||||
@@ -14,6 +14,11 @@ import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
class CredentialStore(context: Context) {
|
||||
|
||||
companion object {
|
||||
/** Same format as BankAccount.loginTag / MibCard.loginTag, e.g. "bml_<loginId>". */
|
||||
fun loginKey(bank: String, loginId: String) = "${bank}_$loginId"
|
||||
}
|
||||
|
||||
private val prefs = context.getSharedPreferences("credential_store", Context.MODE_PRIVATE)
|
||||
private val keyAlias = "basedbank_credential_key"
|
||||
private val transformation = "AES/GCM/NoPadding"
|
||||
@@ -23,6 +28,37 @@ class CredentialStore(context: Context) {
|
||||
data class FahipayCredentials(val idCard: String, val password: String)
|
||||
data class MfaisaCredentials(val msisdn: String, val pin: String)
|
||||
|
||||
// ── Cross-bank login order (keys are login tags, see loginKey) ──────────
|
||||
|
||||
/** User-chosen order of all logins; logins not yet ordered (e.g. newly added) follow, bank by bank. */
|
||||
fun getLoginOrder(): List<String> {
|
||||
val all = getMibLoginIds().map { loginKey("mib", it) } +
|
||||
getBmlLoginIds().map { loginKey("bml", it) } +
|
||||
getFahipayLoginIds().map { loginKey("fahipay", it) } +
|
||||
getMfaisaLoginIds().map { loginKey("mfaisa", it) }
|
||||
val saved = try {
|
||||
val arr = org.json.JSONArray(prefs.getString("login_order", null) ?: "[]")
|
||||
(0 until arr.length()).map { arr.getString(it) }
|
||||
} catch (_: Exception) { emptyList() }
|
||||
return saved.filter { it in all } + all.filter { it !in saved }
|
||||
}
|
||||
|
||||
/** Position of a login tag in [getLoginOrder]; unknown tags sort last. */
|
||||
fun loginRank(): (String) -> Int {
|
||||
val order = getLoginOrder()
|
||||
return { tag -> order.indexOf(tag).let { if (it < 0) Int.MAX_VALUE else it } }
|
||||
}
|
||||
|
||||
/** Saves the combined order and keeps each bank's own login list in the same relative order. */
|
||||
fun setLoginOrder(keys: List<String>) {
|
||||
prefs.edit().putString("login_order", org.json.JSONArray(keys).toString()).apply()
|
||||
fun idsFor(bank: String) = keys.filter { it.startsWith("${bank}_") }.map { it.removePrefix("${bank}_") }
|
||||
setMibLoginIds(idsFor("mib"))
|
||||
setBmlLoginIds(idsFor("bml"))
|
||||
setFahipayLoginIds(idsFor("fahipay"))
|
||||
setMfaisaLoginIds(idsFor("mfaisa"))
|
||||
}
|
||||
|
||||
// ── MIB login credentials (multi-login, keyed by loginId = username) ─────
|
||||
|
||||
fun getMibLoginIds(): List<String> {
|
||||
@@ -36,6 +72,11 @@ class CredentialStore(context: Context) {
|
||||
|
||||
fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty()
|
||||
|
||||
fun setMibLoginIds(order: List<String>) {
|
||||
if (order.toSet() != getMibLoginIds().toSet()) return
|
||||
prefs.edit().putString("mib_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||
}
|
||||
|
||||
private fun addMibLoginId(loginId: String) {
|
||||
val ids = getMibLoginIds().toMutableList()
|
||||
if (loginId !in ids) {
|
||||
@@ -160,6 +201,11 @@ class CredentialStore(context: Context) {
|
||||
|
||||
fun hasBmlCredentials(): Boolean = getBmlLoginIds().isNotEmpty()
|
||||
|
||||
fun setBmlLoginIds(order: List<String>) {
|
||||
if (order.toSet() != getBmlLoginIds().toSet()) return
|
||||
prefs.edit().putString("bml_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||
}
|
||||
|
||||
private fun addBmlLoginId(loginId: String) {
|
||||
val ids = getBmlLoginIds().toMutableList()
|
||||
if (loginId !in ids) {
|
||||
@@ -316,6 +362,11 @@ class CredentialStore(context: Context) {
|
||||
|
||||
fun hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty()
|
||||
|
||||
fun setFahipayLoginIds(order: List<String>) {
|
||||
if (order.toSet() != getFahipayLoginIds().toSet()) return
|
||||
prefs.edit().putString("fahipay_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||
}
|
||||
|
||||
private fun addFahipayLoginId(loginId: String) {
|
||||
val ids = getFahipayLoginIds().toMutableList()
|
||||
if (loginId !in ids) {
|
||||
@@ -473,6 +524,11 @@ class CredentialStore(context: Context) {
|
||||
|
||||
fun hasMfaisaCredentials(): Boolean = getMfaisaLoginIds().isNotEmpty()
|
||||
|
||||
fun setMfaisaLoginIds(order: List<String>) {
|
||||
if (order.toSet() != getMfaisaLoginIds().toSet()) return
|
||||
prefs.edit().putString("mfaisa_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||
}
|
||||
|
||||
private fun addMfaisaLoginId(loginId: String) {
|
||||
val ids = getMfaisaLoginIds().toMutableList()
|
||||
if (loginId !in ids) {
|
||||
@@ -764,6 +820,15 @@ class CredentialStore(context: Context) {
|
||||
fun setHiddenMibProfileIds(loginId: String, ids: Set<String>) =
|
||||
prefs.edit().putStringSet("mib_${loginId}_hidden_profile_ids", ids).apply()
|
||||
|
||||
// ── Per-account visibility (account numbers are globally unique) ─────────
|
||||
|
||||
/** Returns the set of account numbers the user has chosen to hide, across all logins. */
|
||||
fun getHiddenAccountNumbers(): Set<String> =
|
||||
prefs.getStringSet("hidden_account_numbers", emptySet()) ?: emptySet()
|
||||
|
||||
fun setHiddenAccountNumbers(accountNumbers: Set<String>) =
|
||||
prefs.edit().putStringSet("hidden_account_numbers", accountNumbers).apply()
|
||||
|
||||
// ── Crypto primitives ─────────────────────────────────────────────────────
|
||||
|
||||
private fun getOrCreateKey(): SecretKey {
|
||||
|
||||
@@ -9,22 +9,58 @@ data class PaymvQrData(
|
||||
|
||||
object PaymvQrParser {
|
||||
|
||||
private const val BML_GATEWAY_PREFIX = "https://pay.bml.com.mv/app/"
|
||||
private const val BML_EBANKING_PREFIX = "https://ebanking.bankofmaldives.com.mv/qrpay/"
|
||||
private const val BML_POS_DOMAIN = "mv.com.bml.qtr"
|
||||
|
||||
/**
|
||||
* Returns the BML gateway URL if [raw] is or contains one, otherwise null.
|
||||
* Handles both plain URL QRs and combined EMV QRs (e.g. Fahipay+BML card QR).
|
||||
* For combined EMV QRs the URL is parsed from TLV (root tag 35 → sub-tag 20 → sub-sub-tag 01)
|
||||
* rather than via regex, to avoid greedily consuming subsequent EMV tag bytes.
|
||||
* Returns the value to hand to the BML QR payment flow, or null when [raw] is not a BML QR.
|
||||
*
|
||||
* Three shapes exist:
|
||||
* - plain URL QRs — the QR text is the gateway short link or an ebanking `qrpay` link;
|
||||
* - combined EMV QRs (e.g. Fahipay+BML card QRs), which carry the full gateway URL in TLV at
|
||||
* `35` → `20` → `01`;
|
||||
* - BML POS QRs (supplementary data domain `mv.com.bml.qtr`), where the same TLV path holds a
|
||||
* bare reference such as `02:<32 hex>` rather than a URL. Those return the whole EMV payload,
|
||||
* which [bmlPayRequestKey] then reduces back to the reference the lookup wants.
|
||||
*
|
||||
* The TLV is walked rather than regex-matched so a URL cannot greedily swallow the EMV tags
|
||||
* that follow it.
|
||||
*/
|
||||
fun extractBmlGatewayUrl(raw: String): String? {
|
||||
if (raw.startsWith("https://pay.bml.com.mv/app/")) return raw
|
||||
return try {
|
||||
val root = parseTlv(raw)
|
||||
val bmlMerchantInfo = root["35"]?.let { parseTlv(it) } ?: return null
|
||||
val inner = bmlMerchantInfo["20"]?.let { parseTlv(it) } ?: return null
|
||||
inner["01"]?.takeIf { it.startsWith("https://pay.bml.com.mv/app/") }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
fun bmlQrPayTarget(raw: String): String? {
|
||||
if (raw.startsWith(BML_GATEWAY_PREFIX) || raw.startsWith(BML_EBANKING_PREFIX)) return raw
|
||||
val ref = bmlQrReference(raw) ?: return null
|
||||
if (ref.startsWith("https://")) return ref.takeIf { it.startsWith(BML_GATEWAY_PREFIX) }
|
||||
// A bare reference only means a BML POS QR when the supplementary domain says so; anything
|
||||
// else keeps falling through to the PayMV parse, as it did before POS QRs existed.
|
||||
return raw.takeIf { supplementaryDomain(it)?.startsWith(BML_POS_DOMAIN) == true }
|
||||
}
|
||||
|
||||
/** TLV `80` → `00` — the supplementary data domain (`mv.favara.mpqr`, `mv.com.bml.qtr`, …). */
|
||||
private fun supplementaryDomain(raw: String): String? = try {
|
||||
parseTlv(raw)["80"]?.let { parseTlv(it) }?.get("00")
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** TLV `35` → `20` → `01` — the BML payment reference carried inside an EMV QR. */
|
||||
private fun bmlQrReference(raw: String): String? = try {
|
||||
val merchantInfo = parseTlv(raw)["35"]?.let { parseTlv(it) }
|
||||
val inner = merchantInfo?.get("20")?.let { parseTlv(it) }
|
||||
inner?.get("01")?.takeIf { it.isNotBlank() }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* The `payrequest` lookup key for [target] (a value returned by [bmlQrPayTarget]), to be
|
||||
* Base64-encoded into the URL. URL QRs resolve under the URL itself; POS QRs resolve under the
|
||||
* bare `35` → `20` → `01` reference (the whole EMV payload is rejected with code 112,
|
||||
* "Unsupported payment link").
|
||||
*/
|
||||
fun bmlPayRequestKey(target: String): String {
|
||||
if (target.startsWith("https://")) return target
|
||||
return bmlQrReference(target) ?: target
|
||||
}
|
||||
|
||||
fun parse(raw: String): PaymvQrData? {
|
||||
|
||||
@@ -40,6 +40,8 @@ object ReceiptStore {
|
||||
remarks = o.optString("remarks"),
|
||||
mibReferenceNo = o.optString("mibReferenceNo"),
|
||||
mibTransactionDate = o.optString("mibTransactionDate"),
|
||||
mibFromProfileName = o.optString("mibFromProfileName"),
|
||||
mibTransactionType = o.optString("mibTransactionType"),
|
||||
bmlFromName = o.optString("bmlFromName"),
|
||||
bmlReference = o.optString("bmlReference"),
|
||||
bmlTimestamp = o.optString("bmlTimestamp"),
|
||||
@@ -76,6 +78,8 @@ object ReceiptStore {
|
||||
put("remarks", d.remarks)
|
||||
put("mibReferenceNo", d.mibReferenceNo)
|
||||
put("mibTransactionDate", d.mibTransactionDate)
|
||||
put("mibFromProfileName", d.mibFromProfileName)
|
||||
put("mibTransactionType", d.mibTransactionType)
|
||||
put("bmlFromName", d.bmlFromName)
|
||||
put("bmlReference", d.bmlReference)
|
||||
put("bmlTimestamp", d.bmlTimestamp)
|
||||
|
||||
@@ -9,10 +9,11 @@ object Totp {
|
||||
/**
|
||||
* Generate a 6-digit TOTP code from a Base32-encoded secret (RFC 6238 / RFC 4226).
|
||||
* Uses HmacSHA1, 30-second window, 6 digits — matching standard authenticator apps.
|
||||
* [periodOffset] shifts the time window, e.g. 1 yields the next code.
|
||||
*/
|
||||
fun generate(base32Secret: String, digits: Int = 6, periodSeconds: Long = 30): String {
|
||||
fun generate(base32Secret: String, digits: Int = 6, periodSeconds: Long = 30, periodOffset: Long = 0): String {
|
||||
val key = base32Decode(base32Secret.uppercase().replace(" ", "").replace("-", ""))
|
||||
val counter = System.currentTimeMillis() / 1000L / periodSeconds
|
||||
val counter = System.currentTimeMillis() / 1000L / periodSeconds + periodOffset
|
||||
val otp = hotp(key, counter, digits)
|
||||
return otp.toString().padStart(digits, '0')
|
||||
}
|
||||
|
||||
@@ -36,9 +36,9 @@ object BmlCardParser {
|
||||
"C8902", "C8907", "C8909", "C8912", "C8992", "C8996", "C8997", "C8982", "C8983" -> "cards/bml/master_islamic.png"
|
||||
"C8101" -> "cards/bml/master_masveriyaa.png"
|
||||
"C8102" -> "cards/bml/master_odiveriyaa.png"
|
||||
"C8010", "C8011" -> "cards/bml/master_platinum.png"
|
||||
"C8010", "C8011", "C8033" -> "cards/bml/master_platinum.png"
|
||||
"C8040", "C8044" -> "cards/bml/master_world.png"
|
||||
"C8030", "C8033" -> "cards/bml/master_business_debit.png"
|
||||
"C8030" -> "cards/bml/master_business_debit.png"
|
||||
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png"
|
||||
"C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
|
||||
"C8905", "C8995" -> "cards/bml/visa_credit.png"
|
||||
|
||||
@@ -15,6 +15,8 @@ object BmlContactParser {
|
||||
network = TransferNetwork.BML,
|
||||
bankColor = contact.bankColor,
|
||||
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
|
||||
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
|
||||
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
|
||||
imageHash = contact.customerImgHash,
|
||||
profileId = contact.profileId,
|
||||
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
||||
|
||||
@@ -15,6 +15,8 @@ object FahipayContactParser {
|
||||
network = TransferNetwork.FAHIPAY,
|
||||
bankColor = contact.bankColor,
|
||||
detail = null, // Fahipay contacts show no detail line
|
||||
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
|
||||
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
|
||||
imageHash = contact.customerImgHash,
|
||||
profileId = contact.profileId,
|
||||
transferSubtitle = contact.benefAccount,
|
||||
|
||||
@@ -21,6 +21,8 @@ object MibContactParser {
|
||||
network = network,
|
||||
bankColor = contact.bankColor,
|
||||
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
|
||||
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
|
||||
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
|
||||
imageHash = contact.customerImgHash,
|
||||
profileId = contact.profileId,
|
||||
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Dark-mode replacement for drawable/bottom_receipt_wave.jpg (988x48):
|
||||
receipt background above the teeth, footer colour below. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="329dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="988"
|
||||
android:viewportHeight="48">
|
||||
<path
|
||||
android:fillColor="@color/bml_receipt_bg"
|
||||
android:pathData="M0,0 L988,0 L988,48 L0,48 Z" />
|
||||
<path
|
||||
android:fillColor="@color/bml_receipt_footer"
|
||||
android:pathData="M0,48 L0,27 L24.08,8.28 Q30.88,3 37.67,8.28 L54.96,21.72 Q61.75,27 68.54,21.72 L85.83,8.28 Q92.63,3 99.42,8.28 L116.71,21.72 Q123.5,27 130.29,21.72 L147.58,8.28 Q154.38,3 161.17,8.28 L178.46,21.72 Q185.25,27 192.04,21.72 L209.33,8.28 Q216.13,3 222.92,8.28 L240.21,21.72 Q247,27 253.79,21.72 L271.08,8.28 Q277.88,3 284.67,8.28 L301.96,21.72 Q308.75,27 315.54,21.72 L332.83,8.28 Q339.63,3 346.42,8.28 L363.71,21.72 Q370.5,27 377.29,21.72 L394.58,8.28 Q401.38,3 408.17,8.28 L425.46,21.72 Q432.25,27 439.04,21.72 L456.33,8.28 Q463.13,3 469.92,8.28 L487.21,21.72 Q494,27 500.79,21.72 L518.08,8.28 Q524.88,3 531.67,8.28 L548.96,21.72 Q555.75,27 562.54,21.72 L579.83,8.28 Q586.63,3 593.42,8.28 L610.71,21.72 Q617.5,27 624.29,21.72 L641.58,8.28 Q648.38,3 655.17,8.28 L672.46,21.72 Q679.25,27 686.04,21.72 L703.33,8.28 Q710.13,3 716.92,8.28 L734.21,21.72 Q741,27 747.79,21.72 L765.08,8.28 Q771.88,3 778.67,8.28 L795.96,21.72 Q802.75,27 809.54,21.72 L826.83,8.28 Q833.63,3 840.42,8.28 L857.71,21.72 Q864.5,27 871.29,21.72 L888.58,8.28 Q895.38,3 902.17,8.28 L919.46,21.72 Q926.25,27 933.04,21.72 L950.33,8.28 Q957.13,3 963.92,8.28 L988,27 L988,48 Z" />
|
||||
</vector>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:startColor="#2E7D32"
|
||||
android:centerColor="#43A047"
|
||||
android:endColor="#66BB6A"
|
||||
android:angle="315"
|
||||
android:type="linear" />
|
||||
</shape>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,13 @@
|
||||
<?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:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M15,4 L7,12 L15,20" />
|
||||
</vector>
|
||||
@@ -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="M11.8,10.9c-2.27,-0.59 -3,-1.2 -3,-2.15 0,-1.09 1.01,-1.85 2.7,-1.85 1.78,0 2.44,0.85 2.5,2.1h2.21c-0.07,-1.72 -1.12,-3.3 -3.21,-3.81V3h-3v2.16c-1.94,0.42 -3.5,1.68 -3.5,3.61 0,2.31 1.91,3.46 4.7,4.13 2.5,0.6 3,1.48 3,2.41 0,0.69 -0.49,1.79 -2.7,1.79 -2.06,0 -2.87,-0.92 -2.98,-2.1h-2.2c0.12,2.19 1.76,3.42 3.68,3.83V21h3v-2.15c1.95,-0.37 3.5,-1.5 3.5,-3.55 0,-2.84 -2.43,-3.61 -4.7,-4.2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- MIB full-screen receipt close button: tinted-black disc, themed ring, white X -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="32"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:fillColor="#4D000000"
|
||||
android:strokeColor="@color/mib_receipt_close_ring"
|
||||
android:strokeWidth="1.5"
|
||||
android:pathData="M16,1.25 A14.75,14.75 0 1,1 16,30.75 A14.75,14.75 0 1,1 16,1.25 Z" />
|
||||
<path
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M11,11 L21,21 M21,11 L11,21" />
|
||||
</vector>
|
||||
@@ -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>
|
||||
@@ -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="?attr/colorOnSurfaceVariant"
|
||||
android:pathData="M4,7h16v2H4V7zM4,11h16v2H4v-2zM4,15h16v2H4v-2z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Full MIB logo (mark + "MALDIVES ISLAMIC BANK" wordmark); wordmark follows the receipt footer text color -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="187.6dp"
|
||||
android:height="22dp"
|
||||
android:viewportWidth="779.5"
|
||||
android:viewportHeight="91.4">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:strokeColor="#A8AAAC"
|
||||
android:strokeWidth="3.8833"
|
||||
android:strokeMiterLimit="10"
|
||||
android:pathData="M64.6,89.2H26.7c-13.5,0-24.5-11-24.5-24.5v-38c0-13.5,11-24.5,24.5-24.5h37.9c13.5,0,24.5,11,24.5,24.5v37.9 C89.1,78.2,78.2,89.2,64.6,89.2z" />
|
||||
<path
|
||||
android:fillColor="#1E2859"
|
||||
android:pathData="M49.4,28.7c1.7-1.7,3.5-3.3,5.4-4.8c1.9,1.5,3.7,3.1,5.4,4.8c9,9.2,14.4,21.7,14.4,35.3c0,0.7,0,1.5-0.1,2.2 H57.6l10.9-6.8c-0.5-6-2.4-11.8-5.6-17.2c-2.2-3.7-5-7-8.2-9.7c-3.2,2.7-5.9,6-8.2,9.7c-3.2,5.3-5,11.2-5.6,17.2l4.6,6.8H35 c0-0.7-0.1-1.5-0.1-2.2C34.9,50.3,40.4,37.9,49.4,28.7z" />
|
||||
<path
|
||||
android:fillColor="#006A4D"
|
||||
android:pathData="M41.9,28.7c-1.7-1.7-3.5-3.3-5.4-4.8c-1.9,1.5-3.7,3.1-5.4,4.8c-9,9.2-14.4,21.7-14.4,35.3 c0,0.7,0,1.5,0.1,2.2h16.9l-10.9-6.8c0.5-6,2.4-11.8,5.6-17.2c2.2-3.7,5-7,8.2-9.7c3.2,2.7,5.9,6,8.2,9.7c3.2,5.3,5,11.2,5.6,17.2 l-4.6,6.8h10.6c0-0.7,0.1-1.5,0.1-2.2C56.3,50.3,50.8,37.9,41.9,28.7z" />
|
||||
<path
|
||||
android:fillColor="@color/mib_receipt_footer_text"
|
||||
android:pathData="M146.4,49.5c-0.2-5.3-0.5-11.7-0.5-17.2h-0.2c-1.3,5-3,10.5-4.9,15.7l-6,17.8h-5.8l-5.5-17.5 c-1.6-5.2-3-10.8-4.1-15.9h-0.1c-0.2,5.4-0.4,11.9-0.7,17.5l-0.9,16.5h-7l2.7-41h9.9l5.4,16.5c1.5,4.8,2.7,9.7,3.8,14.2h0.2 c1.1-4.4,2.5-9.5,4.1-14.3l5.7-16.4h9.7l2.4,41h-7.3L146.4,49.5z" />
|
||||
<path
|
||||
android:fillColor="@color/mib_receipt_footer_text"
|
||||
android:pathData="M170,54.6l-3.5,11.6h-7.7l13.1-41h9.6l13.3,41h-8L183,54.6H170z M181.7,49l-3.2-10.1 c-0.8-2.5-1.5-5.3-2.1-7.7h-0.1c-0.6,2.4-1.2,5.2-1.9,7.7L171.2,49H181.7z" />
|
||||
<path
|
||||
android:fillColor="@color/mib_receipt_footer_text"
|
||||
android:pathData="M200.5,25.2h7.5V60h16.9v6.3h-24.3V25.2z" />
|
||||
<path
|
||||
android:fillColor="@color/mib_receipt_footer_text"
|
||||
android:pathData="M230.5,25.8c3.3-0.5,7.5-0.9,11.9-0.9c7.7,0,13,1.6,16.7,4.7c4,3.2,6.4,8.1,6.4,15.1c0,7.3-2.5,12.8-6.4,16.3 c-4.1,3.7-10.6,5.6-18.6,5.6c-4.4,0-7.7-0.2-10.1-0.5V25.8z M237.9,60.5c1,0.2,2.6,0.2,4.1,0.2c9.7,0.1,15.5-5.3,15.5-15.7 c0.1-9.1-5.2-14.2-14.5-14.2c-2.4,0-4.1,0.2-5.1,0.4V60.5z" />
|
||||
<path
|
||||
android:fillColor="@color/mib_receipt_footer_text"
|
||||
android:pathData="M279.5,25.2v41H272v-41H279.5z" />
|
||||
<path
|
||||
android:fillColor="@color/mib_receipt_footer_text"
|
||||
android:pathData="M297.4,66.2l-13.3-41h8.2l5.6,18.6c1.6,5.2,2.9,10,4,15h0.1c1.1-4.9,2.6-9.9,4.2-14.8l6-18.7h8l-14.2,41 H297.4z" />
|
||||
<path
|
||||
android:fillColor="@color/mib_receipt_footer_text"
|
||||
android:pathData="M347.6,48.1h-15.5v12h17.3v6.1h-24.8v-41h23.8v6.1h-16.4V42h15.5V48.1z" />
|
||||
<path
|
||||
android:fillColor="@color/mib_receipt_footer_text"
|
||||
android:pathData="M355.8,58.1c2.4,1.4,6.1,2.6,9.9,2.6c4.8,0,7.5-2.3,7.5-5.6c0-3.1-2.1-4.9-7.3-6.8c-6.8-2.4-11.1-6-11.1-11.9 c0-6.7,5.6-11.8,14.5-11.8c4.4,0,7.7,1,9.9,2.1l-1.8,6c-1.5-0.8-4.3-1.9-8.2-1.9c-4.7,0-6.8,2.6-6.8,4.9c0,3.2,2.4,4.6,7.8,6.8 c7.1,2.7,10.6,6.3,10.6,12.2c0,6.6-5,12.3-15.6,12.3c-4.3,0-8.8-1.2-11.1-2.6L355.8,58.1z" />
|
||||
<path
|
||||
android:fillColor="@color/mib_receipt_footer_text"
|
||||
android:pathData="M407.6,25.2v41h-7.5v-41H407.6z" />
|
||||
<path
|
||||
android:fillColor="@color/mib_receipt_footer_text"
|
||||
android:pathData="M416,58.1c2.4,1.4,6.1,2.6,9.9,2.6c4.8,0,7.5-2.3,7.5-5.6c0-3.1-2.1-4.9-7.3-6.8c-6.8-2.4-11.1-6-11.1-11.9 c0-6.7,5.6-11.8,14.5-11.8c4.4,0,7.7,1,9.9,2.1l-1.8,6c-1.5-0.8-4.3-1.9-8.2-1.9c-4.7,0-6.8,2.6-6.8,4.9c0,3.2,2.4,4.6,7.8,6.8 c7.1,2.7,10.6,6.3,10.6,12.2c0,6.6-5,12.3-15.6,12.3c-4.3,0-8.8-1.2-11.1-2.6L416,58.1z" />
|
||||
<path
|
||||
android:fillColor="@color/mib_receipt_footer_text"
|
||||
android:pathData="M447.8,25.2h7.5V60h16.9v6.3h-24.3V25.2z" />
|
||||
<path
|
||||
android:fillColor="@color/mib_receipt_footer_text"
|
||||
android:pathData="M486.2,54.6l-3.5,11.6H475l13.1-41h9.6l13.3,41h-8l-3.7-11.6H486.2z M498,49l-3.2-10.1 c-0.8-2.5-1.5-5.3-2.1-7.7h-0.1c-0.6,2.4-1.2,5.2-1.9,7.7L487.4,49H498z" />
|
||||
<path
|
||||
android:fillColor="@color/mib_receipt_footer_text"
|
||||
android:pathData="M551.1,49.5c-0.2-5.3-0.5-11.7-0.5-17.2h-0.2c-1.3,5-3,10.5-4.9,15.7l-6,17.8h-5.8l-5.5-17.5 c-1.6-5.2-3-10.8-4.1-15.9h-0.1c-0.2,5.4-0.4,11.9-0.7,17.5l-0.9,16.5h-7l2.7-41h9.9l5.4,16.5c1.5,4.8,2.7,9.7,3.8,14.2h0.2 c1.1-4.4,2.5-9.5,4.1-14.3l5.7-16.4h9.7l2.4,41h-7.3L551.1,49.5z" />
|
||||
<path
|
||||
android:fillColor="@color/mib_receipt_footer_text"
|
||||
android:pathData="M574.3,25.2v41h-7.5v-41H574.3z" />
|
||||
<path
|
||||
android:fillColor="@color/mib_receipt_footer_text"
|
||||
android:pathData="M612.3,65c-1.8,0.9-5.7,1.8-10.6,1.8c-13,0-20.9-8.2-20.9-20.6c0-13.5,9.4-21.7,21.9-21.7c4.9,0,8.5,1,10,1.8 l-1.6,6c-1.9-0.9-4.6-1.6-8-1.6c-8.3,0-14.4,5.2-14.4,15.1c0,9,5.3,14.8,14.3,14.8c3,0,6.2-0.6,8.2-1.5L612.3,65z" />
|
||||
<path
|
||||
android:fillColor="@color/mib_receipt_footer_text"
|
||||
android:pathData="M631.3,25.8c2.4-0.5,6.7-0.9,10.9-0.9c5.5,0,8.9,0.7,11.7,2.6c2.6,1.5,4.3,4.2,4.3,7.7c0,3.8-2.4,7.2-6.8,8.9 v0.1c4.3,1.1,8.3,4.5,8.3,10.2c0,3.7-1.6,6.5-4,8.5c-2.9,2.6-7.7,3.8-15.2,3.8c-4.1,0-7.3-0.3-9.2-0.5V25.8z M638.7,42h3.8 c5.2,0,8.1-2.4,8.1-5.9c0-3.8-2.9-5.6-7.7-5.6c-2.2,0-3.5,0.1-4.3,0.3V42z M638.7,60.8c1,0.1,2.3,0.2,4,0.2c4.8,0,9.1-1.8,9.1-6.9 c0-4.7-4.1-6.7-9.3-6.7h-3.7V60.8z" />
|
||||
<path
|
||||
android:fillColor="@color/mib_receipt_footer_text"
|
||||
android:pathData="M674.3,54.6l-3.5,11.6h-7.7l13.1-41h9.6l13.3,41h-8l-3.7-11.6H674.3z M686.1,49l-3.2-10.1 c-0.8-2.5-1.5-5.3-2.1-7.7h-0.1c-0.6,2.4-1.2,5.2-1.9,7.7L675.5,49H686.1z" />
|
||||
<path
|
||||
android:fillColor="@color/mib_receipt_footer_text"
|
||||
android:pathData="M704.8,66.2v-41h8.5l10.6,17.6c2.7,4.6,5.1,9.3,7,13.7h0.1c-0.5-5.5-0.7-10.8-0.7-17V25.2h6.9v41h-7.7 l-10.7-18c-2.6-4.5-5.4-9.6-7.4-14.2l-0.2,0.1c0.3,5.3,0.4,10.7,0.4,17.5v14.7H704.8z" />
|
||||
<path
|
||||
android:fillColor="@color/mib_receipt_footer_text"
|
||||
android:pathData="M745.9,25.2h7.4v18.9h0.2c1-1.6,2-3,3-4.4l10.7-14.4h9.2l-14.1,17.5l15,23.5h-8.8L757,47.5l-3.7,4.4v14.4 h-7.4V25.2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- MIB receipt header: solid green under the official app's translucent palm texture -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/mib_receipt_amount" />
|
||||
<item>
|
||||
<bitmap
|
||||
android:src="@drawable/mib_receipt_texture"
|
||||
android:gravity="fill" />
|
||||
</item>
|
||||
</layer-list>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 196 KiB |
Binary file not shown.
Binary file not shown.
@@ -48,6 +48,7 @@
|
||||
android:id="@+id/languageToggle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:baselineAligned="false"
|
||||
app:singleSelection="true"
|
||||
app:selectionRequired="true">
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Full-screen BML receipt, modelled on docs/bmlapi/tmp/recipt_full_*.jpg.
|
||||
The receipt card is inserted at the top of cardHolder, directly followed by
|
||||
the Save/Share buttons; the rest of the screen shows the footer colour. -->
|
||||
<LinearLayout
|
||||
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:orientation="vertical"
|
||||
android:background="@color/bml_receipt_footer">
|
||||
|
||||
<!-- Top bar — status bar inset is added as top padding in code -->
|
||||
<FrameLayout
|
||||
android:id="@+id/topBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/bml_receipt_bg">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnBack"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_gravity="start|center_vertical"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@drawable/ic_chevron_back"
|
||||
app:tint="@color/bml_receipt_amount"
|
||||
android:contentDescription="Back" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:text="Transfer successful"
|
||||
android:textSize="17sp"
|
||||
android:textColor="@color/bml_receipt_amount"
|
||||
android:fontFamily="@font/sofia_pro" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/bml_receipt_divider" />
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:overScrollMode="never"
|
||||
android:scrollbars="none">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardHolder"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<!-- receipt card goes here (index 0) -->
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/bml_receipt_bottom_divider" />
|
||||
|
||||
<!-- Bottom actions — nav bar inset is added to bottom padding in code -->
|
||||
<LinearLayout
|
||||
android:id="@+id/bottomBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@color/bml_receipt_footer"
|
||||
android:paddingHorizontal="20dp"
|
||||
android:paddingTop="20dp"
|
||||
android:paddingBottom="16dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnSaveFull"
|
||||
style="@style/Widget.Material3.Button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:insetTop="0dp"
|
||||
android:insetBottom="0dp"
|
||||
android:text="Save receipt"
|
||||
android:textAllCaps="false"
|
||||
android:letterSpacing="0"
|
||||
android:textSize="17sp"
|
||||
android:textColor="#FFFFFF"
|
||||
android:fontFamily="@font/sofia_pro"
|
||||
app:backgroundTint="@color/bml_red"
|
||||
app:cornerRadius="10dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnShareFull"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:insetTop="0dp"
|
||||
android:insetBottom="0dp"
|
||||
android:text="Share receipt"
|
||||
android:textAllCaps="false"
|
||||
android:letterSpacing="0"
|
||||
android:textSize="17sp"
|
||||
android:textColor="@color/bml_receipt_message"
|
||||
android:fontFamily="@font/sofia_pro"
|
||||
app:rippleColor="@color/bml_receipt_divider" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,96 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Full-screen MIB receipt. The receipt card is inserted into cardHolder and its green
|
||||
header is extended under the status bar in code; the close button floats over the
|
||||
header just below the status bar. Share/Save sit pinned at the bottom of the screen. -->
|
||||
<FrameLayout
|
||||
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="@color/mib_receipt_bg">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:overScrollMode="never"
|
||||
android:scrollbars="none">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/cardHolder"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</ScrollView>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/mib_receipt_divider" />
|
||||
|
||||
<!-- Bottom actions — nav bar inset is added to bottom padding in code -->
|
||||
<LinearLayout
|
||||
android:id="@+id/bottomBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="20dp"
|
||||
android:paddingTop="20dp"
|
||||
android:paddingBottom="16dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnShareFull"
|
||||
style="@style/Widget.Material3.Button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:insetTop="0dp"
|
||||
android:insetBottom="0dp"
|
||||
android:text="Share Receipt"
|
||||
android:textAllCaps="false"
|
||||
android:letterSpacing="0"
|
||||
android:textSize="17sp"
|
||||
android:textColor="#FFFFFF"
|
||||
app:backgroundTint="@color/mib_blue"
|
||||
app:cornerRadius="10dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnSaveFull"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:insetTop="0dp"
|
||||
android:insetBottom="0dp"
|
||||
android:text="Save Receipt"
|
||||
android:textAllCaps="false"
|
||||
android:letterSpacing="0"
|
||||
android:textSize="17sp"
|
||||
android:textColor="@color/mib_blue"
|
||||
app:backgroundTint="@color/mib_receipt_bg"
|
||||
app:strokeColor="@color/mib_blue"
|
||||
app:strokeWidth="1dp"
|
||||
app:cornerRadius="10dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Close (back) button — status bar inset is added to top margin in code -->
|
||||
<ImageButton
|
||||
android:id="@+id/btnClose"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_gravity="top|end"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@drawable/ic_mib_receipt_close"
|
||||
android:contentDescription="Close" />
|
||||
|
||||
</FrameLayout>
|
||||
@@ -175,6 +175,32 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:orientation="vertical"
|
||||
android:gravity="end"
|
||||
android:alpha="0.7">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Next"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvNextOtpCode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSecondaryContainer"
|
||||
android:letterSpacing="0.1"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/otpTimer"
|
||||
android:layout_width="32dp"
|
||||
|
||||
@@ -1,15 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:clipToPadding="false" />
|
||||
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:id="@+id/emptyState"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:padding="32dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:alpha="0.6"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_nav_otp"
|
||||
android:tint="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:text="No OTP codes yet.\nAdd an MIB or BML login to see its codes here."
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="24dp"
|
||||
android:orientation="vertical"
|
||||
android:background="#FFFFFF">
|
||||
android:background="@color/bml_receipt_bg">
|
||||
|
||||
<!-- BML icon (bmlicon.jpg, centered, ~52dp ≈ 146/1080*360dp) -->
|
||||
<ImageView
|
||||
@@ -32,7 +32,7 @@
|
||||
android:layout_height="52dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="20dp"
|
||||
android:src="@drawable/bml_icon"
|
||||
android:src="@drawable/bml_logo_vector"
|
||||
android:scaleType="fitCenter"
|
||||
android:adjustViewBounds="true"
|
||||
android:contentDescription="@null" />
|
||||
@@ -47,7 +47,7 @@
|
||||
android:layout_marginBottom="20dp"
|
||||
android:id="@+id/tvMessage"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#2D2D2D"
|
||||
android:textColor="@color/bml_receipt_message"
|
||||
android:fontFamily="@font/nunito_sans"
|
||||
android:gravity="center" />
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginBottom="13dp"
|
||||
android:textSize="42sp"
|
||||
android:textColor="#242424"
|
||||
android:textColor="@color/bml_receipt_amount"
|
||||
android:fontFamily="@font/sofia_pro"
|
||||
android:gravity="center" />
|
||||
|
||||
@@ -109,66 +109,66 @@
|
||||
|
||||
<!-- Status (value = green #8BC155) -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Status" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Status" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||
<TextView android:id="@+id/tvStatus" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="SUCCESS" android:textSize="15sp" android:textColor="#8BC155" android:fontFamily="@font/sofia_pro" />
|
||||
</LinearLayout>
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
||||
|
||||
<!-- Message (value wraps if long) -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Message" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Message" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||
<TextView android:id="@+id/tvMessageRow" android:layout_width="0dp" android:layout_weight="1.4" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
||||
</LinearLayout>
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
||||
|
||||
<!-- Reference -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reference" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reference" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||
<TextView android:id="@+id/tvReference" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" />
|
||||
</LinearLayout>
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
||||
|
||||
<!-- Transaction date -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Transaction date" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Transaction date" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||
<TextView android:id="@+id/tvTransactionDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" />
|
||||
</LinearLayout>
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
||||
|
||||
<!-- From (value uppercase) -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="From" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="From" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||
<TextView android:id="@+id/tvFrom" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:textAllCaps="false" />
|
||||
</LinearLayout>
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
||||
|
||||
<!-- To (stacked name + account) -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="vertical" android:gravity="end">
|
||||
<TextView android:id="@+id/tvToName" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
||||
<TextView android:id="@+id/tvToAccount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="2dp" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
||||
|
||||
<!-- Amount (value = green #8BC155) -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Amount" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Amount" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||
<TextView android:id="@+id/tvAmountRow" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#8BC155" android:fontFamily="@font/sofia_pro" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Remarks (hidden when empty) -->
|
||||
<View android:id="@+id/remarksDivider" android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" android:visibility="gone" />
|
||||
<View android:id="@+id/remarksDivider" android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" android:visibility="gone" />
|
||||
<LinearLayout android:id="@+id/remarksRow" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp" android:visibility="gone">
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Remarks" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Remarks" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||
<TextView android:id="@+id/tvRemarks" android:layout_width="0dp" android:layout_weight="1.4" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
||||
</LinearLayout>
|
||||
@@ -199,7 +199,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:background="#F5F5F5"
|
||||
android:background="@color/bml_receipt_footer"
|
||||
android:paddingVertical="22dp">
|
||||
|
||||
<TextView
|
||||
@@ -246,18 +246,10 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginHorizontal="4dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="Save"
|
||||
app:icon="@drawable/ic_save" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnDone"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="Done" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -376,18 +376,10 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginHorizontal="4dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="Save"
|
||||
app:icon="@drawable/ic_save" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnDone"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="Done" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
android:overScrollMode="never"
|
||||
android:scrollbars="none">
|
||||
|
||||
<!-- Renderable receipt card (header grows to fill remaining space) -->
|
||||
<!-- Renderable receipt card -->
|
||||
<LinearLayout
|
||||
android:id="@+id/receiptCard"
|
||||
android:layout_width="match_parent"
|
||||
@@ -23,76 +23,35 @@
|
||||
android:layout_marginHorizontal="40dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<!-- Green header — fills all space not taken by body -->
|
||||
<!-- Green header — from/to avatars and names -->
|
||||
<FrameLayout
|
||||
android:id="@+id/receiptHeader"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="200dp"
|
||||
android:background="@drawable/trx_success_bg">
|
||||
android:layout_height="160dp"
|
||||
android:background="@drawable/mib_receipt_header_bg">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="TRANSACTION RECEIPT"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="14sp"
|
||||
android:letterSpacing="0.08"
|
||||
android:layout_marginBottom="22dp" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="64dp"
|
||||
android:src="@drawable/ic_receipt_check"
|
||||
android:contentDescription="@null" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="SUCCESSFUL"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="16sp"
|
||||
android:letterSpacing="0.05"
|
||||
android:layout_marginTop="18dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<!-- White body — fixed size content -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="#FFFFFF"
|
||||
android:paddingTop="16dp">
|
||||
|
||||
<!-- Avatars row -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingBottom="14dp">
|
||||
android:paddingHorizontal="24dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
<!-- Plain ImageView: bitmaps are circle-cropped in code (ShapeableImageView's
|
||||
hardware-layer mask doesn't render on the scaled card or in captures) -->
|
||||
<ImageView
|
||||
android:id="@+id/ivFromAvatar"
|
||||
android:layout_width="52dp"
|
||||
android:layout_height="52dp"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||
android:scaleType="fitCenter"
|
||||
android:contentDescription="@null" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvFromLabel"
|
||||
@@ -100,7 +59,9 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#565656"
|
||||
android:maxWidth="110dp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#FFFFFF"
|
||||
android:gravity="center"
|
||||
android:maxLines="2" />
|
||||
|
||||
@@ -109,22 +70,25 @@
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginHorizontal="12dp"
|
||||
android:layout_marginHorizontal="10dp"
|
||||
android:src="@drawable/ic_arrow_right"
|
||||
android:tint="#FFFFFF"
|
||||
android:contentDescription="@null" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
<!-- Plain ImageView: bitmaps are circle-cropped in code (ShapeableImageView's
|
||||
hardware-layer mask doesn't render on the scaled card or in captures) -->
|
||||
<ImageView
|
||||
android:id="@+id/ivToAvatar"
|
||||
android:layout_width="52dp"
|
||||
android:layout_height="52dp"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||
android:scaleType="fitCenter"
|
||||
android:contentDescription="@null" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvToLabel"
|
||||
@@ -132,7 +96,9 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#565656"
|
||||
android:maxWidth="110dp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#FFFFFF"
|
||||
android:gravity="center"
|
||||
android:maxLines="2" />
|
||||
|
||||
@@ -140,81 +106,92 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
||||
</FrameLayout>
|
||||
|
||||
<!-- Total Amount -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="TOTAL AMOUNT"
|
||||
android:textSize="13sp"
|
||||
android:textColor="#a0a2a1"
|
||||
android:letterSpacing="0.08"
|
||||
android:gravity="center"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="4dp" />
|
||||
<!-- Body -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@color/mib_receipt_bg">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvAmount"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="34sp"
|
||||
android:textColor="#f14f0f"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/mib_receipt_amount"
|
||||
android:gravity="center"
|
||||
android:paddingBottom="10dp" />
|
||||
android:paddingTop="14dp" />
|
||||
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
||||
<TextView
|
||||
android:id="@+id/tvStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Success"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/mib_receipt_amount"
|
||||
android:gravity="center"
|
||||
android:paddingBottom="6dp" />
|
||||
|
||||
<!-- Reference # -->
|
||||
<!-- Transaction # -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reference #" android:textSize="15sp" android:textColor="#a0a2a1" />
|
||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||
<TextView android:id="@+id/tvReferenceNo" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Transaction#" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||
<TextView android:id="@+id/tvReferenceNo" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||
</LinearLayout>
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||
|
||||
<!-- To Account -->
|
||||
<!-- From (sender profile name) -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To Account" android:textSize="15sp" android:textColor="#a0a2a1" />
|
||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||
<TextView android:id="@+id/tvToAccount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="From" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||
<TextView android:id="@+id/tvFromName" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||
</LinearLayout>
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||
|
||||
<!-- To Bank -->
|
||||
<!-- To Account (recipient name + account number) -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To Bank" android:textSize="15sp" android:textColor="#a0a2a1" />
|
||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||
<TextView android:id="@+id/tvToBank" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="To Account" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||
<TextView android:id="@+id/tvToAccount" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||
</LinearLayout>
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||
|
||||
<!-- Bank -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Bank" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||
<TextView android:id="@+id/tvToBank" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||
</LinearLayout>
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||
|
||||
<!-- Transaction Type -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Transaction Type" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||
<TextView android:id="@+id/tvTransactionType" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||
</LinearLayout>
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||
|
||||
<!-- Transaction Date -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Transaction Date" android:textSize="15sp" android:textColor="#a0a2a1" />
|
||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||
<TextView android:id="@+id/tvTransactionDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Transaction Date" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||
<TextView android:id="@+id/tvTransactionDate" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||
</LinearLayout>
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||
|
||||
<!-- Value Date -->
|
||||
<!-- Processed Date -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Value Date" android:textSize="15sp" android:textColor="#a0a2a1" />
|
||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||
<TextView android:id="@+id/tvValueDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Processed Date" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||
<TextView android:id="@+id/tvValueDate" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||
</LinearLayout>
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||
|
||||
<!-- Purpose -->
|
||||
<!-- Remarks -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Purpose" android:textSize="15sp" android:textColor="#a0a2a1" />
|
||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||
<TextView android:id="@+id/tvPurpose" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Remarks" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||
<TextView android:id="@+id/tvPurpose" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- MIB footer -->
|
||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -224,19 +201,10 @@
|
||||
|
||||
<ImageView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="28dp"
|
||||
android:src="@drawable/mib_logo"
|
||||
android:layout_height="22dp"
|
||||
android:src="@drawable/mib_logo_full"
|
||||
android:adjustViewBounds="true"
|
||||
android:contentDescription="@null" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="MALDIVES ISLAMIC BANK"
|
||||
android:textSize="13sp"
|
||||
android:textColor="#000000"
|
||||
android:letterSpacing="0.05" />
|
||||
android:contentDescription="Maldives Islamic Bank" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -273,18 +241,10 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginHorizontal="4dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="Save"
|
||||
app:icon="@drawable/ic_save" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnDone"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="Done" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:src="@drawable/bml_icon"
|
||||
android:src="@drawable/bml_logo_vector"
|
||||
android:scaleType="fitCenter"
|
||||
android:contentDescription="BML" />
|
||||
|
||||
|
||||
@@ -282,6 +282,7 @@
|
||||
android:id="@+id/languageToggle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:baselineAligned="false"
|
||||
app:singleSelection="true"
|
||||
app:selectionRequired="true">
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
android:padding="16dp"
|
||||
android:clipToPadding="false">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvLoginsTitle"
|
||||
@@ -20,10 +21,12 @@
|
||||
android:layout_marginBottom="8dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- Rows pad back the negative margin so the tap/drag highlight has a gap around the content -->
|
||||
<LinearLayout
|
||||
android:id="@+id/loginsContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="-12dp"
|
||||
android:orientation="vertical" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
|
||||
@@ -148,12 +148,16 @@
|
||||
app:endIconDrawable="@android:drawable/ic_menu_search"
|
||||
app:endIconContentDescription="@string/transfer_lookup_account">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView
|
||||
android:id="@+id/etTo"
|
||||
style="@style/Widget.Material3.AutoCompleteTextView.OutlinedBox"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textNoSuggestions"
|
||||
android:maxLines="1" />
|
||||
android:maxLines="1"
|
||||
android:completionThreshold="3"
|
||||
android:dropDownHeight="wrap_content"
|
||||
android:imeOptions="actionSearch" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="10dp">
|
||||
|
||||
@@ -26,25 +27,6 @@
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnEditContact"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@drawable/ic_edit"
|
||||
android:padding="6dp"
|
||||
android:contentDescription="@string/contact_edit" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnDeleteContact"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@drawable/ic_delete"
|
||||
android:padding="6dp"
|
||||
android:tint="?attr/colorError"
|
||||
android:contentDescription="@string/contact_delete" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnTransferContact"
|
||||
android:layout_width="36dp"
|
||||
|
||||
@@ -2,55 +2,163 @@
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
style="@style/Widget.Material3.CardView.Filled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:cardElevation="2dp">
|
||||
app:cardCornerRadius="24dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
android:paddingHorizontal="20dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvOtpLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvOtpCode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceDisplaySmall"
|
||||
android:textColor="?attr/colorPrimary"
|
||||
android:fontFamily="monospace"
|
||||
android:letterSpacing="0.15"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/otpProgress"
|
||||
<!-- Header: logo, bank + holder name, countdown ring -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:max="30"
|
||||
app:trackCornerRadius="4dp"
|
||||
app:indicatorColor="?attr/colorPrimary"
|
||||
app:trackColor="?attr/colorSurfaceVariant" />
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvOtpCountdown"
|
||||
android:layout_width="wrap_content"
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/ivBankLogo"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:scaleType="fitCenter"
|
||||
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/tvOtpBank"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvOtpLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp">
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/otpProgress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:indeterminate="false"
|
||||
android:max="30"
|
||||
app:indicatorSize="40dp"
|
||||
app:trackThickness="3dp"
|
||||
app:trackCornerRadius="2dp"
|
||||
app:indicatorColor="?attr/colorPrimary"
|
||||
app:trackColor="?attr/colorOutlineVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvOtpCountdown"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Current code + copy -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvOtpCode"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:fontFamily="monospace"
|
||||
android:letterSpacing="0.12"
|
||||
android:textAppearance="?attr/textAppearanceDisplaySmall"
|
||||
android:textColor="?attr/colorPrimary"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnCopyOtp"
|
||||
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:contentDescription="Copy OTP"
|
||||
android:tooltipText="Copy OTP"
|
||||
app:icon="@drawable/ic_copy" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.divider.MaterialDivider
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<!-- Next code + copy -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="Next"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvNextOtpCode"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:fontFamily="monospace"
|
||||
android:letterSpacing="0.1"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnCopyNextOtp"
|
||||
style="@style/Widget.Material3.Button.IconButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:contentDescription="Copy next OTP"
|
||||
android:tooltipText="Copy next OTP"
|
||||
app:icon="@drawable/ic_copy"
|
||||
app:iconTint="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
<?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="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingBottom="24dp">
|
||||
|
||||
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/ivPhoto"
|
||||
android:layout_width="112dp"
|
||||
android:layout_height="112dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:scaleType="centerCrop"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:gravity="center_horizontal"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/actionRow"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/actionTransfer"
|
||||
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/btnTransfer"
|
||||
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||
android:layout_width="56dp"
|
||||
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"
|
||||
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/btnEdit"
|
||||
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||
android:layout_width="56dp"
|
||||
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" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/actionDelete"
|
||||
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/btnDelete"
|
||||
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
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" />
|
||||
|
||||
<TextView
|
||||
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" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.divider.MaterialDivider
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/rowAccount"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="64dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_manage_card"
|
||||
app:tint="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvAccount"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textIsSelectable="false" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/contact_account_number"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</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"
|
||||
android:layout_height="40dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/contact_copy_account"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_copy"
|
||||
app:tint="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/rowRealName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="64dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_contacts"
|
||||
app:tint="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvRealName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textIsSelectable="false" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/contact_account_name"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/rowCurrency"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="64dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_currency"
|
||||
app:tint="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCurrency"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textIsSelectable="false" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/contact_currency"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/rowBank"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="64dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/ivBankIcon"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_nav_finances"
|
||||
app:tint="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvBank"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textIsSelectable="false" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/contact_bank"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/sourceSection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<com.google.android.material.divider.MaterialDivider
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginVertical="8dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="56dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/ivSourceIcon"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:importantForAccessibility="no" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSource"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSourceBank"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
@@ -145,4 +145,5 @@
|
||||
<!-- Connectivity banner -->
|
||||
<string name="connectivity_no_internet">އިންޓަނެޓް ބައްލަވާ، ދެން ތިޖޫރީ ލޯޑް ކުރޭ</string>
|
||||
<string name="connectivity_server_error">%s އާ ގުޅުމުގައި މައްސަލައެއް</string>
|
||||
<string name="drag_to_reorder">ދަމާ ތަރުތީބު ބަދަލުކުރޭ</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- BML receipt — dark mode -->
|
||||
<color name="bml_receipt_bg">#191A1C</color>
|
||||
<color name="bml_receipt_message">#DEDEE0</color>
|
||||
<color name="bml_receipt_amount">#DEDEE0</color>
|
||||
<color name="bml_receipt_label">#DEDEE0</color>
|
||||
<color name="bml_receipt_footer">#000000</color>
|
||||
<color name="bml_receipt_divider">#3D3E40</color>
|
||||
<color name="bml_receipt_bottom_divider">#1E1F21</color>
|
||||
|
||||
<!-- MIB receipt — dark mode -->
|
||||
<color name="mib_receipt_bg">#1A1A3C</color>
|
||||
<color name="mib_receipt_label">#FFFFFF</color>
|
||||
<color name="mib_receipt_value">#FFFFFF</color>
|
||||
<color name="mib_receipt_divider">#33335C</color>
|
||||
<color name="mib_receipt_footer_text">#FFFFFF</color>
|
||||
<color name="mib_receipt_close_ring">#000000</color>
|
||||
</resources>
|
||||
@@ -5,4 +5,24 @@
|
||||
<color name="seed_secondary">#9AD141</color>
|
||||
<color name="color_unpaid">#E85D04</color>
|
||||
<color name="ic_logo_background">#E8B547</color>
|
||||
|
||||
<!-- BML receipt (dark variants in values-night/colors.xml) -->
|
||||
<color name="bml_receipt_bg">#FFFFFF</color>
|
||||
<color name="bml_receipt_message">#2D2D2D</color>
|
||||
<color name="bml_receipt_amount">#242424</color>
|
||||
<color name="bml_receipt_label">#000000</color>
|
||||
<color name="bml_receipt_footer">#F5F5F5</color>
|
||||
<color name="bml_receipt_divider">#E9E9E9</color>
|
||||
<color name="bml_receipt_bottom_divider">#EBEBEB</color>
|
||||
<color name="bml_red">#E21B23</color>
|
||||
|
||||
<!-- MIB receipt (dark variants in values-night/colors.xml) -->
|
||||
<color name="mib_receipt_bg">#FFFFFF</color>
|
||||
<color name="mib_receipt_label">#000000</color>
|
||||
<color name="mib_receipt_value">#000000</color>
|
||||
<color name="mib_receipt_divider">#CAC4D0</color>
|
||||
<color name="mib_receipt_footer_text">#000000</color>
|
||||
<color name="mib_receipt_amount">#1EA833</color>
|
||||
<color name="mib_receipt_close_ring">#FFFFFF</color>
|
||||
<color name="mib_blue">#006FFC</color>
|
||||
</resources>
|
||||
|
||||
@@ -127,7 +127,6 @@
|
||||
<string name="paymvqr_save_failed">Failed to save image</string>
|
||||
<string name="paymvqr_include_phone">Include phone number</string>
|
||||
<string name="paymvqr_reference_hint">Reference (optional)</string>
|
||||
<string name="paymvqr_reference_default">PayMV QR Transfer</string>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<string name="action_lock">Lock app</string>
|
||||
@@ -332,6 +331,14 @@
|
||||
<string name="contact_delete_message">Remove %s from your contacts?</string>
|
||||
<string name="contact_deleted">Contact deleted</string>
|
||||
<string name="contact_delete_failed">Could not delete contact</string>
|
||||
<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 -->
|
||||
<string name="financing_empty">No financing deals found</string>
|
||||
@@ -391,4 +398,5 @@
|
||||
<!-- Connectivity banner -->
|
||||
<string name="connectivity_no_internet">Please check your connection and reload Thijooree</string>
|
||||
<string name="connectivity_server_error">Connectivity issue with %s</string>
|
||||
<string name="drag_to_reorder">Drag to reorder</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<style name="ShapeAppearance.Circle" parent="ShapeAppearance.Material3.Corner.Full" />
|
||||
|
||||
<!-- Destructive confirmation dialog: centered icon/title, filled red confirm button -->
|
||||
<style name="ThemeOverlay.BasedBank.DestructiveDialog" parent="ThemeOverlay.Material3.MaterialAlertDialog.Centered">
|
||||
<item name="buttonBarPositiveButtonStyle">@style/Widget.BasedBank.DestructiveDialogButton</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.BasedBank.DestructiveDialogButton" parent="Widget.Material3.Button">
|
||||
<item name="backgroundTint">?attr/colorError</item>
|
||||
<item name="android:textColor">?attr/colorOnError</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.BasedBank" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="colorPrimary">@color/seed_primary</item>
|
||||
<item name="colorSecondary">@color/seed_secondary</item>
|
||||
|
||||
@@ -190,9 +190,9 @@ Known asset mappings:
|
||||
|---|---|---|
|
||||
| `C8201`, `C8001`, `C8009` | Mastercard Prepaid | `master_prepaid` |
|
||||
| `C8205`, `C8005`, `C8008` | Mastercard Prepaid Travel | `master_prepaid_travel` |
|
||||
| `C8010`, `C8011` | Mastercard Platinum | `master_platinum` |
|
||||
| `C8010`, `C8011`, `C8033` | Mastercard Platinum | `master_platinum` |
|
||||
| `C8020`, `C8022` | Mastercard Gold | `master_gold` |
|
||||
| `C8030`, `C8033` | Mastercard Business Debit | `master_business_debit` |
|
||||
| `C8030` | Mastercard Business Debit | `master_business_debit` |
|
||||
| `C8040`, `C8044` | Mastercard World | `master_world` |
|
||||
| `C8101` | Mastercard Masveriyaa | `master_masveriyaa` |
|
||||
| `C8102` | Mastercard Odiveriyaa | `master_odiveriyaa` |
|
||||
|
||||
@@ -33,6 +33,16 @@ TLV path: **root tag `35` → sub-tag `20` → sub-sub-tag `01`**
|
||||
|
||||
The value at tag `01` is the full `https://pay.bml.com.mv/app/...` URL.
|
||||
|
||||
### 3. POS QR (`mv.com.bml.qtr`)
|
||||
|
||||
BML POS terminals emit an EMVCo-style dynamic QR with no tag `26` and supplementary domain
|
||||
`mv.com.bml.qtr`. The same TLV path (`35` → `20` → `01`) holds a bare reference such as
|
||||
`02:215c7b9f15ce4ed28e15697ea976db99` instead of a URL. That bare reference — not the whole payload,
|
||||
which is rejected with code `112` — is what gets Base64-encoded into the payrequest lookup below.
|
||||
The reference does **not** resolve as a `pay.bml.com.mv/app/` short code in a browser; only the API
|
||||
understands it. See
|
||||
[PayMV QR Format → BML POS QR](../thijooree/18-paymv-qr-format.md#bml-pos-qr-mvcombmlqtr).
|
||||
|
||||
---
|
||||
|
||||
## PayMV QR Format (TLV)
|
||||
@@ -71,19 +81,29 @@ PayMV QRs (static, PayMV-native) use a decimal TLV encoding (not BER-TLV):
|
||||
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/{base64Url}
|
||||
```
|
||||
|
||||
`{base64Url}` is the full QR URL (e.g. `https://pay.bml.com.mv/app/...`) base64-encoded with standard encoding (with padding).
|
||||
`{base64Url}` is the lookup key, base64-encoded with standard encoding — the full QR URL
|
||||
(e.g. `https://pay.bml.com.mv/app/...`), or for POS QRs the bare `35` → `20` → `01` reference.
|
||||
BML's own app omits the `=` padding; the padded form resolves as well, so the client matches the app
|
||||
rather than relying on either being required.
|
||||
|
||||
### Headers
|
||||
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `accept` | `application/json` — **required in practice**, see below |
|
||||
| `Authorization` | `Bearer <access_token>` |
|
||||
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||
| `x-app-version` | `2.1.44.348` |
|
||||
|
||||
> **`accept: application/json` is not optional.** Handled errors (codes 103, 112, …) come back as
|
||||
> JSON regardless, but when the route throws, the server renders the Internet Banking HTML login
|
||||
> page — under HTTP **200** — instead of a JSON error body. A client without the header then sees a
|
||||
> "successful" HTML response it cannot parse. All BML API requests set it in `bmlApiRequest()`.
|
||||
|
||||
```bash
|
||||
curl --request GET \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/<base64Url>' \
|
||||
--header 'accept: application/json' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348'
|
||||
@@ -116,6 +136,16 @@ curl --request GET \
|
||||
| `amount` | Payment amount (`"0.00"` for static QRS) |
|
||||
| `currency` | Currency code (typically `"MVR"`) |
|
||||
|
||||
### Failure Responses
|
||||
|
||||
`success: false` comes back with a code and a user-facing message; the client shows BML's own
|
||||
wording and keeps the user on the Transfer screen.
|
||||
|
||||
| Code | Message |
|
||||
|---|---|
|
||||
| `103` | The payment request has expired |
|
||||
| `112` | Unsupported payment link |
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Pay (3-Step TOTP Flow)
|
||||
|
||||
@@ -128,4 +128,4 @@ Fetch all four service groups in sequence. For each group:
|
||||
|
||||
---
|
||||
|
||||
[← Profile Picture](06-profile-picture.md)
|
||||
[← Profile Picture](06-profile-picture.md) **Next →** [PayMV QR](08-paymv-qr.md)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# PayMV QR (Receive)
|
||||
|
||||
> ⚠️ **Work in progress.** Thijooree does not call this endpoint yet. It generates Fahipay QRs locally, and the Fahipay app currently rejects those as **"Invalid QR"**. See [PayMV QR Format → Fahipay](../thijooree/18-paymv-qr-format.md#fahipay-work-in-progress).
|
||||
|
||||
Fahipay's app does **not** build its receive QR on the device. Its `PayMVQR` screen asks the server for a finished card image and displays it. Found by decompiling the app (v2.0.2, Hermes bundle), **not yet confirmed with a traffic capture**: request headers and the exact response shape are unverified.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
GET api/app/qr/?lang=<lang>&type=p2p&amount=<amount>
|
||||
```
|
||||
|
||||
The app also has a `POST api/app/qr/` variant, sent as form data with `type=p2p`, `lang`, `version`, `platform=app`, `amount` and `device[...]` fields.
|
||||
|
||||
## Response
|
||||
|
||||
The app reads the image from the first of these fields that is present: `qr_image`, `qr`, `image`, `qr_url`, `qr_code`. The value is either an `http…` URL or raw base64, which the app prefixes with `data:image/png;base64,`. The payload text is read from `qr_code_text` / `qrCode`.
|
||||
|
||||
## The card image
|
||||
|
||||
A 1240 × 1322 PNG:
|
||||
- Blue `#005DA3` card with a 6 px border.
|
||||
- The square Fahipay icon plus the "FahiPay" wordmark top-left, and "PayMV QR" top-right.
|
||||
- A blue panel holding the holder's name and the QR.
|
||||
- The reference (`62→05`, e.g. `P2KVTPYL4E`) printed vertically in blue in the white margin right of the panel. The amount is not included in it.
|
||||
- A "MALDIVES NATIONAL QR" footer.
|
||||
|
||||
Thijooree reproduces this layout in `PayMvQrFragment.renderFahipayQrCard()`; see [PayMV QR Screen](../thijooree/11-paymv-qr-screen.md#fahipay--renderfahipayqrcard).
|
||||
|
||||
Server-issued payload fields that differ from a plain PayMV QR: `60` = `LD` + 4 digits, `62→05` = `P` + 9 chars, `62→08` = `PAYMENT`, and `54` = `***` when there is no amount. Full samples are in [PayMV QR Format](../thijooree/18-paymv-qr-format.md#real-receive-qrs-reference-samples).
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← Saved Favourites](07-contacts.md)
|
||||
@@ -127,6 +127,7 @@ Client Server
|
||||
| 5 | [Transaction History](05-history.md) | Paginated activity/transaction history |
|
||||
| 6 | [Profile Picture](06-profile-picture.md) | Local-only profile picture storage (no Fahipay endpoint) |
|
||||
| 7 | [Saved Favourites](07-contacts.md) | Fetch saved contacts per payment service |
|
||||
| 8 | [PayMV QR](08-paymv-qr.md) | Server-generated receive QR (`api/app/qr/`) — work in progress |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -279,4 +279,4 @@ python tmp/mfaisa_transfer.py <myMsisdn> <myMpin> <recipientMsisdn>
|
||||
|
||||
---
|
||||
|
||||
> **← Back to** [Transaction History](03-history.md) | [README](README.md)
|
||||
> **← Back to** [Transaction History](03-history.md) | [README](README.md) | **Next →** [QR Merchant Payment](05-qr-pay.md)
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
# QR Merchant Payment ("Smart Pay")
|
||||
|
||||
Pay an Ooredoo M-Faisa merchant by scanning their QR. The QR encodes only a numeric `qrCodeId` (e.g. `1594103440350`) — no URL, no EMV TLV envelope. The flow is three calls and **does not require OTP** (`2FARequired=NONE`).
|
||||
|
||||
> **Currency / pocket constraints:** captures cover MVR→MVR purchases from the user's EMONEY pocket only. The `transactionCurrency` field is taken from the QR lookup; we have not seen a non-MVR variant.
|
||||
|
||||
---
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
Client Server
|
||||
| |
|
||||
| POST /QRCodeUtility/fetchQRCodeById | ← user scanned a QR
|
||||
| formData = { qrCodeId, tenantCode } |
|
||||
|------------------------------------------------>|
|
||||
| [{ success, response:[{ commercialName, |
|
||||
| customerId, mobileNumber, currencyCode, |
|
||||
| txnAmount, status, ... }] }] |
|
||||
|<------------------------------------------------|
|
||||
| |
|
||||
| (show merchant, accept amount + remarks)
|
||||
| |
|
||||
| POST /initiateNewBuy |
|
||||
| formData = { merchantId, mobileNumber, |
|
||||
| sourceDetails, transactionAmount, |
|
||||
| transactionType:"PURCHASE", … } |
|
||||
|------------------------------------------------>|
|
||||
| [{ 2FARequired:"NONE", |
|
||||
| authenticationType:"NONE", |
|
||||
| success:true, |
|
||||
| response:[{ responseObject:{ referenceId, |
|
||||
| chargeDetails, … } }] }] |
|
||||
|<------------------------------------------------|
|
||||
| |
|
||||
| (no OTP — go straight to confirm)
|
||||
| |
|
||||
| POST /confirmNewBuy |
|
||||
| formData = { referenceId } |
|
||||
| transactionAuthDetails = "null" ← literal |
|
||||
|------------------------------------------------>|
|
||||
| [{ success:true, |
|
||||
| message:"Payment Completed Successfully", |
|
||||
| response:[{ responseObject:{ isCompleted, |
|
||||
| balanceInquiryDTO, ... } }] }] |
|
||||
|<------------------------------------------------|
|
||||
```
|
||||
|
||||
All three endpoints carry the standard anti-replay pair (`rndValue` + `csValue`) derived from each request's `formData` JSON — see [01-encryption.md → rndValue / csValue](01-encryption.md#anti-replay-envelope-rndvalue--csvalue).
|
||||
|
||||
Unlike the transfer flow, **every endpoint here returns its envelope as a JSON array** `[{...}]` for both success and error.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: `QRCodeUtility/fetchQRCodeById` — resolve merchant
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/QRCodeUtility/fetchQRCodeById
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
**Content-Type:** `application/x-www-form-urlencoded`
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `role` | **`R01`** (the other two endpoints use `RETAIL_SUBSCRIBER` — this one does not) |
|
||||
| `channel` | `C03` |
|
||||
| `loginExchangeKey` | From login |
|
||||
| `rndValue` / `csValue` | [Standard anti-replay](01-encryption.md#anti-replay-envelope-rndvalue--csvalue) |
|
||||
| `formData` | JSON below ([html-safe `=` escaping](01-encryption.md#html-safe-gson--escape)) |
|
||||
|
||||
```json
|
||||
{
|
||||
"qrCodeId": "<numeric id from QR>",
|
||||
"tenantCode": "ooredoo"
|
||||
}
|
||||
```
|
||||
|
||||
### Response — happy path
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"success": true,
|
||||
"message": "QRCode fetched Successfully.",
|
||||
"response": [
|
||||
{
|
||||
"mobileNumber": "9609569506", /* merchant's '960' + msisdn */
|
||||
"customerId": "72518", /* used as merchantId in step 2 */
|
||||
"commercialName": "Family Room", /* merchant display name */
|
||||
"qrCodeId": "1594103440350",
|
||||
"qrImageString": "<base64 PNG>", /* unused */
|
||||
"accountNumber": null,
|
||||
"txnAmount": null, /* static QR; dynamic QRs put a number here */
|
||||
"currencyCode": "MVR",
|
||||
"status": "Active",
|
||||
"role": "AGENT",
|
||||
"tenantCode": "ooredoo",
|
||||
"...": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`accountNumber` / `txnAmount` are JSON `null` for **static QRs** (the user chooses the amount). For **dynamic QRs** the server returns the fixed amount in `txnAmount` and the client should lock the amount field.
|
||||
|
||||
The `mobileNumber` field already includes the `960` country prefix.
|
||||
|
||||
### Response — QR not found / inactive
|
||||
|
||||
```json
|
||||
[{ "success": false, "message": "QRCode not found." }]
|
||||
```
|
||||
|
||||
The client also rejects entries with `status != "Active"`.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: `initiateNewBuy` — initiate purchase (no OTP triggered)
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/initiateNewBuy
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
**Content-Type:** `application/x-www-form-urlencoded`
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `role` | `RETAIL_SUBSCRIBER` |
|
||||
| `channel` | `C03` (top-level differs from inner `formData.channel`, which is `SubscriberApp`) |
|
||||
| `loginExchangeKey` | From login |
|
||||
| `rndValue` / `csValue` | Standard anti-replay (derived from the `formData` below) |
|
||||
| `formData` | JSON below |
|
||||
|
||||
```json
|
||||
{
|
||||
"channel": "SubscriberApp",
|
||||
"commodityType": "WALLET",
|
||||
"description": "<remarks>", /* free text, may be empty */
|
||||
"merchantId": "<customerId from step 1>",
|
||||
"mobileNumber": "<merchant '960' msisdn from step 1>",
|
||||
"sourceDetails": {
|
||||
"MDNId": "960<myMsisdn>", /* PLAINTEXT — '960' + my phone */
|
||||
"actorRoleType": "RETAIL_SUBSCRIBER",
|
||||
"pocketId": "<my source pocket id>" /* EMONEY pocket from login */
|
||||
},
|
||||
"transactionAmount": "<amount>", /* string, e.g. "7.56" */
|
||||
"transactionCurrency": "MVR",
|
||||
"transactionType": "PURCHASE"
|
||||
}
|
||||
```
|
||||
|
||||
Unlike the transfer flow's `initiateFTRequest`, this endpoint does **not** take an `identifier` header field or `tPin`.
|
||||
|
||||
### Response — happy path
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"2FARequired": "NONE", /* ← the key difference */
|
||||
"authenticationType": "NONE",
|
||||
"success": true,
|
||||
"message": "Purchase Initiated Successfully",
|
||||
"response": [
|
||||
{
|
||||
"requestObject": { "...": "..." },
|
||||
"responseObject": {
|
||||
"referenceId": "685011023630",
|
||||
"transactionAmount": { "amount": 7.56, "currencyCode": "MVR" },
|
||||
"netAmount": { "amount": 7.56, "currencyCode": "MVR" },
|
||||
"chargeDetailsDTO": { "totalFeesInTenantCurrency": { "amount": 0.0, "...": "..." }, "...": "..." },
|
||||
"isCompleted": false,
|
||||
"authenticationType":"NONE",
|
||||
"...": "..."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Cache `referenceId` for step 3. No SMS is sent — proceed straight to confirm.
|
||||
|
||||
> **Defensive check:** the Thijooree client throws if it ever sees `2FARequired != "NONE"` on this endpoint so a no-op confirm can't silently complete. If Ooredoo ever turns 2FA on for QR pay, you'll see a clear error instead of a partial transaction.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: `confirmNewBuy` — settle purchase
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/confirmNewBuy
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
**Content-Type:** `application/x-www-form-urlencoded`
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `role` | `RETAIL_SUBSCRIBER` |
|
||||
| `channel` | `C03` |
|
||||
| `loginExchangeKey` | From login |
|
||||
| `rndValue` / `csValue` | Anti-replay derived from `formData` below |
|
||||
| `formData` | `{"referenceId": "<from step 2>"}` |
|
||||
| `transactionAuthDetails` | **literal string `"null"`** (not a JSON null, not an empty string — the captured request sends the four-character string `null`) |
|
||||
|
||||
### Response — happy path
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"success": true,
|
||||
"message": "Payment Completed Successfully",
|
||||
"response": [
|
||||
{
|
||||
"responseObject": {
|
||||
"isCompleted": true,
|
||||
"balanceInquiryDTO": {
|
||||
"currencyCode": "MVR",
|
||||
"pocketAmount": 92.85,
|
||||
"pocketId": "<source pocket id>",
|
||||
"pocketBalanceMap": { "...": "..." }
|
||||
},
|
||||
"status": { "replyCode": 0.0 },
|
||||
"...": "..."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Session expiry
|
||||
|
||||
Same envelope as elsewhere — `attributeValue: "SESSION_EXPIRED"` with HTTP 200; the client throws `MfaisaSessionExpiredException`. See [03-history.md → Session expiry](03-history.md#session-expiry).
|
||||
|
||||
---
|
||||
|
||||
## Optional: `save/smart-pay-recipient` — bookkeeping
|
||||
|
||||
After a successful confirm the official Ooredoo app saves the merchant to a server-side "recent recipients" list:
|
||||
|
||||
```
|
||||
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/save/smart-pay-recipient
|
||||
```
|
||||
|
||||
This is **not** required for the payment itself — the transfer is final once `confirmNewBuy` succeeds. Thijooree skips it; the merchant is kept in the local picker `RecentsCache` under an `mfaisaqr:<qrCodeId>` synthetic accountNumber (mirroring the BML QR `bmlqr:` scheme).
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
> **← Back to** [Transfer Money](04-transfer.md) | [README](README.md)
|
||||
@@ -88,6 +88,7 @@ Client Server
|
||||
| 2 | [Login](02-login.md) | Subscriber lookup + mPIN login |
|
||||
| 3 | [Transaction History](03-history.md) | Paginated history per session |
|
||||
| 4 | [Transfer Money](04-transfer.md) | Three-step wallet-to-wallet send: recipient lookup → initiate (server SMSes OTP) → confirm |
|
||||
| 5 | [QR Merchant Payment](05-qr-pay.md) | Three-step "smart pay" scan-to-merchant: QR lookup → initiate → confirm. **No OTP** (`2FARequired=NONE`) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -155,6 +155,15 @@ When the timeout expires a 10-second countdown warning dialog appears. If dismis
|
||||
|
||||
Each stored profile has a visibility flag. Hidden profiles are excluded from the accounts list and from all API refresh cycles until re-enabled in Settings → Logins.
|
||||
|
||||
### Login Order
|
||||
|
||||
The user sets the order of their logins in [Settings → Logins](14-settings.md#login-order). It applies everywhere accounts are listed:
|
||||
|
||||
- `HomeViewModel.accounts` and `HomeViewModel.mibCards` sort themselves by `CredentialStore.loginRank()` on every `setValue` / `postValue`, so every observer (accounts list, transfer dropdown, contact picker "My accounts", PayMV QR, BML QR pay, transfer history, BML loans in Financing) gets the same order with no per-screen sorting. The sort is stable, so accounts from the same login keep the order the bank returned them in.
|
||||
- Combined MIB + BML card lists (dashboard stack, [Cards](22-cards.md)) are sorted by `CardItem.loginTag`.
|
||||
- [Financing](13-financing.md) places the MIB and BML sections by whichever bank's first login ranks higher.
|
||||
- The [OTP Screen](10-otp-screen.md) sorts its entries by login.
|
||||
|
||||
---
|
||||
|
||||
## MIB Session KeepAlive
|
||||
|
||||
@@ -13,10 +13,13 @@ Hosts one card per enrolled bank authenticator. Banks with no stored TOTP seed a
|
||||
## TOTP Display
|
||||
|
||||
Each card shows:
|
||||
- Bank logo and name
|
||||
- The current 6-digit TOTP code (large text)
|
||||
- A circular countdown ring showing time remaining in the current 30-second window
|
||||
- The code refreshes automatically when the window expires — no user interaction needed
|
||||
- Bank logo, bank name and account holder name
|
||||
- A circular countdown ring with the seconds left in the current 30-second window; the ring and code turn red in the last 5 seconds
|
||||
- The current 6-digit TOTP code (large text) with a copy button
|
||||
- The next window's code (smaller, below a divider) with its own copy button — handy when the current code is about to expire
|
||||
- The codes refresh automatically when the window expires — no user interaction needed
|
||||
|
||||
Tapping anywhere on the card also copies the current code. If no logins have a seed, an empty-state message is shown instead.
|
||||
|
||||
### Algorithm
|
||||
|
||||
@@ -30,14 +33,14 @@ Standard RFC 6238 TOTP:
|
||||
|
||||
## Supported Banks
|
||||
|
||||
One card is rendered for every MIB and every BML login that has a stored OTP seed (`OtpFragment.kt:93-98`). Seeds are per-`loginId` in `CredentialStore`.
|
||||
One card is rendered for every MIB and every BML login that has a stored OTP seed (`OtpFragment.kt`), sorted by the user's [login order](00-app-overview.md#login-order). Seeds are per-`loginId` in `CredentialStore`.
|
||||
|
||||
| Bank | Seed source | Card label |
|
||||
| Bank | Seed source | Card title / subtitle |
|
||||
|---|---|---|
|
||||
| MIB | `loadMibCredentials(loginId).otpSeed` (entered at login) | `"MIB · {fullName}"` |
|
||||
| BML | `loadBmlCredentials(loginId).otpSeed` (entered at login) | `"BML · {fullName}"` |
|
||||
| MIB | `loadMibCredentials(loginId).otpSeed` (entered at login) | `MIB` / `{fullName}` |
|
||||
| BML | `loadBmlCredentials(loginId).otpSeed` (entered at login) | `BML` / `{fullName}` |
|
||||
|
||||
If no full name has been cached the label falls back to plain `"MIB"` / `"BML"` and a background `MibProfileClient.fetchPersonalProfile()` / `BmlAccountClient.fetchUserInfo()` call refreshes it.
|
||||
If no full name has been cached the subtitle falls back to `"Authenticator"` and a background `MibProfileClient.fetchPersonalProfile()` / `BmlAccountClient.fetchUserInfo()` call refreshes it.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,37 +2,102 @@
|
||||
|
||||
Generates a receive-payment PayMV / Favara QR code. **Generation only** — the send/scan side of PayMV lives in `TransferFragment` via `newInstanceWithAutoScan()` and the [QR scanner](25-qr-scanner.md).
|
||||
|
||||
> **Fahipay QRs are a work in progress.** Thijooree's Fahipay card matches Fahipay's design, but the Fahipay app currently rejects the QRs it generates as **"Invalid QR"**. BML QRs scan fine. See [PayMV QR Format → Fahipay](18-paymv-qr-format.md#fahipay-work-in-progress).
|
||||
|
||||
---
|
||||
|
||||
## Fragment — `PayMvQrFragment`
|
||||
|
||||
A single screen (no tabs). Re-renders the QR live as the user edits the form.
|
||||
A single screen (no tabs). Re-renders the QR live (300 ms debounce) as the user edits the form. It can also be opened for a saved contact with `PayMvQrFragment.forContact(accountNumber, name, bank)`. The QR then pays into the contact's account, and the account picker and phone toggle are hidden.
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Source / behaviour |
|
||||
|---|---|
|
||||
| Source account dropdown | `viewModel.accounts`, filtered to non-card MVR accounts (MIB and BML USD currently excluded — both flagged as TODO in source). Defaults to `CredentialStore.getDefaultAccountNumber()` when set |
|
||||
| Amount (`etAmount`) | Optional. Blank → open-amount QR |
|
||||
| Reference (`etReference`) | Free-text purpose; defaults to `paymvqr_reference_default` if blank — written to tag 62→08 |
|
||||
| Include phone (`switchIncludePhone`) | When on, writes the saved BML / Fahipay mobile to sub-tag 26→05 (auto-prefixed `+960` if 7-digit local) |
|
||||
| Source account dropdown | `viewModel.accounts`, filtered to non-card MVR accounts (MIB, M-Faisa and BML USD currently excluded — flagged as TODO in source). Defaults to `CredentialStore.getDefaultAccountNumber()` when set |
|
||||
| Amount (`etAmount`) | Optional. Blank / zero / unparseable → open-amount QR. Commas are stripped |
|
||||
| Reference (`etReference`) | Free-text purpose, written to tag 62→08. Blank → tag omitted (BML), or `PAYMENT` (Fahipay) |
|
||||
| Include phone (`switchIncludePhone`) | When on, writes the saved BML / Fahipay mobile to sub-tag 26→05, normalised to `+960XXXXXXX` |
|
||||
|
||||
### Generation
|
||||
### What goes on the card
|
||||
|
||||
`buildQrPayload()` assembles a decimal TLV payload per the [PayMV QR Format](18-paymv-qr-format.md):
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Name | BML / MIB: `accountBriefName`. Fahipay: the holder's full name (`profileName`, falling back to the saved Fahipay profile's `fullName`) — **not** the generic "Fahipay Wallet" brief name. Contacts: the contact's name. Always uppercased |
|
||||
| QR | The payload below, white modules on the card blue, error correction M, no quiet zone |
|
||||
| Vertical text | The QR's reference (tag 62→05) — see below |
|
||||
| Amount | **Not printed** on the card (neither bank does); it only appears inside the QR, and in BML's vertical text |
|
||||
|
||||
1. Tag 26 container: GUI (`mv.favara.mpqr`), acquirer BIC, account number, optional mobile, `IPAY`
|
||||
2. Acquirer BIC is derived from the source account's bank: `MALBMVMV` (BML) / `MADVMVMV` (MIB) / `FAHIMVMV` (Fahipay)
|
||||
3. Tag 62 container: random 9-char reference + the purpose text
|
||||
4. Tag 80 container: GUI + ISO timestamp
|
||||
5. Appends `"6304"` and computes CRC-16/CCITT-FALSE over the full string
|
||||
### Vertical text (reference)
|
||||
|
||||
The rendered card image (bank-styled background plus QR) is shown in-place.
|
||||
Both banks print a short code vertically beside the QR, reading bottom-to-top. It is the same string as the payload's reference, **tag 62→05**, so Thijooree calculates the reference first and uses it for both.
|
||||
|
||||
| Bank | Reference / vertical text | Example |
|
||||
|---|---|---|
|
||||
| BML | Account number converted to **base-32** (digits `0-9A-V`, uppercase), followed by the **amount exactly as typed** (commas removed, no forced decimals), capped at 25 chars | `7730000188362` → `70V3UKKUA`; with amount `100` → `70V3UKKUA100` |
|
||||
| Fahipay | `P` + 9 random uppercase alphanumeric chars. **No amount** | `P2KVTPYL4E` |
|
||||
| Other (MIB contacts) | 9 random uppercase alphanumeric chars | `WHQS0SX5O` |
|
||||
|
||||
BML's base-32 is `AccountNumbertoBase32` from the BML app: `BigInt(account)`, repeatedly `% 32` into the alphabet `0123456789ABCDEFGHIJKLMNOPQRSTUV`. If the account number isn't numeric, Thijooree falls back to a random 9-char reference.
|
||||
|
||||
---
|
||||
|
||||
## Card Rendering
|
||||
|
||||
Two renderers, chosen by the target's bank. Both return a `Bitmap` shown in `ivQrCard` (`fitCenter`) and used for Share / Save.
|
||||
|
||||
### BML (and MIB) — `renderQrCard()`
|
||||
|
||||
A 1:1 copy of BML app v2.1.47's `ReceiveCard` React Native component (decompiled from the Hermes bundle). All values are BML's StyleSheet values in dp, laid out for a 560 dp reference screen width (`SCREEN_WIDTH_DP`) and drawn at 2 px/dp (`PX_PER_DP`), so the card comes out about 1024 px wide.
|
||||
|
||||
| Element | Spec |
|
||||
|---|---|
|
||||
| Colour | `mmaBlue` `#0E5CA4` everywhere |
|
||||
| Card | Width `sw − 48`. Blue background, radius 20. The white top section is inset 2 dp (top corners 18), which shows as a thin blue border |
|
||||
| Header row | 32 dp from top, 40 dp side margins. Left: `bml_logo_paymv` ("BANK OF MALDIVES" wordmark, from BML's assets) contained in `0.38·sw × 0.38·sw·0.1117`. Right: "PayMV QR", Sofia Pro Bold, `#0E5CA4`, sized to BML's `0.2·sw × 0.2·sw·0.1733` image box, shifted down 1 dp. MIB uses `mib_faisanet_logo` in the same box |
|
||||
| QR panel | 24 dp below the header, 40 dp side margins, radius 16, 24 dp bottom margin |
|
||||
| Name | Roboto (system default) regular, 14 sp, white; 8 + 12 dp above, 16 dp below |
|
||||
| QR | `0.5·sw` square; 37 dp padding below |
|
||||
| Vertical text | Roboto 10 sp, **black at 80 % opacity**, rotated −90°. Centred `railW/1.37 − railW/2` right of the QR's right edge and `(qr + railW/1.5)/2` down from the QR top, where `railW = sw/1.85`. Ellipsized to `railW` |
|
||||
| Footer | "MALDIVES NATIONAL QR", Sofia Pro Bold (`res/font/sofia_pro_bold.ttf`), `0.046·sw`, letter spacing 1.2 dp, 12 dp vertical padding |
|
||||
|
||||
### Fahipay — `renderFahipayQrCard()`
|
||||
|
||||
Fahipay's app doesn't draw its card; it shows an image generated by Fahipay's server (`api/app/qr/`). Thijooree copies that image's layout, measured in pixels on its **1240 × 1322** canvas. Text is sized so capital letters match the measured cap heights.
|
||||
|
||||
| Element | Spec |
|
||||
|---|---|
|
||||
| Colour | `#005DA3` |
|
||||
| Card | Blue, radius 50. White area inset 6 px (top corners 44) down to y 1174. The blue below it is the footer |
|
||||
| Header row | Square app icon `fahipay_logo` at (94, 94)–(163, 163), then the "FahiPay" wordmark `fahipay_logo_long` at x 178, y 104, 48 px tall — **both, side by side** |
|
||||
| "PayMV QR" | Sofia Pro Bold, blue, right-aligned at x 1147, cap height 30 (cap top y 101) |
|
||||
| QR panel | (166, 218)–(1074, 1126), radius 55 |
|
||||
| Name | Roboto regular, white, centred at x 619, cap height 30 (cap top y 314). Shrinks to fit the panel minus 80 px |
|
||||
| QR | 562 px at (338, 417) |
|
||||
| Vertical text | Montserrat Regular (`res/font/montserrat_regular.ttf`), **blue**, cap height 27, rotated −90°. It sits in the **white margin right of the panel**: text starts at y 1087, baseline at x 1171 |
|
||||
| Footer | "MALDIVES NATIONAL QR", Sofia Pro Bold, white, cap height 55.5 (cap top y 1220), BML's letter spacing (1.2/25.76 em) |
|
||||
|
||||
Fonts follow the BML card (Sofia Pro Bold, Roboto), except the vertical text, which keeps Fahipay's Montserrat.
|
||||
|
||||
---
|
||||
|
||||
## Generation
|
||||
|
||||
`buildQrPayload()` assembles a decimal TLV payload per the [PayMV QR Format](18-paymv-qr-format.md#generating-a-receive-payment-qr):
|
||||
|
||||
1. Tag 01: `11` (static). Fahipay QRs with an amount use `12` (dynamic)
|
||||
2. Tag 26: GUI (`mv.favara.mpqr`), acquirer BIC ×2 — `MALBMVMV` (BML) / `MADVMVMV` (MIB) / `FAHIMVMV` (Fahipay), account number, optional mobile, `IPAY`
|
||||
3. Tag 54: amount as `%.2f`. If there's no amount: omitted (BML), `***` (Fahipay)
|
||||
4. Tag 59: name, uppercased, max 25 chars
|
||||
5. Tag 60: Fahipay only — `LD` + 4 random digits
|
||||
6. Tag 62: the reference (above) + purpose
|
||||
7. Tag 80: GUI + timestamp `yyyy-MM-dd'T'HH:mm:ss.00000`
|
||||
8. Appends `"6304"` and computes CRC-16/CCITT-FALSE over the full string
|
||||
|
||||
### Actions
|
||||
|
||||
- **Share** (`btnShare`) — exports the rendered card via `FileProvider` + `ACTION_SEND`
|
||||
- **Save** (`btnSave`, `PayMvQrFragment.kt:78`) — writes the PNG to `MediaStore.Images` / `Pictures/`
|
||||
- **Share** (`btnShare`) — writes `<name>_paymv_qr.png` to the cache and shares it via `FileProvider` + `ACTION_SEND`
|
||||
- **Save** (`btnSave`) — writes `<name>_PayMV_QR.png` to `MediaStore.Images` / `Pictures/`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
> **This flow no longer lives in a dedicated fragment.** `BmlQrPayFragment.kt` still exists as a source file but is unreachable — no callers, no nav graph entry, no intent action routes here. The actual BML gateway QR flow runs inside `TransferFragment` via `TransferFragment.newInstanceFromBmlQr(qrUrl, fromAccountNumber)`. See [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md).
|
||||
|
||||
The on-the-wire payment protocol is unchanged — see [BML QR Payment API](../bmlapi/13-qr-payment.md) for the 3-step TOTP flow (`approve` → `channel: token` → `otp`).
|
||||
The on-the-wire payment protocol is unchanged — see [BML QR Payment API](../bmlapi/13-qr-payment.md) for the 3-step TOTP flow (`approve` → `channel: token` → `otp`), and [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md#bml-qr-merchant-payment-flow) for the three QR sub-modes the live path handles.
|
||||
|
||||
Two differences remain in the stale fragment, should it ever be revived: it calls `lookupPayRequest()` with the scanned URL only (no `PaymvQrParser.bmlPayRequestKey()`, so POS QRs would not resolve), and it swallows every lookup error into the hardcoded `bml_qr_lookup_failed` toast instead of surfacing BML's own message.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ Aggregates financing products across banks — MIB promotional deals and BML loa
|
||||
|
||||
Observes `HomeViewModel.financing` (MIB deals) and `HomeViewModel.bmlLoanDetails`.
|
||||
|
||||
MIB deals carry no login tag, so the two sections are ordered as blocks: MIB first if the first MIB login ranks above the first BML login in the user's [login order](00-app-overview.md#login-order), otherwise BML first. BML loans within their section follow `HomeViewModel.accounts`, which is already in login order.
|
||||
|
||||
---
|
||||
|
||||
## MIB Deals Section
|
||||
|
||||
@@ -33,6 +33,20 @@ Each profile entry shows:
|
||||
- Profile image (circular avatar)
|
||||
- Visibility toggle switch
|
||||
|
||||
### Login Order
|
||||
|
||||
All logins are shown as one list, across banks. When there is more than one login, each row gets a three-line handle (`ic_reorder_lines`) on the right; press and drag it to move the login up or down (`SettingsLoginsFragment.attachDragHandle()`). Tapping the rest of the row still opens the login details.
|
||||
|
||||
On drop the order is saved with `CredentialStore.setLoginOrder()`, which:
|
||||
- writes the combined order to the `login_order` pref as a JSON array of login tags (`mib_<id>`, `bml_<id>`, `fahipay_<id>`, `mfaisa_<id>` — the same format as `BankAccount.loginTag`)
|
||||
- rewrites each bank's own list (`mib_login_ids`, `bml_login_ids`, …) in the same relative order, so code that walks one bank's logins stays consistent
|
||||
|
||||
It then calls `HomeViewModel.resortByLoginOrder()` so already-open screens re-sort immediately.
|
||||
|
||||
`CredentialStore.getLoginOrder()` returns the effective order: saved tags that still exist, followed by any logins not yet ordered (e.g. newly added) in the default MIB → BML → Fahipay → M-Faisa order. `loginRank()` turns that into a sort key; unknown tags sort last.
|
||||
|
||||
See [Login Order](00-app-overview.md#login-order) for where the order is applied.
|
||||
|
||||
### Visibility Toggle
|
||||
|
||||
Toggling a profile off hides it from the accounts list and excludes it from API refresh cycles. The session is kept alive — the profile can be re-enabled without re-logging in.
|
||||
|
||||
@@ -28,12 +28,13 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
|
||||
| `00` | Format indicator | Always `"01"` |
|
||||
| `01` | Point-of-initiation method | `"11"` = static QR, `"12"` = dynamic QR |
|
||||
| `26` | Merchant account information | Container — see sub-tags below |
|
||||
| `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs only |
|
||||
| `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs and in BML POS QRs |
|
||||
| `52` | Merchant category code | `"0000"` (generic) |
|
||||
| `53` | Transaction currency | `"462"` = MVR (ISO 4217 numeric) |
|
||||
| `54` | Transaction amount | Decimal string (e.g. `"1.50"`); absent for open-amount QRs |
|
||||
| `54` | Transaction amount | Decimal string (e.g. `"1.50"`). Open-amount QRs: absent (Thijooree BML) or `"***"` (BML's and Fahipay's own QRs) |
|
||||
| `58` | Country code | `"MV"` |
|
||||
| `59` | Merchant / recipient name | Max 25 characters |
|
||||
| `59` | Merchant / recipient name | Max 25 characters, uppercase in every real QR seen |
|
||||
| `60` | Merchant city / store code | `LD` + 4 digits (e.g. `LD0442`, `LD0745`). Seen in BML POS QRs and in BML's and Fahipay's own receive QRs; meaning of the digits unknown |
|
||||
| `62` | Additional data field | Container — see sub-tags below |
|
||||
| `63` | CRC | `6304` prefix + 4-char hex checksum — always last |
|
||||
| `80` | Supplementary data | Container — timestamp and domain |
|
||||
@@ -65,8 +66,8 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
|
||||
|
||||
| Sub-Tag | Field | Notes |
|
||||
|---|---|---|
|
||||
| `05` | Reference / bill number | 9 random uppercase alphanumeric characters |
|
||||
| `08` | Payment purpose | Free-form text entered by the payee |
|
||||
| `05` | Reference / bill number | Bank-specific — see [Reference (tag 62→05)](#reference-tag-6205). Also printed vertically on the QR card |
|
||||
| `08` | Payment purpose | Free-form text entered by the payee. BML's app defaults it to `Quickpay Transfer`, Fahipay to `PAYMENT` |
|
||||
|
||||
---
|
||||
|
||||
@@ -76,6 +77,7 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
|
||||
|---|---|---|
|
||||
| `00` | Domain | `"mv.favara.mpqr"` |
|
||||
| `01` | Timestamp | ISO 8601 format: `"yyyy-MM-dd'T'HH:mm:ss.00000"` |
|
||||
| `02` | Unknown | `"0005"` — only seen in Fahipay's own QR **with an amount**; not generated by Thijooree |
|
||||
|
||||
---
|
||||
|
||||
@@ -114,18 +116,98 @@ To create a QR that others can scan to pay you:
|
||||
10 04 IPAY
|
||||
52 04 0000 ← MCC
|
||||
53 03 462 ← MVR
|
||||
54 <len> <amount> ← Omit tag entirely if open-amount
|
||||
54 <len> <amount> ← "%.2f". Open amount: omit (BML) / "***" (Fahipay)
|
||||
58 02 MV
|
||||
59 <len> <name up to 25 chars>
|
||||
59 <len> <NAME UP TO 25 CHARS> ← Uppercased
|
||||
60 06 LD<4 random digits> ← Fahipay only
|
||||
62 <len>
|
||||
05 09 <9 random alphanum chars> ← Reference
|
||||
08 <len> <purpose text>
|
||||
05 <len> <reference> ← Bank-specific, see below
|
||||
08 <len> <purpose text> ← Omit if blank (BML) / "PAYMENT" (Fahipay)
|
||||
80 <len>
|
||||
00 15 mv.favara.mpqr
|
||||
01 <len> <yyyy-MM-dd'T'HH:mm:ss.00000> ← Timestamp
|
||||
6304<CRC>
|
||||
```
|
||||
|
||||
For Fahipay, tag `01` is `12` (dynamic) when an amount is set.
|
||||
|
||||
---
|
||||
|
||||
## Reference (tag 62→05)
|
||||
|
||||
The reference is also the **vertical text** printed beside the QR on both banks' cards, so it is calculated once and used for both (`PayMvQrFragment.generateQr()`).
|
||||
|
||||
### BML — base-32 account number + amount
|
||||
|
||||
```
|
||||
reference = base32(accountNumber) + amountAsTyped
|
||||
```
|
||||
|
||||
- `base32` is BML's `AccountNumbertoBase32`: treat the account number as an integer and convert it to base 32 with the alphabet `0123456789ABCDEFGHIJKLMNOPQRSTUV`, most significant digit first
|
||||
- `amountAsTyped` is the amount field with commas removed and **no forced decimals** (`100` stays `100`, `100.5` stays `100.5`). Empty for open-amount QRs
|
||||
- Capped at 25 characters. A non-numeric account number falls back to 9 random characters
|
||||
|
||||
| Account | Amount | Reference / vertical text |
|
||||
|---|---|---|
|
||||
| `7730000188362` | — | `70V3UKKUA` |
|
||||
| `7730000188362` | `100` | `70V3UKKUA100` |
|
||||
|
||||
Confirmed by decoding a QR from BML's app: `62→05` = `70V3UKKUA`, the same as the vertical text on its card.
|
||||
|
||||
### Fahipay — `P` + 9 random characters
|
||||
|
||||
Fahipay's server issues references like `P135KOKXJY` and `P2KVTPYL4E`: `P` followed by 9 uppercase alphanumerics. The vertical text shows the reference only — **the amount is not appended** (confirmed on a QR carrying amount `55`). Thijooree generates `"P" + 9 random chars`.
|
||||
|
||||
### Others
|
||||
|
||||
9 random uppercase alphanumeric characters.
|
||||
|
||||
---
|
||||
|
||||
## Real Receive QRs (Reference Samples)
|
||||
|
||||
Decoded from QR images generated by the official apps (CRC verified).
|
||||
|
||||
**BML app** (open amount):
|
||||
|
||||
```
|
||||
00020101021126920014mv.favara.mpqr0108MALBMVMV0208MALBMVMV031377300001883620511+96091980261004IPAY6006LD04425204000053034625403***5802MV5915SHIHAM A.RAHMAN6234050970V3UKKUA0817Quickpay Transfer80470014mv.favara.mpqr01252026-09-26T02:29:53.000006304F8E6
|
||||
```
|
||||
|
||||
Note that BML's app places tag `60` *inside* tag `26` here (after `10 IPAY`, as `6006LD0442`).
|
||||
|
||||
**Fahipay app**, open amount:
|
||||
|
||||
```
|
||||
00020101021126810014mv.favara.mpqr0108FAHIMVMV0208FAHIMVMV03125008500611080511+96098074051004IPAY5204000053034625403***5802MV5912MOHAMED RAIF6006LD074562250510P135KOKXJY0807PAYMENT80470014mv.favara.mpqr01252026-09-26T03:44:36.0000063042707
|
||||
```
|
||||
|
||||
**Fahipay app**, amount `55`:
|
||||
|
||||
```
|
||||
00020101021226810014mv.favara.mpqr0108FAHIMVMV0208FAHIMVMV03125003600510030511+96091980261004IPAY5204000053034625402555802MV5919SHIHAM ABDUL RAHMAN6006LD097062250510P2KVTPYL4E0807PAYMENT80550014mv.favara.mpqr01252026-09-26T03:22:08.000000204000563045304
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fahipay (Work in Progress)
|
||||
|
||||
> ⚠️ **Fahipay QRs generated by Thijooree do not work yet.** The Fahipay app rejects them as **"Invalid QR"**. BML QRs from Thijooree scan fine in the BML app.
|
||||
|
||||
Fahipay's app doesn't build its QR locally: it fetches it from `GET api/app/qr/?lang=…&type=p2p&amount=…`, and the server returns the finished card image and payload. Things tried so far, with the Fahipay app still reporting invalid:
|
||||
|
||||
| Change | Status |
|
||||
|---|---|
|
||||
| Mobile `26→05` normalised from Fahipay's stored `960XXXXXXX` to `+960XXXXXXX` | Done (was a real bug) |
|
||||
| Name `59` uppercased | Done |
|
||||
| `54` = `***` for open amount, `01` = `12` with an amount | Done |
|
||||
| `60` = `LD` + 4 random digits | Done |
|
||||
| `62→08` defaults to `PAYMENT` | Done |
|
||||
| `62→05` shaped `P` + 9 chars | Done |
|
||||
| `80→02` = `0005` (amount QRs only) | Not done |
|
||||
|
||||
The CRC is correct (verified against all samples). With no amount, Thijooree's payload now has the same fields in the same order as Fahipay's own. The leading theory is that Fahipay's scanner looks up the `P…` reference on Fahipay's server, which issued it. If so, no locally generated QR can pass, and the fix would be to fetch the payload from `api/app/qr/` and render the card around it.
|
||||
|
||||
---
|
||||
|
||||
## Parsing a PayMV QR (Incoming Scan)
|
||||
@@ -153,15 +235,76 @@ The value at sub-sub-tag `01` is a full `https://pay.bml.com.mv/app/...` URL. Ex
|
||||
|
||||
Plain BML QR codes (not combined) start with `https://pay.bml.com.mv/app/` directly.
|
||||
|
||||
`PaymvQrParser.bmlQrPayTarget()` covers all of these: it returns the URL for plain URL QRs and for
|
||||
combined QRs, the whole EMV payload for POS QRs (below, recognised by the `mv.com.bml.qtr`
|
||||
supplementary domain), and null for everything else — PayMV QRs keep falling through to `parse()`.
|
||||
|
||||
---
|
||||
|
||||
## BML POS QR (`mv.com.bml.qtr`)
|
||||
|
||||
BML POS terminals emit a third shape — an EMVCo-style dynamic QR whose supplementary data domain
|
||||
(tag `80` → `00`) is `mv.com.bml.qtr` and which has **no tag `26`**, so there is no PayMV account
|
||||
number to transfer to. The same `35` → `20` container is used as in combined QRs, but sub-tag `01`
|
||||
holds a bare reference instead of a URL.
|
||||
|
||||
Example (CRC verified, same CRC-16/CCITT-FALSE as above):
|
||||
|
||||
```
|
||||
00020101021235752071000202013502:215c7b9f15ce4ed28e15697ea976db9902109809724081030874009538520400005303462540436005802MV5911BEST BANANA6006LD044262220510dtyams497d0804POPE80470014mv.com.bml.qtr01252026-09-21T13:33:22.00000630443FA
|
||||
```
|
||||
|
||||
| TLV path | Value | Notes |
|
||||
|---|---|---|
|
||||
| `00` | `01` | Format indicator |
|
||||
| `01` | `12` | Dynamic QR |
|
||||
| `35`→`20`→`00` | `02` | Version / format of the container (combined QRs use the URL form) |
|
||||
| `35`→`20`→`01` | `02:215c7b9f15ce4ed28e15697ea976db99` | Payment reference — `<type>:<32 hex>` |
|
||||
| `35`→`20`→`02` | `9809724081` | Merchant identifier (10 digits) |
|
||||
| `35`→`20`→`03` | `74009538` | Terminal identifier (8 digits) |
|
||||
| `52` | `0000` | MCC |
|
||||
| `53` | `462` | MVR |
|
||||
| `54` | `3600` | Amount — **no decimal point**, unlike PayMV's `"1.50"` |
|
||||
| `58` / `59` / `60` | `MV` / `BEST BANANA` / `LD0442` | Country, merchant name, merchant city/store code |
|
||||
| `62`→`05` | `dtyams497d` | Reference / bill number |
|
||||
| `62`→`08` | `POPE` | Purpose / terminal label |
|
||||
| `80`→`00` | `mv.com.bml.qtr` | Domain — identifies the POS format |
|
||||
| `80`→`01` | `2026-09-21T13:33:22.00000` | Timestamp |
|
||||
|
||||
### Pay-Request Lookup Key
|
||||
|
||||
The lookup key is the **bare reference**, Base64-encoded without padding:
|
||||
|
||||
```
|
||||
GET .../walletpayments/payrequest/MDI6MjE1YzdiOWYxNWNlNGVkMjhlMTU2OTdlYTk3NmRiOTk
|
||||
```
|
||||
|
||||
BML's own app omits the `=` padding, so `BmlQrPayClient.lookupPayRequest()` encodes with
|
||||
`NO_WRAP or NO_PADDING` for every QR type; the padded form resolves too. What the request *must*
|
||||
carry is `accept: application/json` — see
|
||||
[QR Payment → Step 1 headers](../bmlapi/13-qr-payment.md#headers).
|
||||
|
||||
Confirmed against the live API (the example QR had already expired, so BML answered `103` rather
|
||||
than with merchant details — but `103` means the reference itself resolved):
|
||||
|
||||
| Key tried | Response |
|
||||
|---|---|
|
||||
| `02:215c7b9f15ce4ed28e15697ea976db99` | `103` — "The payment request has expired" ✅ recognised |
|
||||
| the whole EMV payload | `112` — "Unsupported payment link" ❌ |
|
||||
| `https://pay.bml.com.mv/app/02:215c…` | `103` — recognised too; the host itself 400s on that path, so the backend must strip the prefix |
|
||||
|
||||
`PaymvQrParser.bmlPayRequestKey()` returns the URL for URL QRs and the reference for POS QRs, so
|
||||
exactly one request is made either way.
|
||||
|
||||
---
|
||||
|
||||
## Example Payload
|
||||
|
||||
Static QR for account `7700000000123`, holder `"AHMED ALI"`, open amount, purpose `"Rent"`:
|
||||
Static BML QR for account `7730000188362`, holder `"AHMED ALI"`, open amount, purpose `"Rent"`:
|
||||
|
||||
```
|
||||
000201010211268...520400005303462
|
||||
5802MV5909AHMED ALI6225050912345ABCDEF0804Rent
|
||||
5802MV5909AHMED ALI6221050970V3UKKUA0804Rent
|
||||
80...63044A2B
|
||||
```
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ The transfer screen (`TransferFragment`) handles all outgoing payments across MI
|
||||
| `newInstance(accountNumber, displayName, subtitle, colorHex, imageHash)` | Pre-fills the "To" card from a contact, recents pick, or About → Donate |
|
||||
| `newInstanceFrom(account: BankAccount)` | Pre-selects the given account in the "From" dropdown |
|
||||
| `newInstanceFromQr(accountNumber, displayName, amount, remarks, fromAccountNumber?)` | Pre-fills recipient + optional amount/remarks from a PayMV QR scan |
|
||||
| `newInstanceFromBmlQr(qrUrl, fromAccountNumber?)` | BML card/gateway QR merchant payment mode — locks recipient, may pre-fill amount |
|
||||
| `newInstanceFromBmlQr(qrUrl, fromAccountNumber?)` | BML card/gateway/POS QR merchant payment mode — locks recipient, may pre-fill amount |
|
||||
| `newInstanceWithAutoScan()` | Opens the [QR scanner](25-qr-scanner.md) immediately on load |
|
||||
|
||||
---
|
||||
@@ -215,22 +215,29 @@ If channel fetch fails or returns empty, the flow is aborted and the form is re-
|
||||
|
||||
## BML QR Merchant Payment Flow
|
||||
|
||||
Triggered when the transfer screen is opened via `newInstanceFromBmlQr()` or when a BML ebanking/pay.bml URL is scanned from the QR scanner.
|
||||
Triggered when the transfer screen is opened via `newInstanceFromBmlQr()`, which every scanner caller reaches through `PaymvQrParser.bmlQrPayTarget(raw)` — it returns the value to pay with, or null for a QR that is not BML's.
|
||||
|
||||
Two sub-modes:
|
||||
Three sub-modes:
|
||||
|
||||
| Mode | Trigger | Extra step |
|
||||
| Mode | `bmlQrPayTarget()` returns | Extra step |
|
||||
|---|---|---|
|
||||
| Static card QR | URL starts with `https://ebanking.bankofmaldives.com.mv/qrpay/` | None |
|
||||
| Gateway QR | URL starts with `https://pay.bml.com.mv/app/` | `BmlQrPayClient.preInitiatePayment()` required before initiate |
|
||||
| Static card QR | the QR text, when it starts with `https://ebanking.bankofmaldives.com.mv/qrpay/` | None |
|
||||
| Gateway QR | the QR text, or the URL at TLV `35`→`20`→`01` in a combined EMV QR | `BmlQrPayClient.preInitiatePayment()` required before initiate |
|
||||
| POS QR | the whole EMV payload, for QRs whose tag `80`→`00` domain is `mv.com.bml.qtr` | Treated as a gateway QR — see the note below |
|
||||
|
||||
`BmlTransferHandler.lookupQrMerchant()` passes that value through `PaymvQrParser.bmlPayRequestKey()`, which hands the URL to the lookup for URL QRs and the bare `35`→`20`→`01` reference for POS QRs. See [PayMV QR Format — BML POS QR](18-paymv-qr-format.md#bml-pos-qr-mvcombmlqtr).
|
||||
|
||||
Flow:
|
||||
1. `lookupBmlQrMerchant()` — fetches merchant info via `BmlQrPayClient.lookupPayRequest()`. Locks the "To" row.
|
||||
1. `lookupQrMerchant()` — fetches merchant info via `BmlQrPayClient.lookupPayRequest()`. Locks the "To" row.
|
||||
2. For dynamic QRs (`info.amount > 0`), pre-fills the amount and locks the amount field.
|
||||
3. Remarks field is locked (not applicable for merchant payments).
|
||||
4. On confirm: TOTP is generated, then `initiatePayment()` → (for gateway QR: `preInitiatePayment()` first) → `confirmPayment()` with a fresh TOTP.
|
||||
5. On success: a success dialog is shown (no receipt saved). Back-press returns to previous screen.
|
||||
|
||||
**Lookup failure:** the user stays on the Transfer screen with the "To" row restored via `resetToFieldVisibility()` — the screen is no longer popped. When BML answered with `success: false`, its own wording is toasted (`BmlQrPayLookupException.message`, e.g. "The payment request has expired"); network, empty and non-JSON responses fall back to the `bml_qr_lookup_failed` string.
|
||||
|
||||
> **Unverified:** POS QRs are treated as gateway QRs (pre-initiate before initiate) because they carry a preset amount. No POS payment has been completed end-to-end yet — the reference captured for testing had already expired.
|
||||
|
||||
---
|
||||
|
||||
## Transfer Button Enable Conditions
|
||||
@@ -238,7 +245,7 @@ Flow:
|
||||
The transfer button is only enabled when all of the following are true:
|
||||
|
||||
- A source account is selected
|
||||
- A recipient is resolved (`resolvedAccountNumber` not blank, or `bmlQrInfo` is set)
|
||||
- A recipient is resolved (`resolvedAccountNumber` not blank, or the BML handler's `qrInfo` is set)
|
||||
- Amount is greater than `0`
|
||||
- No connectivity error for `NO_INTERNET` or for the source bank
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ A horizontal `RecyclerView` with `LinearSnapHelper` that paginates BML cards + M
|
||||
|
||||
- BML cards: visible accounts with `profileType` in (`BML_PREPAID`, `BML_CREDIT`, `BML_DEBIT`) AND `statusDesc == "Active"`
|
||||
- MIB cards: filtered by `CardsFragment.isMibCardActive(cardStatus)` (i.e. `CHST0`)
|
||||
- Cards are sorted by the user's [login order](00-app-overview.md#login-order)
|
||||
- The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to the front
|
||||
- Cards hidden via `getHiddenDashboardCardNumbers()` are skipped
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ A horizontal `RecyclerView` driven by `CardStackAdapter` with a `PagerSnapHelper
|
||||
- BML: `viewModel.accounts` filtered to `profileType in (BML_PREPAID, BML_CREDIT, BML_DEBIT)`
|
||||
- MIB: `viewModel.mibCards`
|
||||
|
||||
The combined order is `bmlActive + mibActive + bmlInactive + mibInactive`. The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to position 0. Initial data is seeded from `CardsCache` so the stack appears immediately; a background `HomeActivity.triggerRefreshCards()` refreshes it.
|
||||
Active cards come first, then inactive ones; within each group cards are sorted by the user's [login order](00-app-overview.md#login-order) via `CardItem.loginTag`. The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to position 0. Initial data is seeded from `CardsCache` so the stack appears immediately; a background `HomeActivity.triggerRefreshCards()` refreshes it.
|
||||
|
||||
### Card Art
|
||||
|
||||
|
||||
@@ -57,10 +57,10 @@ Each caller registers an `ActivityResultContracts.StartActivityForResult` launch
|
||||
|
||||
| Caller | Result handling |
|
||||
|---|---|
|
||||
| [TransferFragment](07-transfer.md) | Routes PayMV / BML URL via `PaymvQrParser` + `extractBmlGatewayUrl` |
|
||||
| [TransferFragment](07-transfer.md) | `PaymvQrParser.bmlQrPayTarget()` first (URL, combined and POS QRs → BML QR pay), then M-Faisa numeric ids, then `PaymvQrParser.parse()` |
|
||||
| [PayMvQrFragment](11-paymv-qr-screen.md) | Generation only — does not call the scanner directly |
|
||||
| `CredentialsFragment` (login) | `OtpauthParser.parse(raw)` → fills `etOtpSeed` or shows a chooser for multi-entry QRs |
|
||||
| [DashboardFragment](21-dashboard.md) | BML URL → BML QR pay; PayMV → pre-fill Transfer; otherwise toast |
|
||||
| [DashboardFragment](21-dashboard.md) | BML QR (URL or POS) → BML QR pay; PayMV → pre-fill Transfer; otherwise toast |
|
||||
| [CardsFragment](22-cards.md) | Same routing as Dashboard, scoped to the active BML card |
|
||||
|
||||
### Share-to-Scan Fast Path
|
||||
|
||||
@@ -19,10 +19,10 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
|
||||
| [08 — Contacts](08-contacts.md) | Contact list, add/edit/delete, categories, contact picker sheet |
|
||||
| [09 — Activities](09-activities.md) | Local transfer log, TransferReceiptFragment, share/save receipt |
|
||||
| [10 — OTP Screen](10-otp-screen.md) | TOTP display, real-time countdown, enrolled bank authenticators |
|
||||
| [11 — PayMV QR Screen](11-paymv-qr-screen.md) | Generate receive-payment QR (send/scan lives in Transfer) |
|
||||
| [11 — PayMV QR Screen](11-paymv-qr-screen.md) | Generate receive-payment QR, BML/Fahipay card rendering, vertical reference text (Fahipay QRs WIP) |
|
||||
| [12 — BML QR Pay](12-bml-qr-pay.md) | (Stub — see Transfer Flows for the live BML QR merchant flow) |
|
||||
| [13 — Financing](13-financing.md) | MIB promotional deals, BML loans, BML foreign spend limits |
|
||||
| [14 — Settings](14-settings.md) | Settings hub: Logins, Appearance, Privacy & Security, Notifications, Storage, About |
|
||||
| [14 — Settings](14-settings.md) | Settings hub: Logins (drag to reorder), Appearance, Privacy & Security, Notifications, Storage, About |
|
||||
| [15 — Settings: Security](15-settings-security.md) | Change lock method, biometrics, auto-lock timeout, screenshots |
|
||||
| [16 — Settings: Appearance](16-settings-appearance.md) | Navigation mode, slot drag-reorder, theme, accent colour, language |
|
||||
| [17 — Settings: Storage](17-settings-storage.md) | Single "Clear All Caches" button |
|
||||
@@ -39,7 +39,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
|
||||
|
||||
| Document | Description |
|
||||
|---|---|
|
||||
| [18 — PayMV QR Format](18-paymv-qr-format.md) | Decimal TLV encoding, all tags, CRC-16, QR generation recipe, parsing reference |
|
||||
| [18 — PayMV QR Format](18-paymv-qr-format.md) | Decimal TLV encoding, all tags, CRC-16, per-bank references, real samples, Fahipay WIP, parsing reference |
|
||||
| [19 — Parsers](19-parsers.md) | Account display parser architecture — how raw bank API data is normalised into a unified `AccountListDisplay` model |
|
||||
| [20 — Transfer Flows](20-transfer-flows.md) | TransferFragment entry points, recipient lookup, transfer type routing, rejected combinations, BML business OTP flow, BML QR merchant payments |
|
||||
| [AI Security Audit](AI_SECURITY_CHECK.md) | Full source security audit — credential storage, network layer, manifest, data privacy |
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- Support for BML payment gateway using PayMV QR
|
||||
@@ -0,0 +1,5 @@
|
||||
- NFC tap-to-pay with wallet integration
|
||||
- Set a default card for NFC payments
|
||||
- Launcher shortcut renamed to "Scan to Pay"
|
||||
- New NFC icon
|
||||
- Cards hidden when not relevant
|
||||
@@ -0,0 +1,9 @@
|
||||
- Default transfer accounts
|
||||
- Share-to-scan-to-pay with merchant details
|
||||
- Static BML QR scans saved to recents
|
||||
- Unified QR scan flow across banks
|
||||
- Friendlier transfer flow UI
|
||||
- Removed *** masking on PayMV QR amounts
|
||||
- New NFC tap-to-pay animation
|
||||
- Fixed NFC-related crash
|
||||
- Scanning a PayMV QR after selecting a card falls back to the transfer flow
|
||||
@@ -0,0 +1,2 @@
|
||||
- Fixed transfer form resetting on switching tabs
|
||||
- Fixed default account not selected from contacts page or contact picker
|
||||
@@ -0,0 +1 @@
|
||||
- New navigation mode: Nostalgic
|
||||
@@ -0,0 +1,10 @@
|
||||
- View details of blocked balance
|
||||
- Notifications
|
||||
- Wheel optimizations
|
||||
- Fixed back navigation from Finances page
|
||||
- Fixed "An error occurred" instead of "No available balance"
|
||||
- Fixed Islamic visa card image
|
||||
- Fixed PIN button glyph scaling and theme bugs
|
||||
- Fixed receipt buttons on some phones
|
||||
- Fixed balance not updating after BML QR payment
|
||||
- Fixed onboarding warning bypass
|
||||
@@ -0,0 +1 @@
|
||||
- NFC prompts for off, default and not supported states
|
||||
@@ -0,0 +1,3 @@
|
||||
- About page
|
||||
- Donate to support development
|
||||
- Import TOTP QR and Google Authenticator export QR
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user