diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt index 3e08c63..5a0a517 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt @@ -35,6 +35,8 @@ import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import java.util.UUID import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import sh.sar.basedbank.BasedBankApp @@ -47,11 +49,13 @@ 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.databinding.ItemTransferTypeBinding import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler import sh.sar.basedbank.ui.home.transfer.MibTransferHandler import sh.sar.basedbank.ui.home.transfer.TransferDraft +import sh.sar.basedbank.ui.home.transfer.TransferType import sh.sar.basedbank.util.AccountListParser import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.AccountInputParser @@ -497,6 +501,7 @@ class TransferFragment : Fragment() { } draft.toText.isNotEmpty() -> binding.etTo.setText(draft.toText, false) } + if (transferTypePending) showTransferTypePicker() updateTransferButton() } @@ -539,6 +544,117 @@ class TransferFragment : Fragment() { binding.tilTo.endIconDrawable = ContextCompat.getDrawable(requireContext(), android.R.drawable.ic_menu_search) } + // ── Transfer type ───────────────────────────────────────────────────────── + + /** The open "Transfer Type" picker, if any — dismissed when the options go away. */ + private var transferTypeDialog: AlertDialog? = null + + /** + * Offers [types] for [number]. A lone option is applied straight away; with more, the + * "Transfer Type" picker pops up with nothing preselected — the user has to pick, and Send + * stays disabled until they do. Either way the pick also switches the source to an account + * that can pay it (see [applyTransferType]). + */ + internal fun offerTransferTypes(number: String, types: List) { + draft.transferTypes = types + draft.transferTypeNumber = number + draft.transferType = null + types.singleOrNull()?.let { applyTransferType(it); return } + updateTransferButton() + showTransferTypePicker() + } + + /** + * Pops up the offered transfer types. Before anything is picked the user must choose or + * cancel, and cancelling gives up on the number: the options are dropped and the "To" field + * is back for editing. Reopened to change a pick, cancelling just keeps the current one. + */ + private fun showTransferTypePicker() { + val types = draft.transferTypes + if (types.isEmpty() || transferTypeDialog?.isShowing == true) return + // Up to three tiles a row; fewer options just share the width + val grid = androidx.recyclerview.widget.RecyclerView(requireContext()).apply { + layoutManager = androidx.recyclerview.widget.GridLayoutManager(context, types.size.coerceIn(1, 3)) + val pad = (20 * resources.displayMetrics.density).toInt() + setPadding(pad, pad / 2, pad, 0) + clipToPadding = false + overScrollMode = View.OVER_SCROLL_NEVER + } + grid.adapter = object : androidx.recyclerview.widget.RecyclerView.Adapter() { + override fun getItemCount() = types.size + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + object : androidx.recyclerview.widget.RecyclerView.ViewHolder( + ItemTransferTypeBinding.inflate(layoutInflater, parent, false).root) {} + override fun onBindViewHolder(holder: androidx.recyclerview.widget.RecyclerView.ViewHolder, position: Int) { + val tile = ItemTransferTypeBinding.bind(holder.itemView) + val type = types[position] + tile.ivIcon.setImageResource(type.iconRes) + val badge = type.badgeRes + tile.ivBadge.visibility = if (badge != null) View.VISIBLE else View.GONE + badge?.let { tile.ivBadge.setImageResource(it) } + tile.tvLabel.text = type.label + tile.tvSubtitle.text = type.subtitle + tile.root.setOnClickListener { + transferTypeDialog?.dismiss() + applyTransferType(type) + } + } + } + transferTypeDialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.transfer_type_for, draft.transferTypeNumber)) + .setView(grid) + .setNegativeButton(android.R.string.cancel) { _, _ -> if (transferTypePending) clearRecipient() } + .setCancelable(!transferTypePending) + .setOnDismissListener { transferTypeDialog = null } + .show() + } + + /** Forgets the offered types and closes the picker if it's up. */ + internal fun resetTransferTypes() { + draft.transferTypes = emptyList() + draft.transferTypeNumber = "" + draft.transferType = null + fahipayHandler?.clearState() + transferTypeDialog?.dismiss() + updateTransferButton() + } + + /** Options are on offer but the user hasn't picked one yet. */ + private val transferTypePending: Boolean + get() = draft.transferTypes.isNotEmpty() && draft.transferType == null + + /** + * Applies a picked transfer type: switches the source to one that can pay it, unless the + * selected one already can, then fills the recipient card. + */ + private fun applyTransferType(type: TransferType) { + draft.transferType = type + val number = draft.transferTypeNumber + val source = selectedAccount + if (source == null || !type.worksFrom(source)) { + val accounts = viewModel.accounts.value ?: emptyList() + val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber() + val pick = when (type) { + // The default account when it can do Favara, otherwise leave the user to choose + is TransferType.Favara -> accounts.firstOrNull { it.accountNumber == defaultNum && type.worksFrom(it) } + is TransferType.Fahipay -> accounts.firstOrNull(type::worksFrom) + } + if (pick != null) selectSourceAccount(pick) + else { + clearSourceAccount() + Toast.makeText(requireContext(), R.string.transfer_no_from_account, Toast.LENGTH_SHORT).show() + } + } + when (type) { + is TransferType.Favara -> { + fahipayHandler?.clearState() + showFavaraRecipient(type.info) + } + is TransferType.Fahipay -> fahipayHandler().applyService(type, number) + } + updateTransferButton() + } + // ── Sensitive masking helpers ───────────────────────────────────────────── private fun maskAmount(formatted: String): String { @@ -568,6 +684,16 @@ class TransferFragment : Fragment() { binding.actvFrom.setText("", false) return@setOnItemClickListener } + // A source that can't pay the picked transfer type: drop the pick and its + // recipient card, and ask again for the same number. A lone option would only + // switch the source straight back, so then the recipient goes altogether. + if (draft.transferType?.worksFrom(picked) == false) { + if (draft.transferTypes.size > 1) { + clearRecipient(keepTransferTypes = true) + draft.transferType = null + showTransferTypePicker() + } else clearRecipient() + } selectedAccount = picked updateAmountPrefix(picked) showFromCard(picked) @@ -826,28 +952,18 @@ class TransferFragment : Fragment() { } else false } - binding.btnClearToInfo.setOnClickListener { - bmlHandler().clearQrMerchant() - bmlHandler().clearCardMerchant() - mfaisaHandler().clearQrMerchant() - resolvedAccountNumber = "" - resolvedRecipientName = "" - resolvedDestCurrency = "" - resolvedToOwnAccount = null - fahipayHandler().clearState() - mfaisaHandler?.clearState() - binding.cardToInfo.visibility = View.GONE - binding.tilTo.visibility = View.VISIBLE - binding.btnPickContact.visibility = View.VISIBLE - binding.btnScanQr.visibility = View.VISIBLE - binding.tilTo.error = null - // Re-apply MFAISA-mode if needed (hides QR/contact picker + sets phone keyboard) - applyMfaisaToFieldMode(selectedAccount?.bank == "MFAISA") - updateTransferButton() + binding.btnClearToInfo.setOnClickListener { clearRecipient() } + // Tapping the recipient card changes the transfer type, when the lookup offered more than one + binding.cardToInfo.setOnClickListener { + if (draft.transferTypes.size > 1) showTransferTypePicker() } binding.etTo.addTextChangedListener { binding.tilTo.error = null + // Options looked up for a different number no longer apply + val offeredFor = draft.transferTypeNumber + if (offeredFor.isNotEmpty() && AccountInputParser.normalize(it?.toString().orEmpty().trim()) != offeredFor) + resetTransferTypes() if (binding.cardToInfo.visibility == View.VISIBLE) { resolvedAccountNumber = "" resolvedRecipientName = "" @@ -866,6 +982,32 @@ class TransferFragment : Fragment() { setupContactDropdown() } + /** + * Drops the resolved recipient and brings the "To" field back. The transfer type options go + * too unless [keepTransferTypes] — the source changed under a picked type, and the user + * should be able to choose again for the same number. + */ + private fun clearRecipient(keepTransferTypes: Boolean = false) { + bmlHandler().clearQrMerchant() + bmlHandler().clearCardMerchant() + mfaisaHandler().clearQrMerchant() + resolvedAccountNumber = "" + resolvedRecipientName = "" + resolvedDestCurrency = "" + resolvedToOwnAccount = null + fahipayHandler?.clearState() + if (!keepTransferTypes) resetTransferTypes() + mfaisaHandler?.clearState() + binding.cardToInfo.visibility = View.GONE + binding.tilTo.visibility = View.VISIBLE + binding.btnPickContact.visibility = View.VISIBLE + binding.btnScanQr.visibility = View.VISIBLE + binding.tilTo.error = null + // Re-apply MFAISA-mode if needed (hides QR/contact picker + sets phone keyboard) + applyMfaisaToFieldMode(selectedAccount?.bank == "MFAISA") + updateTransferButton() + } + /** * Switches this screen into BML merchant-QR mode, keeping a selected BML card as the source. * Done in place: reopening the screen for it rebuilt the whole form and made the To row @@ -969,6 +1111,15 @@ class TransferFragment : Fragment() { } private fun lookupAccount() { + val accountNumber = AccountInputParser.normalize(binding.etTo.text?.toString()?.trim() ?: "") + + // No source yet and a phone number: look it up every way it could be paid and let the + // user pick the transfer type, which then picks the source + if (selectedAccount == null && AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) { + lookupPhoneForAnySource(accountNumber) + return + } + if (selectedAccount == null) { val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber() if (defaultNum != null) { @@ -986,7 +1137,6 @@ class TransferFragment : Fragment() { return } } - val accountNumber = AccountInputParser.normalize(binding.etTo.text?.toString()?.trim() ?: "") if (accountNumber.isBlank()) { Toast.makeText(requireContext(), R.string.transfer_enter_account_first, Toast.LENGTH_SHORT).show() return @@ -1013,100 +1163,147 @@ class TransferFragment : Fragment() { return } - val mibSess = mibHandler.session - val bmlSess = bmlSessionFor(selectedAccount) - if (mibSess == null && bmlSess == null) { + if (mibHandler.session == null && bmlSessionFor(selectedAccount) == null) { Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() return } - val isBmlSource = selectedAccount?.bank == "BML" + val preferBml = selectedAccount?.bank == "BML" startLookupLoading() - - // Both banks can resolve a destination, so whichever the source belongs to gets first - // try and the other is the fallback. Either way the result lands in MibIpsAccountInfo. viewLifecycleOwner.lifecycleScope.launch { - var errorMsg: String? = null - val info = withContext(Dispatchers.IO) { - if (isBmlSource && bmlSess != null) { - val isMibDest = AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.MIB_ACCOUNT - val bmlInfo = bmlHandler().validateDestination(bmlSess, accountNumber, verifyAsMib = isMibDest) - if (bmlInfo != null) { - // BML's MIB verify endpoint doesn't return the account's currency. - // Enrich via MIB lookup when a MIB session is available. - if (isMibDest && bmlInfo.currency.isBlank() && mibSess != null) - bmlInfo.copy(currency = mibHandler.lookupCurrency(bmlInfo.accountNumber)) - else bmlInfo - } else { - val outcome = mibHandler.lookupDestination(accountNumber) - errorMsg = outcome.error - outcome.info - } - } else { - val outcome = mibHandler.lookupDestination(accountNumber) - errorMsg = outcome.error - if (outcome.info != null) { - outcome.info - } else { - val bmlInfo = bmlSess?.let { - bmlHandler().validateDestination(it, accountNumber, verifyAsMib = false) - } - if (bmlInfo != null) errorMsg = null - else if (errorMsg == null) errorMsg = getString(R.string.transfer_account_not_found) - bmlInfo - } + val (info, errorMsg) = withContext(Dispatchers.IO) { lookupFavara(accountNumber, preferBml) } + stopLookupLoading() + if (info != null) showFavaraRecipient(info) + else Toast.makeText(requireContext(), errorMsg, Toast.LENGTH_SHORT).show() + } + } + + /** + * A phone number searched before any source is picked. Runs the Favara lookup (through MIB + * or BML, whichever is logged in) and, when the user has a Fahipay wallet, the Dhiraagu / + * Ooredoo carrier lookup — in parallel — and offers whatever came back as transfer types. + */ + private fun lookupPhoneForAnySource(number: String) { + val accounts = viewModel.accounts.value ?: emptyList() + val hasFahipay = accounts.any { it.bank == "FAHIPAY" } + val hasFavara = mibHandler.session != null || bmlSessionFor(null) != null + if (!hasFavara && !hasFahipay) { + Toast.makeText(requireContext(), R.string.transfer_no_from_account, Toast.LENGTH_SHORT).show() + return + } + // Favara goes through the default account's bank first, as it would once that's the source + val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber() + val preferBml = accounts.firstOrNull { it.accountNumber == defaultNum }?.bank == "BML" + val fahipay = fahipayHandler() + + resetTransferTypes() + startLookupLoading() + viewLifecycleOwner.lifecycleScope.launch { + val (favara, fahipayTypes) = withContext(Dispatchers.IO) { + coroutineScope { + val favara = async { if (hasFavara) lookupFavara(number, preferBml) else null to null } + val fahipayTypes = async { if (hasFahipay) fahipay.lookupServices(number) else emptyList() } + favara.await() to fahipayTypes.await() } } stopLookupLoading() - if (info != null) { - val accounts = viewModel.accounts.value ?: emptyList() - val matchedAcc = accounts.firstOrNull { it.accountNumber == info.accountNumber } - val matchedCont = contacts.firstOrNull { it.benefAccount == info.accountNumber } - - val displayName = matchedAcc?.accountBriefName ?: matchedCont?.benefNickName ?: info.accountName - val colorHex = if (matchedAcc != null) "#FE860E" else matchedCont?.bankColor ?: "#607D8B" - - resolvedAccountNumber = info.accountNumber - resolvedRecipientName = info.accountName - resolvedBankName = info.bankId - resolvedDestCurrency = info.currency - savedToSubtitle = listOfNotNull(info.accountNumber, info.bankId, info.currency.takeIf { it.isNotBlank() }) - .joinToString(" · ") - savedToColorHex = colorHex - savedToImageHash = when { - matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash - matchedCont?.customerImgHash != null -> matchedCont.customerImgHash - else -> null - } - - if (matchedAcc != null) { - showToCard(matchedAcc) - } else { - binding.tvToAccountName.text = displayName - binding.tvToBankBic.text = savedToSubtitle - binding.tvToAccountDetails.visibility = View.GONE - binding.tvToBalance.visibility = View.GONE - binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP - binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex)) - } - binding.tilTo.visibility = View.GONE - binding.btnPickContact.visibility = View.GONE - binding.btnScanQr.visibility = View.GONE - binding.cardToInfo.visibility = View.VISIBLE - updateTransferButton() - saveToRecents(info) - focusAmount() - - when { - matchedAcc?.profileImageHash != null -> - loadToPhoto(matchedAcc.profileImageHash, isProfile = true) - matchedCont?.customerImgHash != null -> - loadToPhoto(matchedCont.customerImgHash, isProfile = false) - } - } else { - Toast.makeText(requireContext(), errorMsg, Toast.LENGTH_SHORT).show() + val types = listOfNotNull(favara.first?.let { TransferType.Favara(it) }) + fahipayTypes + if (types.isEmpty()) { + Toast.makeText(requireContext(), favara.second ?: getString(R.string.transfer_account_not_found), Toast.LENGTH_SHORT).show() + return@launch } + offerTransferTypes(number, types) + } + } + + /** + * Favara / IPS lookup of [accountNumber], returning the resolved recipient or the error to + * show. Both banks can resolve a destination, so [preferBml] decides which gets first try + * and the other is the fallback. Either way the result lands in MibIpsAccountInfo. + * Blocking — call from IO. + */ + private fun lookupFavara(accountNumber: String, preferBml: Boolean): Pair { + val mibSess = mibHandler.session + val bmlSess = bmlSessionFor(selectedAccount) + var errorMsg: String? = null + val info = if (preferBml && bmlSess != null) { + val isMibDest = AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.MIB_ACCOUNT + val bmlInfo = bmlHandler().validateDestination(bmlSess, accountNumber, verifyAsMib = isMibDest) + if (bmlInfo != null) { + // BML's MIB verify endpoint doesn't return the account's currency. + // Enrich via MIB lookup when a MIB session is available. + if (isMibDest && bmlInfo.currency.isBlank() && mibSess != null) + bmlInfo.copy(currency = mibHandler.lookupCurrency(bmlInfo.accountNumber)) + else bmlInfo + } else { + val outcome = mibHandler.lookupDestination(accountNumber) + errorMsg = outcome.error + outcome.info + } + } else { + val outcome = mibHandler.lookupDestination(accountNumber) + errorMsg = outcome.error + if (outcome.info != null) { + outcome.info + } else { + val bmlInfo = bmlSess?.let { + bmlHandler().validateDestination(it, accountNumber, verifyAsMib = false) + } + if (bmlInfo != null) errorMsg = null + else if (errorMsg == null) errorMsg = getString(R.string.transfer_account_not_found) + bmlInfo + } + } + return info to errorMsg + } + + /** Fills the recipient card with a Favara / IPS lookup result. */ + private fun showFavaraRecipient(info: MibIpsAccountInfo) { + val accounts = viewModel.accounts.value ?: emptyList() + val contacts = viewModel.contacts.value ?: emptyList() + val matchedAcc = accounts.firstOrNull { it.accountNumber == info.accountNumber } + val matchedCont = contacts.firstOrNull { it.benefAccount == info.accountNumber } + + val displayName = matchedAcc?.accountBriefName ?: matchedCont?.benefNickName ?: info.accountName + val colorHex = if (matchedAcc != null) "#FE860E" else matchedCont?.bankColor ?: "#607D8B" + + resolvedAccountNumber = info.accountNumber + resolvedRecipientName = info.accountName + resolvedBankName = info.bankId + resolvedDestCurrency = info.currency + savedToSubtitle = listOfNotNull(info.accountNumber, info.bankId, info.currency.takeIf { it.isNotBlank() }) + .joinToString(" · ") + savedToColorHex = colorHex + savedToImageHash = when { + matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash + matchedCont?.customerImgHash != null -> matchedCont.customerImgHash + else -> null + } + + if (matchedAcc != null) { + showToCard(matchedAcc) + } else { + binding.tvToAccountName.text = displayName + binding.tvToBankBic.text = savedToSubtitle + binding.tvToAccountDetails.visibility = View.GONE + binding.tvToBalance.visibility = View.GONE + binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP + binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex)) + } + binding.tilTo.visibility = View.GONE + binding.btnPickContact.visibility = View.GONE + binding.btnScanQr.visibility = View.GONE + binding.cardToInfo.visibility = View.VISIBLE + updateTransferButton() + saveToRecents(info) + focusAmount() + + when { + matchedAcc?.profileImageHash != null -> + loadToPhoto(matchedAcc.profileImageHash, isProfile = true) + matchedCont?.customerImgHash != null -> + loadToPhoto(matchedCont.customerImgHash, isProfile = false) } } @@ -1696,7 +1893,7 @@ class TransferFragment : Fragment() { 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 + val hasAll = selectedAccount != null && recipientReady && amount > 0 && !transferTypePending if (!hasAll) { binding.btnTransfer.isEnabled = false; return } val errors = viewModel.connectivityErrors.value ?: emptySet() val bankOffline = "NO_INTERNET" in errors || @@ -1726,7 +1923,7 @@ class TransferFragment : Fragment() { resolvedBankName = "" resolvedDestCurrency = "" resolvedToOwnAccount = null - fahipayHandler().clearState() + resetTransferTypes() binding.cardToInfo.visibility = View.GONE binding.tilTo.visibility = View.VISIBLE binding.btnPickContact.visibility = View.VISIBLE @@ -1827,6 +2024,10 @@ class TransferFragment : Fragment() { bmlHandler = null fahipayHandler = null mfaisaHandler = null + // Re-shown from the draft by the next view if still unanswered + transferTypeDialog?.setOnDismissListener(null) + transferTypeDialog?.dismiss() + transferTypeDialog = null // Unregistered automatically with viewLifecycleOwner; drop the stale handle. qrLauncher = null _binding = null diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/FahipayTransferHandler.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/FahipayTransferHandler.kt index d32792b..e66a866 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/FahipayTransferHandler.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/FahipayTransferHandler.kt @@ -1,6 +1,6 @@ package sh.sar.basedbank.ui.home.transfer -import android.view.View +import androidx.annotation.DrawableRes import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -15,24 +15,28 @@ import sh.sar.basedbank.util.AccountInputParser /** * A service a Fahipay wallet can pay out to. The carrier lookup decides which of these apply to - * a given number; [label] names it in the recipient card, [destinationLabel] in the confirm - * dialog's "To" block. + * a given number; [label] names it in the "Transfer Type" picker and the recipient card, + * [destinationLabel] in the confirm dialog's "To" block. * * Wallet-to-wallet Fahipay transfer is not here yet — there is no send path for it (see the * class KDoc on [FahipayTransferHandler]). Add it as a constant once that lands, and the * exhaustive `when`s over this enum will point at every site that needs updating. */ -enum class FahipayService(val label: String, val destinationLabel: String) { - RAASTAS("Raastas", "Ooredoo · Raastas"), - OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay"), - DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload"), - DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay"), +enum class FahipayService( + val label: String, + val destinationLabel: String, + @param:DrawableRes val iconRes: Int, +) { + 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), } /** * Owns the Fahipay-only parts of the Transfer screen: the carrier lookup that turns a phone - * number into a set of payable services, the chip picker shown when more than one applies, and - * the selected service that the confirm dialog labels the destination with. + * number into a set of payable services, offering those in the fragment's "Transfer Type" + * picker, and the selected service that the confirm dialog labels the destination with. * * Mirrors [BmlTransferHandler] / [MfaisaTransferHandler]: the fragment keeps the shared confirm * dialog, the recipient card and the form state; the handler keeps everything Fahipay-specific. @@ -75,10 +79,39 @@ class FahipayTransferHandler( lookupCarrier(rawInput) } - /** Clears the selected service and hides the chip picker. */ + /** Forgets the selected service. The picker itself is the fragment's to reset. */ fun clearState() { service = null - binding.layoutServiceSelector.visibility = View.INVISIBLE + } + + /** + * Fills the recipient card for a picked Fahipay transfer type. The fragment has already + * switched the source to a Fahipay account. + */ + fun applyService(type: TransferType.Fahipay, number: String) { + service = type.service + val contacts = viewModel.contacts.value ?: emptyList() + val displayName = type.ownerName + ?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName + ?: number + fragment.prefillToDirectly( + accountNumber = number, + displayName = displayName, + subtitle = "${type.label} · $number", + colorHex = "#FF6B00", + imageHash = null + ) + fragment.focusAmount() + } + + /** + * The Fahipay transfer types [number] can be paid with, from the carrier lookup. Dhiraagu is + * the only carrier that hands back an owner name. Blocking — call from IO. + */ + fun lookupServices(number: String): List { + val result = queryCarriers(number) + val ownerName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() } + return servicesFor(result).map { TransferType.Fahipay(it, ownerName) } } // ─── Carrier lookup ────────────────────────────────────────────────────── @@ -89,24 +122,13 @@ class FahipayTransferHandler( ) private fun lookupCarrier(number: String) { + fragment.resetTransferTypes() fragment.startLookupLoading() fragment.viewLifecycleOwner.lifecycleScope.launch { - val result = withContext(Dispatchers.IO) { queryCarriers(number) } + val types = withContext(Dispatchers.IO) { lookupServices(number) } fragment.stopLookupLoading() - - val dhiraaguName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() } - val services = servicesFor(result) - - if (services.isEmpty()) return@launch - - // Only one option — auto-select, no chip UI needed - if (services.size == 1) { - selectService(services[0], number, dhiraaguName) - return@launch - } - - // Multiple options (Ooredoo HYBRID) — show chips - showServiceChips(services, number, dhiraaguName) + if (types.isEmpty()) return@launch + fragment.offerTransferTypes(number, types) } } @@ -143,58 +165,4 @@ 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) } - - // ─── Service picker ────────────────────────────────────────────────────── - - private fun showServiceChips( - services: List, - number: String, - dhiraaguName: String? - ) { - binding.chipDhiraaguReload.visibility = visibilityFor(FahipayService.DHIRAAGU_RELOAD in services) - binding.chipDhiraaguBill.visibility = visibilityFor(FahipayService.DHIRAAGU_BILL in services) - binding.chipRaastas.visibility = visibilityFor(FahipayService.RAASTAS in services) - binding.chipOoredooBill.visibility = visibilityFor(FahipayService.OOREDOO_BILL in services) - binding.layoutServiceSelector.visibility = View.VISIBLE - binding.chipGroupService.clearCheck() - - // Dhiraagu is the only carrier that hands back an owner name, so the Ooredoo chips - // resolve their display name from saved contacts instead. - bindChip(binding.chipDhiraaguReload, FahipayService.DHIRAAGU_RELOAD, number, dhiraaguName) - bindChip(binding.chipDhiraaguBill, FahipayService.DHIRAAGU_BILL, number, dhiraaguName) - bindChip(binding.chipRaastas, FahipayService.RAASTAS, number, null) - bindChip(binding.chipOoredooBill, FahipayService.OOREDOO_BILL, number, null) - } - - private fun bindChip( - chip: com.google.android.material.chip.Chip, - picked: FahipayService, - number: String, - ownerName: String? - ) { - chip.setOnCheckedChangeListener { _, checked -> - if (checked) { - selectService(picked, number, ownerName) - binding.layoutServiceSelector.visibility = View.INVISIBLE - } - } - } - - private fun visibilityFor(shown: Boolean) = if (shown) View.VISIBLE else View.GONE - - private fun selectService(picked: FahipayService, number: String, ownerName: String?) { - service = picked - val contacts = viewModel.contacts.value ?: emptyList() - val displayName = ownerName - ?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName - ?: number - fragment.prefillToDirectly( - accountNumber = number, - displayName = displayName, - subtitle = "${picked.label} · $number", - colorHex = "#FF6B00", - imageHash = null - ) - fragment.focusAmount() - } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/TransferDraft.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/TransferDraft.kt index f2dce1b..cf7f784 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/TransferDraft.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/TransferDraft.kt @@ -50,6 +50,11 @@ class TransferDraft { var mfaisaRecipient: MfaisaTransferClient.Recipient? = null var mfaisaQrInfo: MfaisaQrPayClient.QrMerchant? = null + // Transfer type — options the "To" lookup offered for [transferTypeNumber], and the pick + var transferTypes: List = emptyList() + var transferTypeNumber = "" + var transferType: TransferType? = null + // Fahipay var fahipayService: FahipayService? = null } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/TransferType.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/TransferType.kt new file mode 100644 index 0000000..57363da --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/TransferType.kt @@ -0,0 +1,42 @@ +package sh.sar.basedbank.ui.home.transfer + +import androidx.annotation.DrawableRes +import sh.sar.basedbank.R +import sh.sar.basedbank.api.mib.MibIpsAccountInfo +import sh.sar.basedbank.api.models.BankAccount + +/** + * One option in the Transfer screen's "Transfer Type" picker: a way of paying the number in + * the "To" field. Picking one also decides the source account — see [worksFrom]. + * + * Add new kinds (BML verified card, …) as subclasses; the exhaustive `when`s over this type + * point at every site that needs updating. + */ +sealed interface TransferType { + val label: String + /** Second line in the picker: who gets paid, and how. */ + val subtitle: String + @get:DrawableRes val iconRes: Int + /** Small logo on the icon's bottom corner — the wallet that pays it — or null for none. */ + @get:DrawableRes val badgeRes: Int? get() = null + + /** Whether [account] can be the source for this transfer type. */ + fun worksFrom(account: BankAccount): Boolean + + /** A bank transfer to the account the Favara ID (phone number) resolved to. */ + data class Favara(val info: MibIpsAccountInfo) : TransferType { + override val label get() = "Favara Transfer" + override val subtitle get() = info.accountName + override val iconRes get() = R.drawable.favara_logo + override fun worksFrom(account: BankAccount) = account.bank == "MIB" || account.bank == "BML" + } + + /** A Fahipay payout (reload, Raastas, bill pay) to the phone number. */ + data class Fahipay(val service: FahipayService, val ownerName: String?) : TransferType { + override val label get() = service.label + override val subtitle get() = listOfNotNull(ownerName, "via Fahipay").joinToString(" · ") + override val iconRes get() = service.iconRes + override val badgeRes get() = R.drawable.fahipay_logo + override fun worksFrom(account: BankAccount) = account.bank == "FAHIPAY" + } +} diff --git a/app/src/main/res/drawable/dhiraagu_logo.xml b/app/src/main/res/drawable/dhiraagu_logo.xml new file mode 100644 index 0000000..9480763 --- /dev/null +++ b/app/src/main/res/drawable/dhiraagu_logo.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/dhiraagu_long.xml b/app/src/main/res/drawable/dhiraagu_long.xml new file mode 100644 index 0000000..8a915d1 --- /dev/null +++ b/app/src/main/res/drawable/dhiraagu_long.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/favara_logo.xml b/app/src/main/res/drawable/favara_logo.xml new file mode 100644 index 0000000..ffad9af --- /dev/null +++ b/app/src/main/res/drawable/favara_logo.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/favara_long.xml b/app/src/main/res/drawable/favara_long.xml new file mode 100644 index 0000000..6e53ffc --- /dev/null +++ b/app/src/main/res/drawable/favara_long.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_transfer.xml b/app/src/main/res/layout/fragment_transfer.xml index d0a5a61..2e61e6c 100644 --- a/app/src/main/res/layout/fragment_transfer.xml +++ b/app/src/main/res/layout/fragment_transfer.xml @@ -266,66 +266,6 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b8cd5b4..3c2f13b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -261,7 +261,7 @@ Account Number or Favara ID From To - Select Service + Transfer type for %1$s Fahipay transfers require a 7-digit phone number My Accounts This is the same account as the sender diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index d58d0ad..d8cb657 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,9 @@