prep for reload/raastas part 3: add GST notice, max and min values and make contact picker and contacts page behave correctly for fahipay contacts

This commit is contained in:
2026-10-02 19:51:52 +05:00
parent b05ee44715
commit c0944d3809
9 changed files with 229 additions and 23 deletions
@@ -15,7 +15,7 @@ import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
class ContactPickerAdapter(
private val imageCache: MutableMap<String, Bitmap>,
private val onItemClick: (accountNumber: String, label: String) -> Unit,
private val onItemClick: (accountNumber: String, label: String, categoryId: String?) -> Unit,
private val onSameAsFrom: () -> Unit,
private val onImageNeeded: ((hash: String) -> Unit)? = null,
private val onItemLongClick: ((accountNumber: String, anchor: android.view.View) -> Boolean)? = null
@@ -33,7 +33,9 @@ class ContactPickerAdapter(
val imageHash: String? = null,
val inactiveReason: String? = null,
val balance: String? = null,
val bankLogoRes: Int? = null
val bankLogoRes: Int? = null,
/** The saved contact's category, for rows that are contacts; null otherwise. */
val categoryId: String? = null
) : PickerItem()
}
@@ -125,7 +127,7 @@ class ContactPickerAdapter(
Toast.makeText(binding.root.context, item.inactiveReason, Toast.LENGTH_SHORT).show()
item.isSameAsFrom ->
Toast.makeText(binding.root.context, R.string.transfer_same_account, Toast.LENGTH_SHORT).show()
else -> onItemClick(item.accountNumber, item.displayName)
else -> onItemClick(item.accountNumber, item.displayName, item.categoryId)
}
}
binding.root.setOnLongClickListener { view ->
@@ -54,7 +54,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val pageAdapters: List<ContactPickerAdapter> = pages.mapIndexed { i, page ->
ContactPickerAdapter(
imageCache = sharedImageCache,
onItemClick = { accountNumber, label -> handlePickerSelection(accountNumber, label) },
onItemClick = { accountNumber, label, categoryId -> handlePickerSelection(accountNumber, label, categoryId) },
onSameAsFrom = {},
onImageNeeded = { hash -> fetchImage(hash) },
onItemLongClick = { accountNumber, anchor ->
@@ -161,12 +161,13 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
}.also { it.attach() }
}
private fun handlePickerSelection(accountNumber: String, label: String) {
private fun handlePickerSelection(accountNumber: String, label: String, categoryId: String?) {
val contacts = viewModel.contacts.value ?: emptyList()
val accounts = viewModel.accounts.value ?: emptyList()
val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
categoryId?.let { bundle.putString(KEY_CATEGORY, it) }
when {
accountNumber.startsWith("bmlqr:") -> {
bundle.putString(KEY_SUBTITLE, "BML QR Merchant")
@@ -226,7 +227,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
imageHash = r.imageHash,
// A MFAISA-tagged recent is itself a valid M-Faisa recipient — don't grey it out
// when the source is M-Faisa.
inactiveReason = if (r.bank == "MFAISA") null else mfaisaInactive
inactiveReason = if (r.bank == "MFAISA") null else mfaisaInactive,
categoryId = r.contactCategory
))
}
return items
@@ -328,7 +330,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
colorHex = contact.bankColor,
isSameAsFrom = contact.benefAccount == fromAccountNumber,
imageHash = contact.customerImgHash,
inactiveReason = mfaisaInactive ?: currencyMismatchReason(fromCurrency, contact.transferCyDesc)
inactiveReason = mfaisaInactive ?: currencyMismatchReason(fromCurrency, contact.transferCyDesc),
categoryId = contact.benefCategoryId
))
}
return items
@@ -396,6 +399,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
const val KEY_SUBTITLE = "subtitle"
const val KEY_COLOR = "color"
const val KEY_IMAGE_HASH = "imageHash"
/** The picked contact's category (`BankContact.benefCategoryId`); absent for non-contacts. */
const val KEY_CATEGORY = "category"
private const val ARG_FROM_ACCOUNT = "fromAccount"
private const val RECENTS_TAG = "__recents__"
private const val MY_ACCOUNTS_TAG = "__my_accounts__"
@@ -188,7 +188,8 @@ class ContactsFragment : Fragment() {
displayName = contact.name,
subtitle = contact.transferSubtitle,
colorHex = contact.bankColor,
imageHash = contact.imageHash
imageHash = contact.imageHash,
contactCategory = contact.categoryId
)
(requireActivity() as HomeActivity).showWithBackStack(fragment)
}
@@ -51,6 +51,7 @@ import sh.sar.basedbank.databinding.ItemPickerRowBinding
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
import sh.sar.basedbank.databinding.ItemTransferTypeBinding
import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
import sh.sar.basedbank.ui.home.transfer.FahipayService
import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
import sh.sar.basedbank.ui.home.transfer.MibTransferHandler
@@ -277,6 +278,7 @@ class TransferFragment : Fragment() {
private const val ARG_SUBTITLE = "contact_subtitle"
private const val ARG_COLOR = "contact_color"
private const val ARG_IMAGE_HASH = "contact_image_hash"
private const val ARG_CONTACT_CATEGORY = "contact_category"
private const val ARG_FROM_ACCOUNT = "from_account"
private const val ARG_AMOUNT_PREFILL = "amount_prefill"
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
@@ -311,7 +313,9 @@ class TransferFragment : Fragment() {
displayName: String,
subtitle: String,
colorHex: String,
imageHash: String?
imageHash: String?,
/** The contact's category; a Fahipay favourites list opens as that payout service. */
contactCategory: String? = null
) = TransferFragment().apply {
arguments = Bundle().apply {
putString(ARG_ACCOUNT, accountNumber)
@@ -319,6 +323,7 @@ class TransferFragment : Fragment() {
putString(ARG_SUBTITLE, subtitle)
putString(ARG_COLOR, colorHex)
if (imageHash != null) putString(ARG_IMAGE_HASH, imageHash)
if (contactCategory != null) putString(ARG_CONTACT_CATEGORY, contactCategory)
}
}
@@ -393,6 +398,9 @@ class TransferFragment : Fragment() {
return@setFragmentResultListener
}
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
if (applyFahipayContact(accountNumber, bundle.getString(ContactPickerSheetFragment.KEY_CATEGORY), label)) {
return@setFragmentResultListener
}
val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
@@ -435,9 +443,11 @@ class TransferFragment : Fragment() {
private fun applyArguments() {
// Pre-select contact if navigated from contacts page or QR scan
arguments?.getString(ARG_ACCOUNT)?.let { account ->
val name = arguments?.getString(ARG_NAME) ?: account
if (applyFahipayContact(account, arguments?.getString(ARG_CONTACT_CATEGORY), name)) return@let
prefillToDirectly(
accountNumber = account,
displayName = arguments?.getString(ARG_NAME) ?: account,
displayName = name,
subtitle = arguments?.getString(ARG_SUBTITLE) ?: account,
colorHex = arguments?.getString(ARG_COLOR) ?: "#607D8B",
imageHash = arguments?.getString(ARG_IMAGE_HASH)
@@ -1090,6 +1100,9 @@ class TransferFragment : Fragment() {
binding.etTo.setOnItemClickListener { _, _, position, _ ->
val contact = adapter.getContact(position) ?: return@setOnItemClickListener
if (applyFahipayContact(contact.benefAccount, contact.benefCategoryId, contact.benefNickName)) {
return@setOnItemClickListener
}
prefillToDirectly(
accountNumber = contact.benefAccount,
displayName = contact.benefNickName,
@@ -1102,6 +1115,19 @@ class TransferFragment : Fragment() {
}
}
/**
* A saved Fahipay favourite: its list ([categoryId]) already says which service pays it, so
* that service is applied as the only transfer type, with no carrier lookup. That switches
* the source to the Fahipay wallet and brings in the service's amount rules, the same as a
* searched number. Returns false, doing nothing, when [categoryId] isn't a Fahipay list.
*/
private fun applyFahipayContact(number: String, categoryId: String?, name: String): Boolean {
val service = FahipayService.fromContactCategory(categoryId) ?: return false
clearRecipient()
offerTransferTypes(number, listOf(TransferType.Fahipay(service, name.takeIf { it.isNotBlank() && it != number })))
return true
}
/** Picks the user's default account as the source when a recipient is chosen before any source. */
private fun selectDefaultSourceIfNone() {
if (selectedAccount != null) return
@@ -1312,7 +1338,9 @@ class TransferFragment : Fragment() {
displayName: String,
subtitle: String,
colorHex: String,
imageHash: String?
imageHash: String?,
/** Saved with the recent, so picking it again pays the same way (Fahipay services). */
contactCategory: String? = null
) {
resolvedAccountNumber = accountNumber
resolvedRecipientName = displayName
@@ -1363,7 +1391,8 @@ class TransferFragment : Fragment() {
subtitle = subtitle,
colorHex = colorHex,
imageHash = recentImageHash,
isProfileImage = recentIsProfileImage
isProfileImage = recentIsProfileImage,
contactCategory = contactCategory
))
if (contact != null && imageHash != null) loadToPhoto(imageHash, isProfile = false)
}
@@ -1890,10 +1919,15 @@ class TransferFragment : Fragment() {
private fun updateTransferButton() {
// The handler is dropped with the view, but a picked Fahipay service lives on in the
// draft — build it so the amount rules still apply after the view is recreated.
val fahipay = if (draft.fahipayService != null) fahipayHandler() else null
fahipay?.syncAmountField()
if (bmlHandler().isOtpFlowActive) return
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
val recipientReady = bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank()
val hasAll = selectedAccount != null && recipientReady && amount > 0 && !transferTypePending
val amountOk = amount > 0 && fahipay?.amountProblem == null
val hasAll = selectedAccount != null && recipientReady && amountOk && !transferTypePending
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
val errors = viewModel.connectivityErrors.value ?: emptySet()
val bankOffline = "NO_INTERNET" in errors ||
@@ -1,5 +1,6 @@
package sh.sar.basedbank.ui.home.transfer
import android.text.InputType
import androidx.annotation.DrawableRes
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
@@ -12,6 +13,8 @@ import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.ui.home.HomeViewModel
import sh.sar.basedbank.ui.home.TransferFragment
import sh.sar.basedbank.util.AccountInputParser
import java.math.BigDecimal
import java.math.RoundingMode
/**
* A service a Fahipay wallet can pay out to. The carrier lookup decides which of these apply to
@@ -26,11 +29,41 @@ enum class FahipayService(
val label: String,
val destinationLabel: String,
@param:DrawableRes val iconRes: Int,
/** Smallest amount the service accepts, in MVR. */
val minAmount: Int,
/** Largest amount the service accepts, in MVR, or null for no limit. */
val maxAmount: Int?,
/** Whether the amount may have a fractional part (up to 2 decimal places). */
val decimalsAllowed: Boolean,
/**
* The contact category of this service's Fahipay favourites list (`BankContact.benefCategoryId`,
* set by `FahipayContactsClient`). Recents of this service are tagged with it too.
*/
val contactCategory: String,
/** GST the carrier takes out of the amount before crediting it, in percent, or null for none. */
val gstPercent: Int? = null,
) {
RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo),
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo),
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo),
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo),
RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo,
minAmount = 11, maxAmount = null, decimalsAllowed = false,
contactCategory = "FAHIPAY_RAASTAS", gstPercent = 8),
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo,
minAmount = 10, maxAmount = 50000, decimalsAllowed = true,
contactCategory = "FAHIPAY_OOREDOO_BILL"),
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo,
minAmount = 8, maxAmount = 1000, decimalsAllowed = false,
contactCategory = "FAHIPAY_RELOAD"),
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo,
minAmount = 10, maxAmount = 5000, decimalsAllowed = false,
contactCategory = "FAHIPAY_DHIRAAGU_BILL");
companion object {
/**
* The service a saved Fahipay favourite or recent pays with, from its [contactCategory],
* or null when [categoryId] isn't one of them.
*/
fun fromContactCategory(categoryId: String?): FahipayService? =
entries.firstOrNull { it.contactCategory == categoryId }
}
}
/**
@@ -79,9 +112,71 @@ class FahipayTransferHandler(
lookupCarrier(rawInput)
}
/** Forgets the selected service. The picker itself is the fragment's to reset. */
/**
* Forgets the selected service and gives back the amount and reference fields it took over.
* The picker itself is the fragment's to reset.
*/
fun clearState() {
if (service == null) return
service = null
binding.tilAmount.error = null
binding.tilAmount.helperText = null
binding.etAmount.inputType = DECIMAL_INPUT
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
}
/**
* Why the typed amount can't be sent with the selected service, or null when it can (or the
* field is empty, or no service is selected). Checks the service's minimum, maximum and
* whether it takes decimals.
*/
val amountProblem: String?
get() {
val svc = service ?: return null
val text = binding.etAmount.text?.toString()?.trim().orEmpty()
if (text.isEmpty()) return null
val amount = text.toBigDecimalOrNull() ?: return ctx.getString(R.string.transfer_fahipay_amount_invalid)
val fraction = amount.stripTrailingZeros().scale()
return when {
!svc.decimalsAllowed && fraction > 0 -> ctx.getString(R.string.transfer_fahipay_amount_whole)
fraction > 2 -> ctx.getString(R.string.transfer_fahipay_amount_decimals)
amount < BigDecimal(svc.minAmount) -> ctx.getString(R.string.transfer_fahipay_amount_min, svc.minAmount)
svc.maxAmount != null && amount > BigDecimal(svc.maxAmount) ->
ctx.getString(R.string.transfer_fahipay_amount_max, "%,d".format(svc.maxAmount))
else -> null
}
}
/**
* Keeps the amount and reference fields in step with the selected service: the amount
* error, the GST note under the amount, and the reference box disabled (none of the
* payouts take one). Idempotent — the fragment calls it on every form change. A no-op
* when no service is selected; [clearState] puts the fields back.
*/
fun syncAmountField() {
val svc = service ?: return
// Whole-number services get a keypad without a decimal point. Only set on change:
// setting inputType restarts the keyboard, and this runs on every keystroke.
val inputType = if (svc.decimalsAllowed) DECIMAL_INPUT else InputType.TYPE_CLASS_NUMBER
if (binding.etAmount.inputType != inputType) binding.etAmount.inputType = inputType
binding.tilRemarks.isEnabled = false
binding.tilRemarks.alpha = 0.4f
val problem = amountProblem
binding.tilAmount.error = problem
binding.tilAmount.helperText = if (problem == null) gstNote(svc) else null
}
/**
* What the recipient is credited once GST comes out, for services that charge it. The
* amount paid is GST-inclusive, so the credit is amount / (1 + rate), rounded down.
*/
private fun gstNote(svc: FahipayService): String? {
val gst = svc.gstPercent ?: return null
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
if (amount == null || amount.signum() <= 0) return ctx.getString(R.string.transfer_fahipay_gst_hint, gst)
val credited = amount.divide(BigDecimal.ONE + BigDecimal(gst).movePointLeft(2), 2, RoundingMode.DOWN)
return ctx.getString(R.string.transfer_fahipay_gst_receive, "%,.2f".format(credited), gst)
}
/**
@@ -90,6 +185,8 @@ class FahipayTransferHandler(
*/
fun applyService(type: TransferType.Fahipay, number: String) {
service = type.service
// None of the payouts take a reference; syncAmountField() disables the box
binding.etRemarks.setText("")
val contacts = viewModel.contacts.value ?: emptyList()
val displayName = type.ownerName
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
@@ -99,7 +196,8 @@ class FahipayTransferHandler(
displayName = displayName,
subtitle = "${type.label} · $number",
colorHex = "#FF6B00",
imageHash = null
imageHash = null,
contactCategory = type.service.contactCategory
)
fragment.focusAmount()
}
@@ -165,4 +263,9 @@ class FahipayTransferHandler(
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.RAASTAS)
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.OOREDOO_BILL)
}
private companion object {
/** The amount field's input type from `fragment_transfer.xml` (`numberDecimal`). */
const val DECIMAL_INPUT = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
}
}
@@ -13,7 +13,10 @@ data class RecentPick(
val isProfileImage: Boolean,
/** Source bank tag for the recent — e.g. "MFAISA". Used by the picker to decide
* per-bank selectability. Null for legacy entries; treated as unspecified. */
val bank: String? = null
val bank: String? = null,
/** The contact category the pick was paid as — set for Fahipay payouts (e.g.
* "FAHIPAY_RAASTAS") so picking the recent again applies the same service. */
val contactCategory: String? = null
)
object RecentsCache {
@@ -38,6 +41,7 @@ object RecentsCache {
if (r.imageHash != null) put("imageHash", r.imageHash)
put("isProfileImage", r.isProfileImage)
if (r.bank != null) put("bank", r.bank)
if (r.contactCategory != null) put("contactCategory", r.contactCategory)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -56,6 +60,7 @@ object RecentsCache {
if (r.imageHash != null) put("imageHash", r.imageHash)
put("isProfileImage", r.isProfileImage)
if (r.bank != null) put("bank", r.bank)
if (r.contactCategory != null) put("contactCategory", r.contactCategory)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -81,7 +86,8 @@ object RecentsCache {
colorHex = o.getString("colorHex"),
imageHash = o.optString("imageHash").takeIf { it.isNotBlank() },
isProfileImage = o.optBoolean("isProfileImage", false),
bank = o.optString("bank").takeIf { it.isNotBlank() }
bank = o.optString("bank").takeIf { it.isNotBlank() },
contactCategory = o.optString("contactCategory").takeIf { it.isNotBlank() }
)
}
} catch (_: Exception) {
@@ -1,6 +1,7 @@
package sh.sar.basedbank.util.fahipayapi
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.ui.home.transfer.FahipayService
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork
@@ -20,7 +21,8 @@ object FahipayContactParser {
imageHash = contact.customerImgHash,
profileId = contact.profileId,
transferSubtitle = contact.benefAccount,
canTransfer = false,
// Each favourites list is one payout service; the Transfer screen picks it from categoryId
canTransfer = FahipayService.fromContactCategory(contact.benefCategoryId) != null,
canEdit = false,
canDelete = false
)
+7
View File
@@ -262,6 +262,13 @@
<string name="transfer_label_from">From</string>
<string name="transfer_label_to">To</string>
<string name="transfer_type_for">Transfer type for %1$s</string>
<string name="transfer_fahipay_amount_invalid">Enter a valid amount</string>
<string name="transfer_fahipay_amount_whole">Whole amounts only, no decimals</string>
<string name="transfer_fahipay_amount_decimals">Up to 2 decimal places</string>
<string name="transfer_fahipay_amount_min">Minimum is MVR %1$d</string>
<string name="transfer_fahipay_amount_max">Maximum is MVR %1$s</string>
<string name="transfer_fahipay_gst_hint">%1$d%% GST is deducted from this amount</string>
<string name="transfer_fahipay_gst_receive">Recipient receives MVR %1$s after %2$d%% GST</string>
<string name="transfer_fahipay_phone_only">Fahipay transfers require a 7-digit phone number</string>
<string name="transfer_my_accounts">My Accounts</string>
<string name="transfer_same_as_from">This is the same account as the sender</string>