Compare commits

...
35 Commits
Author SHA1 Message Date
shihaam fbbfdd8537 release v1.0.28
Auto Tag on Version Change / check-version (push) Successful in 4s
Build and Release APK / build (push) Successful in 3m48s
2026-09-26 07:00:38 +05:00
shihaam 169c3c144c redesigned full screen for MIB recipt 2026-09-26 06:58:27 +05:00
shihaam 71ef7a5b55 MIB recipt redesign to mimick new MIB app and dark mode support
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-26 06:46:19 +05:00
shihaam 32563ea398 redesigned full screen for BML recipt
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-26 04:46:42 +05:00
shihaam 41ce8a65ad bml recipt dark theme support
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-26 04:28:19 +05:00
shihaam c7d3efa64e update docs: update payMV QR Design
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-09-26 04:06:06 +05:00
shihaam 2688118f52 update payMV QR Design 2026-09-26 04:05:36 +05:00
shihaam e717360d00 Merge pull request 'fix language toggle alignment' from azuwlaa/android:fix/language-toggle-alignment
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-09-25 17:53:03 +05:00
shihaam 2f26599931 fix language toggle misaligned on onboarding screen #58
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-09-25 17:50:17 +05:00
azuwlaaandClaude Opus 5.5 f8f9d82440 fix language toggle buttons misaligned
The Dhivehi button sat a few pixels lower than English because the
toggle group aligns children by text baseline and Thaana has different
font metrics. Turn off baseline alignment for the language toggle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQUrmJjrypeoPsubpzuezC
2026-09-25 17:35:45 +05:00
shihaam a37354fc82 release version 1.0.27
Auto Tag on Version Change / check-version (push) Successful in 4s
Build and Release APK / build (push) Successful in 4m5s
2026-09-25 07:03:40 +05:00
shihaam 66568a464a share contact account numbers and PayMV QR for contacts
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-25 06:53:15 +05:00
shihaam 5210e6207f redegsigned contacts page: removed delete and edit buttons from contacts list, made a new contacts sheet and moved buttons there
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-09-25 06:35:39 +05:00
shihaam 369c6e4a37 redegsigned OTP page and show next TOTP code
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-25 06:18:09 +05:00
shihaam fbedc631c5 prep for fdroid
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-25 05:53:53 +05:00
shihaam 7d6c192a63 update ci to use owner-repo name as title and apk name
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-25 05:25:09 +05:00
shihaam e7f7590654 release version 1.0.25
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m17s
2026-09-25 04:45:25 +05:00
shihaam a048be939c Merge branch 'fix/show-currency-of-other-account-in-transfer-page'
Auto Tag on Version Change / check-version (push) Failing after 2s
2026-09-25 04:42:36 +05:00
shihaam 102a986201 Merge branch 'fix/contact-add-page-account-number-enter-behaviour'
Auto Tag on Version Change / check-version (push) Failing after 3s
# Conflicts:
#	app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt
2026-09-25 04:37:22 +05:00
shihaam 440db13a0f also add search button to keyboard in transfer page to feild and move focus to amount feild on search search or to field fill 2026-09-25 04:35:31 +05:00
shihaam 0dc81fef62 Merge branch 'feat/hide-accounts'
Auto Tag on Version Change / check-version (push) Failing after 3s
2026-09-25 04:21:08 +05:00
shihaam 7e52b510a1 disable unnessary api requests on saving profile/account disable and also disable all accounts for a profile when that profile is disabled 2026-09-25 04:20:40 +05:00
shihaam 0d7833af5c Merge branch 'feat/search-saved-contacts-in-account-number-field'
Auto Tag on Version Change / check-version (push) Failing after 2s
2026-09-25 04:05:05 +05:00
shihaam 9e45e36c0a Merge branch 'feat/login-sort-order'
Auto Tag on Version Change / check-version (push) Failing after 4s
2026-09-25 04:02:12 +05:00
shihaam c883b6b521 added margin between icon and edge of selection in login list 2026-09-25 03:52:07 +05:00
shihaam 5dc37aadf0 update docs 2026-09-25 03:46:35 +05:00
shihaam 1e3cf3420b login sorting fixed 2026-09-25 03:43:58 +05:00
shihaam d98dcfd407 auto select from account after to account is selected 2026-09-25 03:19:38 +05:00
flamexode 3b55fa30a8 show currency after searching for account
fixes #40
2026-09-21 21:56:23 +05:00
flamexode 7f0f2707a1 default name to title case of API response name 2026-09-21 18:02:56 +05:00
flamexode 117733b766 change keyboard enter behaviour of add contacts account number field
fixes #43
2026-09-21 17:58:48 +05:00
flamexode d769df6f6b add ability to hide accounts
implements #50
2026-09-21 17:47:26 +05:00
flamexode caf0db60da autocomplete with saved contacts in transfer page
implements #49
2026-09-21 17:47:08 +05:00
flamexode b48022d249 add ability to reorder logins
fixes #47
2026-09-21 17:14:06 +05:00
shihaam 7574a5e8b0 release v1.0.25 - idk numbers
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m33s
2026-09-21 16:30:19 +05:00
111 changed files with 3274 additions and 562 deletions
+14 -17
View File
@@ -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}"
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 36
versionCode = 23
versionName = "1.0.24"
versionCode = 29
versionName = "1.0.28"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -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)
@@ -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()
@@ -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)
@@ -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() }
@@ -119,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
@@ -145,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
@@ -174,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
@@ -191,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 {
@@ -222,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,
@@ -313,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
}
@@ -338,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(
@@ -366,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 {
@@ -412,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(
@@ -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()
}
}
}
@@ -40,9 +40,11 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
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.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.databinding.ItemPickerRowBinding
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler
@@ -78,6 +80,8 @@ class TransferFragment : Fragment() {
private var resolvedAccountNumber = ""
private var resolvedRecipientName = ""
private var resolvedBankName = ""
/** Last real profile/contact photo loaded into the "To" card (not an initials placeholder). */
private var loadedToPhoto: Bitmap? = null
private var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown
private var resolvedToOwnAccount: BankAccount? = null
@@ -92,6 +96,7 @@ class TransferFragment : Fragment() {
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
private var accountDropdownAdapter: AccountDropdownAdapter? = null
private var contactDropdownAdapter: ContactDropdownAdapter? = null
/**
* Owns everything BML-specific: sessions, the transfer itself, the business-profile OTP
@@ -305,6 +310,9 @@ class TransferFragment : Fragment() {
setupFromDropdown()
setupAccountLookup()
// Contact search dropdown on the To field needs viewModel.contacts populated —
// otherwise it stays empty until the contacts tab or picker sheet is opened first.
(activity as? HomeActivity)?.loadAllContacts()
viewModel.hideAmounts.observe(viewLifecycleOwner) {
accountDropdownAdapter?.notifyDataSetChanged()
@@ -327,7 +335,7 @@ class TransferFragment : Fragment() {
// MFAISA source + a phone-number pick (e.g. a tagged M-Faisa recent) — re-run the
// basicBeneDetails lookup so the recipient gets fully resolved before Send is enabled.
if (selectedAccount?.bank == "MFAISA") {
binding.etTo.setText(accountNumber)
binding.etTo.setText(accountNumber, false)
mfaisaHandler().searchRecipient(accountNumber)
return@setFragmentResultListener
}
@@ -336,18 +344,8 @@ class TransferFragment : Fragment() {
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash)
if (selectedAccount == null) {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
if (defaultNum != null) {
val defaultAcc = viewModel.accounts.value?.firstOrNull { it.accountNumber == defaultNum }
if (defaultAcc != null) {
selectedAccount = defaultAcc
updateAmountPrefix(defaultAcc)
showFromCard(defaultAcc)
updateTransferButton()
}
}
}
selectDefaultSourceIfNone()
focusAmount()
}
binding.btnPickContact.setOnClickListener {
@@ -408,7 +406,7 @@ class TransferFragment : Fragment() {
binding.cardToInfo.visibility = View.VISIBLE
if (savedToImageHash != null) loadToPhoto(savedToImageHash!!, isProfile = resolvedToOwnAccount != null)
} else if (savedToText.isNotEmpty()) {
binding.etTo.setText(savedToText)
binding.etTo.setText(savedToText, false)
}
if (savedAmount.isNotEmpty()) binding.etAmount.setText(savedAmount)
if (savedRemarks.isNotEmpty()) binding.etRemarks.setText(savedRemarks)
@@ -537,7 +535,7 @@ class TransferFragment : Fragment() {
resolvedToOwnAccount = null
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.etTo.setText("")
binding.etTo.setText("", false)
}
} else {
binding.tilTo.hint = getString(R.string.transfer_to)
@@ -551,7 +549,7 @@ class TransferFragment : Fragment() {
resolvedToOwnAccount = null
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.etTo.setText("")
binding.etTo.setText("", false)
}
}
// The picker and QR-scan icons live alongside the tilTo input. Keep them in sync with
@@ -720,13 +718,12 @@ class TransferFragment : Fragment() {
}
private fun setupAccountLookup() {
binding.tilTo.setEndIconOnClickListener {
// M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails)
if (selectedAccount?.bank == "MFAISA") {
mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty())
} else {
lookupAccount()
}
binding.tilTo.setEndIconOnClickListener { searchTo() }
binding.etTo.setOnEditorActionListener { _, actionId, _ ->
if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH) {
searchTo()
true
} else false
}
binding.btnClearToInfo.setOnClickListener {
@@ -764,6 +761,62 @@ class TransferFragment : Fragment() {
updateTransferButton()
}
}
setupContactDropdown()
}
private fun searchTo() {
// M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails)
if (selectedAccount?.bank == "MFAISA") {
mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty())
} else {
lookupAccount()
}
}
/** Moves focus to the amount field and brings up the keyboard once a recipient is resolved. */
internal fun focusAmount() {
val et = binding.etAmount
// Posted so it runs after the To-row visibility changes and any closing picker sheet
et.post {
if (_binding == null) return@post
et.requestFocus()
et.setSelection(et.text?.length ?: 0)
val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
imm.showSoftInput(et, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT)
}
}
/** Live "search contacts as you type" dropdown on the To field (shown once 3+ chars are entered). */
private fun setupContactDropdown() {
val adapter = ContactDropdownAdapter(requireContext()) { selectedAccount?.bank == "MFAISA" }
contactDropdownAdapter = adapter
binding.etTo.setAdapter(adapter)
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
adapter.updateSource(contacts)
}
binding.etTo.setOnItemClickListener { _, _, position, _ ->
val contact = adapter.getContact(position) ?: return@setOnItemClickListener
prefillToDirectly(
accountNumber = contact.benefAccount,
displayName = contact.benefNickName,
subtitle = "${contact.benefBankName} · ${contact.benefAccount}",
colorHex = contact.bankColor,
imageHash = contact.customerImgHash
)
selectDefaultSourceIfNone()
focusAmount()
}
}
/** Picks the user's default account as the source when a recipient is chosen before any source. */
private fun selectDefaultSourceIfNone() {
if (selectedAccount != null) return
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber() ?: return
val defaultAcc = viewModel.accounts.value?.firstOrNull { it.accountNumber == defaultNum } ?: return
selectSourceAccount(defaultAcc)
}
private fun lookupAccount() {
@@ -801,6 +854,7 @@ class TransferFragment : Fragment() {
colorHex = matchedContact.bankColor,
imageHash = matchedContact.customerImgHash
)
focusAmount()
return
}
@@ -868,7 +922,8 @@ class TransferFragment : Fragment() {
resolvedRecipientName = info.accountName
resolvedBankName = info.bankId
resolvedDestCurrency = info.currency
savedToSubtitle = "${info.accountNumber} · ${info.bankId}"
savedToSubtitle = listOfNotNull(info.accountNumber, info.bankId, info.currency.takeIf { it.isNotBlank() })
.joinToString(" · ")
savedToColorHex = colorHex
savedToImageHash = when {
matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash
@@ -880,7 +935,7 @@ class TransferFragment : Fragment() {
showToCard(matchedAcc)
} else {
binding.tvToAccountName.text = displayName
binding.tvToBankBic.text = "${info.accountNumber} · ${info.bankId}"
binding.tvToBankBic.text = savedToSubtitle
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
@@ -892,6 +947,7 @@ class TransferFragment : Fragment() {
binding.cardToInfo.visibility = View.VISIBLE
updateTransferButton()
saveToRecents(info)
focusAmount()
when {
matchedAcc?.profileImageHash != null ->
@@ -975,7 +1031,7 @@ class TransferFragment : Fragment() {
binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE
binding.tilTo.error = null
binding.etTo.setText(accountNumber)
binding.etTo.setText(accountNumber, false)
lookupAccount()
}
@@ -1114,6 +1170,8 @@ class TransferFragment : Fragment() {
val destDisplay = binding.tvToAccountName.text?.toString() ?: resolvedAccountNumber
val bankNameCapture = resolvedBankName
val capturedToAvatar = (binding.ivToPhoto.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
// The MIB receipt only takes a real photo; it draws its own initials placeholder otherwise
val capturedToPhoto = capturedToAvatar?.takeIf { it === loadedToPhoto }
val destCurrency = resolvedDestCurrency.ifBlank {
allAccounts.firstOrNull { it.accountNumber == resolvedAccountNumber }
@@ -1151,7 +1209,7 @@ class TransferFragment : Fragment() {
val activity = requireActivity() as HomeActivity
activity.triggerRefresh()
dialog.dismiss()
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToPhoto))
} else if (!ok) {
dialog.dismiss()
if (msg == "CONNECTIVITY") {
@@ -1512,7 +1570,7 @@ class TransferFragment : Fragment() {
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE
binding.etTo.setText("")
binding.etTo.setText("", false)
binding.tilTo.error = null
binding.tilAmount.error = null
}
@@ -1527,6 +1585,7 @@ class TransferFragment : Fragment() {
if (_binding != null) {
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(bitmap)
loadedToPhoto = bitmap
}
}
}
@@ -1782,4 +1841,59 @@ class TransferFragment : Fragment() {
override fun convertResultToString(r: Any?) = ""
}
}
/** Filters [source] contacts by nickname/name/account number, shown once the query is 3+ chars. */
private inner class ContactDropdownAdapter(
private val context: Context,
private val isDisabled: () -> Boolean
) : BaseAdapter(), Filterable {
private var source: List<BankContact> = emptyList()
private var filtered: List<BankContact> = emptyList()
fun updateSource(contacts: List<BankContact>) {
source = contacts
}
fun getContact(position: Int): BankContact? = filtered.getOrNull(position)
override fun getCount() = filtered.size
override fun getItem(position: Int) = filtered[position]
override fun getItemId(position: Int) = position.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val contact = filtered[position]
val b = if (convertView?.tag is ItemPickerRowBinding) {
convertView.tag as ItemPickerRowBinding
} else {
ItemPickerRowBinding.inflate(LayoutInflater.from(context), parent, false).also { it.root.tag = it }
}
b.tvPrimary.text = contact.benefNickName
b.tvSecondary.text = "${contact.benefBankName} · ${contact.benefAccount}"
b.tvBalance.visibility = View.GONE
b.ivIcon.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
b.ivIcon.setImageBitmap(makeInitialsBitmap(contact.benefNickName, contact.bankColor))
return b.root
}
override fun getFilter() = object : Filter() {
override fun performFiltering(constraint: CharSequence?): FilterResults {
val query = constraint?.toString()?.trim().orEmpty()
val matches = if (isDisabled() || query.length < 3) emptyList() else source.filter {
it.benefNickName.contains(query, ignoreCase = true) ||
it.benefName.contains(query, ignoreCase = true) ||
it.benefAccount.contains(query, ignoreCase = true)
}.take(8)
return FilterResults().apply { values = matches; count = matches.size }
}
@Suppress("UNCHECKED_CAST")
override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
filtered = results?.values as? List<BankContact> ?: emptyList()
notifyDataSetChanged()
}
override fun convertResultToString(r: Any?) = ""
}
}
}
@@ -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 = "",
@@ -24,6 +24,7 @@ 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
@@ -33,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
@@ -61,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"
@@ -89,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)
@@ -168,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
@@ -375,8 +403,13 @@ class TransferReceiptFragment : Fragment() {
* 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 canvas = Canvas(bitmap)
@@ -385,6 +418,27 @@ class TransferReceiptFragment : Fragment() {
callback(bitmap)
}
/** 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 {
if (raw.isBlank()) return ""
return try {
@@ -394,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 {
@@ -466,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 {
@@ -194,5 +194,6 @@ class FahipayTransferHandler(
colorHex = "#FF6B00",
imageHash = null
)
fragment.focusAmount()
}
}
@@ -121,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"
@@ -129,6 +129,15 @@ class MibTransferHandler(
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()) {
@@ -160,7 +169,9 @@ class MibTransferHandler(
toBank = toBank,
remarks = remarks,
mibReferenceNo = result.trxId,
mibTransactionDate = result.date
mibTransactionDate = result.date,
mibFromProfileName = src.profileName,
mibTransactionType = transactionType
)
Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt)
} else {
@@ -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 {
@@ -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')
}
@@ -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>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="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>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="M3,11h8V3H3V11zM5,5h4v4H5V5zM3,21h8v-8H3V21zM5,15h4v4H5V15zM13,3v8h8V3H13zM19,9h-4V5h4V9zM19,19h2v2h-2V19zM13,13h2v2h-2V13zM15,15h2v2h-2V15zM13,17h2v2h-2V17zM15,19h2v2h-2V19zM17,17h2v2h-2V17zM17,13h2v2h-2V13zM19,15h2v2h-2V15z" />
</vector>
@@ -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"
+33 -4
View File
@@ -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
+87 -119
View File
@@ -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>
@@ -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>
+1 -19
View File
@@ -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"
+141 -33
View File
@@ -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>
+1
View File
@@ -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>
+19
View File
@@ -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>
+20
View File
@@ -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>
+9 -1
View File
@@ -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>
+10
View File
@@ -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>
+1 -1
View File
@@ -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) &nbsp;&nbsp;&nbsp; **Next →** [PayMV QR](08-paymv-qr.md)
+40
View File
@@ -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).
---
&nbsp;
---
[← Saved Favourites](07-contacts.md)
+1
View File
@@ -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 |
---
+9
View File
@@ -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
+12 -9
View File
@@ -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.
---
+80 -15
View File
@@ -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
View File
@@ -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
+14
View File
@@ -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.
+92 -11
View File
@@ -31,10 +31,10 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
| `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 |
| `60` | Merchant city / store code | BML POS QRs only |
| `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 |
@@ -66,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` |
---
@@ -77,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 |
---
@@ -115,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)
@@ -219,11 +300,11 @@ 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
```
+1
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
@@ -0,0 +1,6 @@
- BML and MIB card freeze/unfreeze
- Background service and push notifications (very unstable)
- Cards ordered: active BML, MIB, inactive BML, inactive MIB
- Faster notification loading
- Fixed notification icon in light theme
- Fixed contact picker rendering
@@ -0,0 +1,9 @@
- Edge-to-edge support for Android 15+
- Redesigned onboarding pages and added disclaimer
- otpauth://totp/ URI import for OTP seed
- Hint shown when biometrics are disabled
- App resumes last screen after unlocking
- Loading indicators for account info and transfers
- Fixed bottom bar staying above the keyboard
- Fixed adding a contact with multiple BML logins
- Added Visa, Master and Amex card icons
@@ -0,0 +1,8 @@
- Contact picker recents show most recent first
- Outstanding and unbilled credit card values
- Fixed MIB USD history amounts
- Fixed currency detection in MIB to MIB transfers
- Option to add contact during BML to MIB USD transfer
- Settings item renamed to "Confirm Transaction"
- Fixed "Cards" title in tap to pay from shortcut
- Removed gap under bottom bar on cards screens
@@ -0,0 +1 @@
- Initial support for Ooredoo M-Faisa
@@ -0,0 +1,4 @@
- Updated BML card mapping (fixed Mastercard Platinum)
- Disabled PayMV QR for M-Faisa accounts
- Fixed share and save buttons showing on shared receipt image
- Fixed bottom bar flashing before receipt share sheet
@@ -0,0 +1,5 @@
- Fixed QR scanner failing to launch on revisit
- Fixed PayMV scan keeping unusable M-Faisa source
- Fixed BML merchant staying loaded after payment
- Transfer logic split into per-bank handlers
- Note: Fahipay is still broken
@@ -0,0 +1 @@
- No changes from previous version
@@ -0,0 +1,7 @@
- Hide accounts you don't want to see
- Reorder logins
- Account number field on the transfer page suggests saved contacts
- Transfer page shows the other account's currency
- Search button on the transfer "To" field keyboard, focus moves to amount afterwards
- Search key in the add contact drawer now works
- Saving a profile disable no longer sends unnecessary refresh requests
@@ -0,0 +1,5 @@
- Redesigned OTP page: codes turn red in the last 5 seconds and there are copy buttons, and you can tap a card to copy its code
- OTP page and login setup now also show the next code
- Redesigned contacts list: tap a contact to open a details sheet, which now holds the edit and delete buttons
- Contact details show the account number, account name, bank and currency
- Copy or share a contact's account details, or show a PayMV QR code for them
@@ -0,0 +1,5 @@
- Fix language toggle alignment.
- Redesign Fahipay (still broken) and BML PayMV QR
- BML receipt preview dark theme support
- MIB receipt redesign with dark theme support
- New recipt full-screen mode for MIB and BML
@@ -0,0 +1,5 @@
- Customizable bottom bar and quick actions
- Multiple Fahipay and MIB account support
- Updated BML logo
- Transfer button stays disabled until all required fields are filled
- Fixed display issues on phones without edge-to-edge screens
@@ -0,0 +1,8 @@
- BML business profile support
- Privacy mode toggle to hide balances
- View previous transfer receipts, full screen view, long-press to copy, save and share
- Initial PayMV QR generation
- Renamed Activities to Recent Transfers
- Fixed card transaction history not loading
- Fixed BML contact list loading
- Fixed single-profile multi-login issue
@@ -0,0 +1,5 @@
- BML loans shown on the Financing page, included in pending finances
- Animated lock and show/hide amount icons
- Credit cards and spending limits in a separate dashboard section, limits shown as progress bars
- Optimized MIB session keepalive
- Long-press to copy receipt fields in full screen
@@ -0,0 +1,8 @@
- MIB debit/credit cards shown in a dashboard carousel
- Transfers from BML business profile accounts
- New setting: auto unlock on correct PIN
- Fewer network requests on BML session refresh
- Refresh indicator moved to the action bar
- Reworked back button navigation
- Fixed PayMV QR page empty space
- BML loan accounts excluded from balance total and transfer sources
@@ -0,0 +1,7 @@
- Compressed MIB card images to reduce app size
- Connectivity banners for network issues
- Transfer button disabled when source bank is unavailable
- Fixed cache read issue when refreshing offline
- Privacy mode hides amounts in transfer dropdown
- User-agent reports real device info
- Fixed error on failed PIN entry
@@ -0,0 +1,8 @@
- QR payments from BML gateway and static QR payments from BML cards
- Zoom and flashlight on QR scanner
- Bank/profile images in accounts list and transfer dropdown
- Local per-profile images for BML and Fahipay
- Better network error handling and timeout handling
- Fixed contacts infinite loading offline
- Fixed crash with no network on transaction history
- Fixed nav bar buttons disappearing

Some files were not shown because too many files have changed in this diff Show More