diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5972710..db64f91 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -32,6 +32,7 @@ @@ -45,20 +46,24 @@ @@ -69,6 +74,7 @@ diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt index 8397dfc..089172b 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt @@ -25,7 +25,9 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -104,6 +106,57 @@ class HomeActivity : AppCompatActivity() { if (securitySet) lock() } + // ── Payment guard ───────────────────────────────────────────────────────── + // + // The manifest has this activity handle theme, language, font-size and display-size changes + // itself, because recreating it mid-payment tears down the screen waiting on the bank's + // answer — the money can move with nothing left to say so. Those changes still need a + // recreate to re-inflate with the new resources, so it runs straight away when nothing is in + // flight and otherwise waits until the last payment finishes. + + private var paymentsInFlight = 0 + private var recreatePending = false + private var lastConfig: Configuration? = null + + /** A payment in flight; [end] it once the outcome is on screen. Ending twice is harmless. */ + inner class PaymentGuard internal constructor() { + private var ended = false + fun end() { + if (ended) return + ended = true + paymentsInFlight-- + if (paymentsInFlight == 0 && recreatePending) { + recreatePending = false + // Posted so a receipt screen committed in the same pass is saved with the state + binding.root.post { recreate() } + } + } + } + + /** Holds off recreation until the guard ends, or [owner] is destroyed, whichever is first. */ + fun beginPayment(owner: LifecycleOwner): PaymentGuard { + paymentsInFlight++ + val guard = PaymentGuard() + owner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) = guard.end() + }) + return guard + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + val previous = lastConfig + lastConfig = Configuration(newConfig) + // Size and orientation changes are handled in place; these need fresh resources. + val needsRecreate = android.content.pm.ActivityInfo.CONFIG_UI_MODE or + android.content.pm.ActivityInfo.CONFIG_LOCALE or + android.content.pm.ActivityInfo.CONFIG_LAYOUT_DIRECTION or + android.content.pm.ActivityInfo.CONFIG_FONT_SCALE or + android.content.pm.ActivityInfo.CONFIG_DENSITY + if (previous == null || (previous.diff(newConfig) and needsRecreate) == 0) return + if (paymentsInFlight > 0) recreatePending = true else recreate() + } + fun lockApp() = lock() fun notifyWheelLockTap() { @@ -136,6 +189,7 @@ class HomeActivity : AppCompatActivity() { window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE) } setContentView(binding.root) + lastConfig = Configuration(resources.configuration) val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO WindowCompat.getInsetsController(window, window.decorView).apply { isAppearanceLightStatusBars = isLight diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt index ffac7a9..d5d4a5e 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt @@ -56,4 +56,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) { * for HTTP 5xx server errors from specific banks. */ val connectivityErrors = MutableLiveData>(emptySet()) + + /** The Transfer screen's form, kept here so tab switches and recreation don't lose it. */ + var transferDraft = sh.sar.basedbank.ui.home.transfer.TransferDraft() } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt index 83ae66c..34d0cf8 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt @@ -123,15 +123,11 @@ class CardsFragment : Fragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val screenW = resources.displayMetrics.widthPixels - val peekPx = screenW / 8 - cardWidth = screenW - 2 * peekPx - - stackAdapter = CardStackAdapter(cardWidth) + stackAdapter = CardStackAdapter() binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false) binding.rvCards.adapter = stackAdapter - binding.rvCards.setPadding(peekPx, 0, peekPx, 0) binding.rvCards.clipToPadding = false + applyCarouselWidth() val snapHelper = PagerSnapHelper() snapHelper.attachToRecyclerView(binding.rvCards) @@ -637,8 +633,13 @@ class CardsFragment : Fragment() { // ── Tap-to-pay mode ──────────────────────────────────────────────────────── + /** Held while tap mode is up: recreating the activity would clear the NFC payment token. */ + private var tapGuard: HomeActivity.PaymentGuard? = null + private fun setTapMode(enabled: Boolean, item: CardItem.Bml? = null) { isTapMode = enabled + tapGuard?.end() + tapGuard = if (enabled) (activity as? HomeActivity)?.beginPayment(viewLifecycleOwner) else null requireActivity().title = getString(if (enabled) R.string.card_pay_nfc else R.string.nav_pay_with_card) if (enabled) enterTapMode(item!!) else exitTapMode() } @@ -959,6 +960,25 @@ class CardsFragment : Fragment() { } } + /** Sizes the carousel from the window width: each card leaves a 1/8 peek on either side. */ + private fun applyCarouselWidth() { + val screenW = resources.displayMetrics.widthPixels + val peekPx = screenW / 8 + cardWidth = screenW - 2 * peekPx + binding.rvCards.setPadding(peekPx, 0, peekPx, 0) + } + + // HomeActivity handles size changes itself (rotation, split screen) rather than being + // recreated, so the carousel has to re-measure for the new width on its own. + override fun onConfigurationChanged(newConfig: android.content.res.Configuration) { + super.onConfigurationChanged(newConfig) + if (_binding == null) return + applyCarouselWidth() + stackAdapter.notifyDataSetChanged() + binding.rvCards.scrollToPosition(currentCardPosition) + binding.rvCards.post { if (_binding != null) applyCardScales() } + } + private fun applyCardScales() { val rv = binding.rvCards val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f @@ -1055,7 +1075,7 @@ class CardsFragment : Fragment() { _binding = null } - private inner class CardStackAdapter(private val cardWidth: Int) : RecyclerView.Adapter() { + private inner class CardStackAdapter : RecyclerView.Adapter() { private var items: List = emptyList() fun update(newItems: List) { @@ -1070,6 +1090,8 @@ class CardsFragment : Fragment() { override fun onBindViewHolder(holder: VH, position: Int) { holder.bind(items[position]) + // Re-applied on every bind so a width change reaches recycled holders too + holder.itemView.layoutParams.width = cardWidth // Pre-scale based on data position so initial render and off-screen cards are correct val fraction = abs(position - currentCardPosition).toFloat().coerceIn(0f, 1f) val scale = 1f - 0.18f * fraction 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 6e40c2d..d24a877 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 @@ -51,6 +51,7 @@ 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.util.AccountListParser import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.AccountInputParser @@ -67,7 +68,16 @@ class TransferFragment : Fragment() { private val binding get() = _binding!! private val viewModel: HomeViewModel by activityViewModels() - private var selectedAccount: BankAccount? = null + /** + * The form lives on the activity's ViewModel (see [TransferDraft]) so a tab switch or a + * theme/language recreation repaints it rather than starting over. The properties below are + * the fragment's view of it. + */ + private val draft: TransferDraft get() = viewModel.transferDraft + + private var selectedAccount: BankAccount? + get() = draft.selectedAccount + set(value) { draft.selectedAccount = value } private fun bmlSessionFor(account: BankAccount?) = bmlHandler().sessionFor(account) /** @@ -78,21 +88,36 @@ class TransferFragment : Fragment() { private val mibHandler by lazy { MibTransferHandler(this) { selectedAccount } } // Resolved recipient info — set after successful lookup or prefill - 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 + private var resolvedAccountNumber: String + get() = draft.resolvedAccountNumber + set(value) { draft.resolvedAccountNumber = value } + private var resolvedRecipientName: String + get() = draft.resolvedRecipientName + set(value) { draft.resolvedRecipientName = value } + private var resolvedBankName: String + get() = draft.resolvedBankName + set(value) { draft.resolvedBankName = value } + private var loadedToPhoto: Bitmap? + get() = draft.loadedToPhoto + set(value) { draft.loadedToPhoto = value } + private var resolvedDestCurrency: String + get() = draft.resolvedDestCurrency + set(value) { draft.resolvedDestCurrency = value } + private var resolvedToOwnAccount: BankAccount? + get() = draft.resolvedToOwnAccount + set(value) { draft.resolvedToOwnAccount = value } + private var savedToSubtitle: String + get() = draft.toSubtitle + set(value) { draft.toSubtitle = value } + private var savedToColorHex: String + get() = draft.toColorHex + set(value) { draft.toColorHex = value } + private var savedToImageHash: String? + get() = draft.toImageHash + set(value) { draft.toImageHash = value } - // Form state preserved across view destroy/create when the fragment instance is cached - private var savedAmount = "" - private var savedRemarks = "" - private var savedToText = "" - private var savedToSubtitle = "" - private var savedToColorHex = "#607D8B" - private var savedToImageHash: String? = null + /** Set when this view applied the fragment's arguments, so the accounts observer may too. */ + private var argsAppliedThisView = false private val dropdownProfileImageCache = mutableMapOf() @@ -112,6 +137,7 @@ class TransferFragment : Fragment() { viewModel = viewModel, currentSource = { selectedAccount }, selectSource = ::selectSourceAccount, + clearSource = ::clearSourceAccount, onStateChanged = { updateTransferButton() }, onTransferSuccess = { receipt, avatar -> ReceiptStore.save(requireContext(), receipt) @@ -253,6 +279,8 @@ class TransferFragment : Fragment() { private const val ARG_BML_QR_URL = "bml_qr_url" private const val ARG_AUTO_SCAN = "auto_scan" private const val ARG_BML_TXN_ID = "bml_txn_id" + /** Set once the arguments have been applied, so later views restore the draft instead. */ + private const val ARG_APPLIED = "args_applied" fun newInstanceWithAutoScan() = TransferFragment().apply { arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) } @@ -315,6 +343,16 @@ class TransferFragment : Fragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + // A screen opened with its own arguments (scanned QR, picked contact…) starts a fresh + // draft, once. Every later view — tab switch back, theme recreation — restores instead. + // The flag lives in the arguments so it survives the fragment being recreated too. + val args = arguments + argsAppliedThisView = args != null && !args.getBoolean(ARG_APPLIED, false) + if (argsAppliedThisView) { + viewModel.transferDraft = TransferDraft() + args!!.putBoolean(ARG_APPLIED, true) + } + qrLauncher = requireActivity().activityResultRegistry.register( qrLauncherKey, viewLifecycleOwner, ActivityResultContracts.StartActivityForResult() ) { onQrScanned(it) } @@ -378,6 +416,19 @@ class TransferFragment : Fragment() { binding.etAmount.addTextChangedListener { updateTransferButton() } + if (argsAppliedThisView) applyArguments() + } + + override fun onViewStateRestored(savedInstanceState: Bundle?) { + super.onViewStateRestored(savedInstanceState) + // Repaint here, not in onViewCreated: after a recreation the framework restores the + // EditTexts' old text in between, and that setText on the To field fires its "user + // edited the recipient" listener — which would hide a To card painted earlier. + if (!argsAppliedThisView) restoreFromDraft() + } + + /** First view of a screen opened with arguments: prefill from them. */ + private fun applyArguments() { // Pre-select contact if navigated from contacts page or QR scan arguments?.getString(ARG_ACCOUNT)?.let { account -> prefillToDirectly( @@ -391,7 +442,7 @@ class TransferFragment : Fragment() { arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) } arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) } - arguments?.getString(ARG_BML_QR_URL)?.let { bmlHandler().lookupQrMerchant(it) } + arguments?.getString(ARG_BML_QR_URL)?.let { openBmlQr(it) } arguments?.getString(ARG_BML_TXN_ID)?.let { // Shown in the To field so a failed lookup leaves the ID there to retry or correct. binding.etTo.setText(it) @@ -401,10 +452,24 @@ class TransferFragment : Fragment() { if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) { launchQrScanner() } + } - // Restore form state when view is recreated on the cached no-args instance - if (arguments == null) { - if (resolvedAccountNumber.isNotEmpty()) { + /** + * Repaints a recreated view from [draft] — nothing is looked up again. The From card is + * repainted by the accounts observer; a BML QR lookup that never finished is retried there. + */ + private fun restoreFromDraft() { + // Amount first: a dynamic merchant QR overwrites and locks it below + if (draft.amount.isNotEmpty()) binding.etAmount.setText(draft.amount) + if (draft.remarks.isNotEmpty()) binding.etRemarks.setText(draft.remarks) + val bmlQr = draft.bmlQrInfo + val mfaisaQr = draft.mfaisaQrInfo + val mfaisaRecipient = draft.mfaisaRecipient + when { + bmlQr != null -> bmlHandler().showQrMerchant(bmlQr) + mfaisaQr != null -> mfaisaHandler().showQrMerchant(mfaisaQr) + mfaisaRecipient != null -> mfaisaHandler().showResolvedRecipient(mfaisaRecipient, saveRecent = false) + resolvedAccountNumber.isNotEmpty() -> { val ownAccount = viewModel.accounts.value?.firstOrNull { it.accountNumber == resolvedAccountNumber } if (ownAccount != null) { showToCard(ownAccount) @@ -420,22 +485,17 @@ class TransferFragment : Fragment() { binding.btnPickContact.visibility = View.GONE binding.btnScanQr.visibility = View.GONE binding.cardToInfo.visibility = View.VISIBLE - if (savedToImageHash != null) loadToPhoto(savedToImageHash!!, isProfile = resolvedToOwnAccount != null) - } else if (savedToText.isNotEmpty()) { - binding.etTo.setText(savedToText, false) + val photo = loadedToPhoto + if (photo != null) { + binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP + binding.ivToPhoto.setImageBitmap(photo) + } else { + savedToImageHash?.let { loadToPhoto(it, isProfile = resolvedToOwnAccount != null) } + } } - if (savedAmount.isNotEmpty()) binding.etAmount.setText(savedAmount) - if (savedRemarks.isNotEmpty()) binding.etRemarks.setText(savedRemarks) - updateTransferButton() + draft.toText.isNotEmpty() -> binding.etTo.setText(draft.toText, false) } - } - - /** Restores the To-input row to its default state when a QR lookup fails. */ - internal fun resetToFieldVisibility() { - binding.cardToInfo.visibility = View.GONE - binding.tilTo.visibility = View.VISIBLE - binding.btnPickContact.visibility = View.VISIBLE - binding.btnScanQr.visibility = View.VISIBLE + updateTransferButton() } internal fun startLookupLoading() { @@ -462,14 +522,7 @@ class TransferFragment : Fragment() { } private fun setupFromDropdown() { - binding.btnClearFromInfo.setOnClickListener { - selectedAccount = null - binding.tilAmount.prefixText = null - binding.cardFromInfo.visibility = View.GONE - binding.tilFrom.visibility = View.VISIBLE - binding.actvFrom.setText("", false) - updateTransferButton() - } + binding.btnClearFromInfo.setOnClickListener { clearSourceAccount() } viewModel.accounts.observe(viewLifecycleOwner) { accounts -> accountDropdownAdapter = AccountDropdownAdapter(requireContext(), accounts) @@ -496,7 +549,7 @@ class TransferFragment : Fragment() { updateTransferButton() } - val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT) + val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)?.takeIf { argsAppliedThisView } if (fromNumber != null && selectedAccount == null) { val match = accounts.firstOrNull { it.accountNumber == fromNumber } if (match != null) { @@ -508,7 +561,7 @@ class TransferFragment : Fragment() { } // Auto-select default account when arriving from contacts page (TO account already pre-filled) - if (selectedAccount == null && arguments?.getString(ARG_ACCOUNT) != null) { + if (selectedAccount == null && argsAppliedThisView && arguments?.getString(ARG_ACCOUNT) != null) { val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber() if (defaultNum != null) { val defaultAcc = accounts.firstOrNull { it.accountNumber == defaultNum } @@ -522,12 +575,9 @@ class TransferFragment : Fragment() { } // On a cold start (e.g. share intent), anyBmlSession() may be null when - // onViewCreated runs. Retry the lookup once sessions are available. - val pendingBmlQrUrl = arguments?.getString(ARG_BML_QR_URL) - if (pendingBmlQrUrl != null && !bmlHandler().qrLookupAttempted) { - val app = requireActivity().application as BasedBankApp - if (app.anyBmlSession() != null) bmlHandler().lookupQrMerchant(pendingBmlQrUrl) - } + // onViewCreated runs; a lookup can also have been cut off by leaving the tab. + // Retry it once sessions are available. + draft.pendingBmlQrTarget?.let { bmlHandler().lookupQrMerchant(it) } // Re-render the from card when the view is recreated on a cached instance if (selectedAccount != null && binding.cardFromInfo.visibility != View.VISIBLE) { @@ -544,7 +594,7 @@ class TransferFragment : Fragment() { binding.tilTo.hint = getString(R.string.ooredoo_phone) binding.etTo.inputType = android.text.InputType.TYPE_CLASS_PHONE // Any previously-resolved non-MFAISA recipient (or stale state) is no longer valid - if (resolvedAccountNumber.isNotBlank() && mfaisaHandler?.recipient == null) { + if (resolvedAccountNumber.isNotBlank() && draft.mfaisaRecipient == null) { resolvedAccountNumber = "" resolvedRecipientName = "" resolvedDestCurrency = "" @@ -557,8 +607,8 @@ class TransferFragment : Fragment() { binding.tilTo.hint = getString(R.string.transfer_to) binding.etTo.inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS // Drop any M-Faisa-resolved recipient when switching banks - if (mfaisaHandler?.recipient != null) { - mfaisaHandler?.clearState() + if (draft.mfaisaRecipient != null) { + mfaisaHandler().clearState() resolvedAccountNumber = "" resolvedRecipientName = "" resolvedDestCurrency = "" @@ -729,6 +779,15 @@ class TransferFragment : Fragment() { updateTransferButton() } + private fun clearSourceAccount() { + selectedAccount = null + binding.tilAmount.prefixText = null + binding.cardFromInfo.visibility = View.GONE + binding.tilFrom.visibility = View.VISIBLE + binding.actvFrom.setText("", false) + updateTransferButton() + } + private fun updateAmountPrefix(account: BankAccount) { binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR " } @@ -781,12 +840,13 @@ class TransferFragment : Fragment() { setupContactDropdown() } - /** Reopens the Transfer screen in BML merchant-QR mode, keeping a selected BML card as the source. */ + /** + * 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 + * vanish and reappear. + */ private fun openBmlQr(bmlTarget: String) { - val fromCard = selectedAccount?.takeIf { - it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" - } - (requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, fromCard?.accountNumber)) + bmlHandler().lookupQrMerchant(bmlTarget) } /** @@ -1445,6 +1505,10 @@ class TransferFragment : Fragment() { dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE dialog.setCancelable(false) + // From here until the outcome is dismissed the payment is in flight: a theme/language + // change waits rather than recreating the screen out from under it. + val guard = (activity as? HomeActivity)?.beginPayment(viewLifecycleOwner) + dialog.setOnDismissListener { guard?.end() } val ctx = requireContext() val dp = resources.displayMetrics.density val spinner = CircularProgressDrawable(ctx).apply { @@ -1706,15 +1770,14 @@ class TransferFragment : Fragment() { override fun onDestroyView() { super.onDestroyView() // Persist form state so it can be restored when the view is recreated - savedAmount = binding.etAmount.text?.toString() ?: "" - savedRemarks = binding.etRemarks.text?.toString() ?: "" - savedToText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else "" - // The bank handlers hold binding refs; drop them so the next view gets fresh ones. - // Clearing also resets any in-progress OTP flow, which cannot sensibly resume. + draft.amount = binding.etAmount.text?.toString() ?: "" + draft.remarks = binding.etRemarks.text?.toString() ?: "" + draft.toText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else "" + // The bank handlers hold binding refs; drop them so the next view gets fresh ones. What + // they resolved lives in the draft; an in-progress BML OTP flow cannot sensibly resume. bmlHandler?.clearState() bmlHandler = null fahipayHandler = null - mfaisaHandler?.clearState() mfaisaHandler = null // Unregistered automatically with viewLifecycleOwner; drop the stale handle. qrLauncher = null diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt index 8a5bcbe..9dd1b6d 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt @@ -59,6 +59,8 @@ class BmlTransferHandler( private val currentSource: () -> BankAccount?, /** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */ private val selectSource: (BankAccount) -> Unit, + /** Asks the fragment to drop the selected source and show the empty From picker. */ + private val clearSource: () -> Unit, /** Hook called whenever handler state changes in a way that affects the Send button. */ private val onStateChanged: () -> Unit, /** Hook called on a successful transfer; fragment navigates to the receipt and refreshes balances. */ @@ -74,6 +76,18 @@ class BmlTransferHandler( /** Business-profile OTP flow. NONE means the Send button behaves normally. */ private enum class OtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP } private var otpState = OtpState.NONE + set(value) { + // The whole OTP flow counts as a payment in flight: a theme change mid-way would + // otherwise recreate the screen between initiate and confirm. + if (field == OtpState.NONE && value != OtpState.NONE) { + otpGuard = host?.beginPayment(fragment.viewLifecycleOwner) + } else if (value == OtpState.NONE) { + otpGuard?.end() + otpGuard = null + } + field = value + } + private var otpGuard: HomeActivity.PaymentGuard? = null private var otpChannel: String? = null private data class PendingTransfer( @@ -93,14 +107,15 @@ class BmlTransferHandler( ) private var pendingTransfer: PendingTransfer? = null + // Merchant QR state lives in the draft so it outlives this handler (dropped with the view). + private val draft get() = viewModel.transferDraft + /** Merchant QR payment mode (set when navigated from a card/gateway QR scan). */ - var qrInfo: BmlQrPayInfo? = null - private set + val qrInfo: BmlQrPayInfo? get() = draft.bmlQrInfo /** True for pay.bml.com.mv QRs, which need an extra pre-initiate step. */ - private var gatewayQr = false - /** Prevents re-running the lookup after the user clears the merchant. */ - var qrLookupAttempted = false - private set + private val gatewayQr: Boolean get() = draft.bmlGatewayQr + /** Stops the accounts observer re-firing a lookup that is already running on this view. */ + private var qrLookupInFlight = false // ─── Public API the fragment calls ─────────────────────────────────────── @@ -155,9 +170,10 @@ class BmlTransferHandler( /** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */ fun clearQrMerchant() { + draft.pendingBmlQrTarget = null if (qrInfo == null) return - qrInfo = null - gatewayQr = false + draft.bmlQrInfo = null + draft.bmlGatewayQr = false binding.tilAmount.isEnabled = true binding.tilRemarks.isEnabled = true binding.tilRemarks.alpha = 1f @@ -173,18 +189,25 @@ class BmlTransferHandler( // ─── Merchant QR ───────────────────────────────────────────────────────── + /** + * Resolves a card/gateway/POS QR to its merchant and switches the screen into QR-pay mode. + * Until it finishes the QR stays in [TransferDraft.pendingBmlQrTarget], which the fragment + * retries once sessions load (cold start) or when the view comes back (tab switched away + * mid-lookup). + */ fun lookupQrMerchant(qrUrl: String) { - qrLookupAttempted = true - // Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount and - // need the extra pre-initiate POST; ebanking qrpay URLs do not. - gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://") - val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl) + // Captured so a lookup finishing after a fresh draft replaced this one can't leak into it + val draft = this.draft + draft.pendingBmlQrTarget = qrUrl + if (qrLookupInFlight) return val session = app.anyBmlSession() ?: return + qrLookupInFlight = true + val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl) - // Lock the "To" input row while loading - binding.tilTo.visibility = View.GONE - binding.btnPickContact.visibility = View.GONE - binding.btnScanQr.visibility = View.GONE + // The To row stays on screen with a spinner while loading and is only swapped for the + // merchant card once there is a merchant to show — hiding it up front left a gap that + // made the form jump twice. + fragment.startLookupLoading() host?.setRefreshing(true) fragment.viewLifecycleOwner.lifecycleScope.launch { @@ -192,19 +215,32 @@ class BmlTransferHandler( runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) } } host?.setRefreshing(false) + qrLookupInFlight = false + if (fragment.view == null) return@launch + if (draft !== viewModel.transferDraft) return@launch + fragment.stopLookupLoading() + // Superseded: cleared meanwhile, or another QR was opened while this one ran + val latest = draft.pendingBmlQrTarget + if (latest != qrUrl) { + latest?.let { lookupQrMerchant(it) } + return@launch + } + draft.pendingBmlQrTarget = null val info = result.getOrNull() if (info == null) { // An expired or rejected QR is BML telling us something specific — show its own - // wording and stay put with the To row restored, rather than bouncing the user out - // of the screen they just scanned from. + // wording and stay put with the To row as it was, rather than bouncing the user + // out of the screen they just scanned from. val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message ?: ctx.getString(R.string.bml_qr_lookup_failed) Toast.makeText(ctx, message, Toast.LENGTH_LONG).show() - fragment.resetToFieldVisibility() onStateChanged() return@launch } - qrInfo = info + draft.bmlQrInfo = info + // Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount + // and need the extra pre-initiate POST; ebanking qrpay URLs do not. + draft.bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://") if (info.amount == 0.0) { RecentsCache.save(ctx, RecentPick( accountNumber = "bmlqr:$qrUrl", @@ -216,7 +252,13 @@ class BmlTransferHandler( )) } - // Auto-select the user's default BML card if no card was pre-selected + // Hide the To row before touching the source: repainting the From card re-syncs the + // picker/scan buttons to the To row's visibility. + hideToRow() + + // Only a BML card can pay a merchant QR — drop any other source, then auto-select + // the user's default card if no card was pre-selected + if (currentSource()?.let { isCard(it) } == false) clearSource() if (currentSource() == null) { val defaultNum = CredentialStore(ctx).getDefaultCardAccountNumber() if (defaultNum != null) { @@ -229,29 +271,44 @@ class BmlTransferHandler( } } - // Show merchant in the "To" card — clear button hidden (can't change recipient for QR) - binding.tvToAccountName.text = info.merchantName - binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" } - binding.tvToAccountDetails.visibility = View.GONE - binding.tvToBalance.visibility = View.GONE - binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP - binding.ivToPhoto.setImageBitmap(fragment.makeInitialsBitmap(info.merchantName, "#0066A1")) - binding.cardToInfo.visibility = View.VISIBLE - - // Pre-fill amount if dynamic QR - if (info.amount > 0.0) { - binding.etAmount.setText("%.2f".format(info.amount)) - binding.tilAmount.isEnabled = false - } - - // Remarks not applicable for merchant QR payments - binding.tilRemarks.isEnabled = false - binding.tilRemarks.alpha = 0.4f - - onStateChanged() + showQrMerchant(info) } } + /** + * Paints a looked-up merchant into the "To" card and puts the form in QR-pay mode. Also how a + * recreated view restores it — no network involved. + */ + fun showQrMerchant(info: BmlQrPayInfo) { + hideToRow() + // Clear button hidden (can't change recipient for QR) + binding.tvToAccountName.text = info.merchantName + binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" } + binding.tvToAccountDetails.visibility = View.GONE + binding.tvToBalance.visibility = View.GONE + binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP + binding.ivToPhoto.setImageBitmap(fragment.makeInitialsBitmap(info.merchantName, "#0066A1")) + binding.cardToInfo.visibility = View.VISIBLE + + // Pre-fill amount if dynamic QR + if (info.amount > 0.0) { + binding.etAmount.setText("%.2f".format(info.amount)) + binding.tilAmount.isEnabled = false + } + + // Remarks not applicable for merchant QR payments + binding.tilRemarks.isEnabled = false + binding.tilRemarks.alpha = 0.4f + + onStateChanged() + } + + private fun hideToRow() { + binding.tilTo.visibility = View.GONE + binding.btnPickContact.visibility = View.GONE + binding.btnScanQr.visibility = View.GONE + } + /** * Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog and * reports the outcome inside it — there is no receipt screen for merchant payments. 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 525d091..d32792b 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 @@ -54,8 +54,9 @@ class FahipayTransferHandler( private val ctx get() = fragment.requireContext() /** The service picked for the current recipient; null until a lookup resolves one. */ - var service: FahipayService? = null - private set + var service: FahipayService? + get() = viewModel.transferDraft.fahipayService + private set(value) { viewModel.transferDraft.fahipayService = value } /** How the confirm dialog names the destination, or "" when nothing is selected. */ val destinationLabel: String get() = service?.destinationLabel.orEmpty() diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MfaisaTransferHandler.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MfaisaTransferHandler.kt index 8a3f2d7..0bcbb5b 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MfaisaTransferHandler.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MfaisaTransferHandler.kt @@ -68,16 +68,29 @@ class MfaisaTransferHandler( private val ctx get() = fragment.requireContext() private val host get() = fragment.activity as? HomeActivity + // Resolved state lives in the draft so it outlives this handler (dropped with the view). + private val draft get() = viewModel.transferDraft + /** Set to the resolved recipient after a successful search; null otherwise. */ - var recipient: MfaisaTransferClient.Recipient? = null - private set + var recipient: MfaisaTransferClient.Recipient? + get() = draft.mfaisaRecipient + private set(value) { draft.mfaisaRecipient = value } /** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */ - var qrInfo: MfaisaQrPayClient.QrMerchant? = null - private set + var qrInfo: MfaisaQrPayClient.QrMerchant? + get() = draft.mfaisaQrInfo + private set(value) { draft.mfaisaQrInfo = value } private var lookupInFlight = false + /** Held from initiate until the OTP flow ends, so a theme change can't recreate mid-way. */ + private var transferGuard: HomeActivity.PaymentGuard? = null + + private fun endTransferFlow() { + transferGuard?.end() + transferGuard = null + } + // ─── Public API the fragment calls ─────────────────────────────────────── /** Whether the recipient lookup has resolved — gates the Send button. */ @@ -154,6 +167,8 @@ class MfaisaTransferHandler( binding.btnTransfer.isEnabled = false (fragment.activity as? HomeActivity)?.setRefreshing(true) + endTransferFlow() + transferGuard = host?.beginPayment(fragment.viewLifecycleOwner) fragment.viewLifecycleOwner.lifecycleScope.launch { val refId = try { @@ -161,6 +176,7 @@ class MfaisaTransferHandler( } catch (e: Exception) { (fragment.activity as? HomeActivity)?.setRefreshing(false) binding.btnTransfer.isEnabled = true + endTransferFlow() showError(e) return@launch } @@ -209,10 +225,8 @@ class MfaisaTransferHandler( // Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually if (currentSource()?.bank != "MFAISA") selectSource(source) - // Lock the "To" input row while loading - binding.tilTo.visibility = View.GONE - binding.btnPickContact.visibility = View.GONE - binding.btnScanQr.visibility = View.GONE + // The To row stays up with a spinner until there is a merchant to swap in + fragment.startLookupLoading() host?.setRefreshing(true) fragment.viewLifecycleOwner.lifecycleScope.launch { @@ -227,9 +241,10 @@ class MfaisaTransferHandler( } catch (_: Exception) { null } } host?.setRefreshing(false) + if (fragment.view == null) return@launch + fragment.stopLookupLoading() if (merchant == null) { Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show() - fragment.resetToFieldVisibility() return@launch } qrInfo = merchant @@ -248,26 +263,37 @@ class MfaisaTransferHandler( )) } - // Show merchant in the "To" card — clear button is the only way to back out - binding.tvToAccountName.text = merchant.merchantName - binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}" - binding.tvToAccountDetails.visibility = View.GONE - binding.tvToBalance.visibility = View.GONE - binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER - binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo) - binding.cardToInfo.visibility = View.VISIBLE - - // Pre-fill + lock amount if the QR is dynamic - val dynamicAmount = merchant.txnAmount?.toDoubleOrNull() - if (dynamicAmount != null && dynamicAmount > 0.0) { - binding.etAmount.setText("%.2f".format(dynamicAmount)) - binding.tilAmount.isEnabled = false - } - - onRecipientChanged() + showQrMerchant(merchant) } } + /** + * Paints a looked-up merchant into the "To" card and locks a dynamic amount. Also how a + * recreated view restores it — no network involved. + */ + fun showQrMerchant(merchant: MfaisaQrPayClient.QrMerchant) { + // Clear button is the only way to back out + binding.tilTo.visibility = View.GONE + binding.btnPickContact.visibility = View.GONE + binding.btnScanQr.visibility = View.GONE + binding.tvToAccountName.text = merchant.merchantName + binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}" + binding.tvToAccountDetails.visibility = View.GONE + binding.tvToBalance.visibility = View.GONE + binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER + binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo) + binding.cardToInfo.visibility = View.VISIBLE + + // Pre-fill + lock amount if the QR is dynamic + val dynamicAmount = merchant.txnAmount?.toDoubleOrNull() + if (dynamicAmount != null && dynamicAmount > 0.0) { + binding.etAmount.setText("%.2f".format(dynamicAmount)) + binding.tilAmount.isEnabled = false + } + + onRecipientChanged() + } + /** * Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog — * the /initiateNewBuy + /confirmNewBuy pair does NOT require OTP for wallet QR pay @@ -397,7 +423,8 @@ class MfaisaTransferHandler( currentSource()?.takeIf { it.bank == "MFAISA" } ?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" } - private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) { + /** Paints [r] into the "To" card; a recreated view restores it with [saveRecent] off. */ + fun showResolvedRecipient(r: MfaisaTransferClient.Recipient, saveRecent: Boolean = true) { // Reuse the same recipient card the fragment uses for other banks. The fragment owns the // card view, so we just populate its text fields and toggle visibility. binding.tvToAccountName.text = r.name.ifBlank { r.msisdn } @@ -413,7 +440,7 @@ class MfaisaTransferHandler( binding.btnScanQr.visibility = View.GONE binding.cardToInfo.visibility = View.VISIBLE - RecentsCache.save(ctx, RecentPick( + if (saveRecent) RecentsCache.save(ctx, RecentPick( accountNumber = r.msisdn, displayName = r.name.ifBlank { r.msisdn }, subtitle = "Ooredoo M-Faisa · ${r.msisdn}", @@ -470,7 +497,7 @@ class MfaisaTransferHandler( refId: String, errorMsg: String? ) { - val view = fragment.view ?: return + val view = fragment.view ?: run { endTransferFlow(); return } val dp = ctx.resources.displayMetrics.density val colorMuted = MaterialColors.getColor( view, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY) @@ -559,6 +586,7 @@ class MfaisaTransferHandler( .setNegativeButton(R.string.cancel) { d, _ -> d.dismiss() binding.btnTransfer.isEnabled = true + endTransferFlow() } .setCancelable(false) .show() @@ -583,6 +611,7 @@ class MfaisaTransferHandler( try { withContext(Dispatchers.IO) { confirmWithRetry(source, refId, otp) } (fragment.activity as? HomeActivity)?.setRefreshing(false) + endTransferFlow() val receipt = TransferReceiptData( bank = "MFAISA", amount = amountValue, @@ -607,6 +636,7 @@ class MfaisaTransferHandler( } catch (e: Exception) { (fragment.activity as? HomeActivity)?.setRefreshing(false) binding.btnTransfer.isEnabled = true + endTransferFlow() showError(e) } } 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 new file mode 100644 index 0000000..c0d8edc --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/TransferDraft.kt @@ -0,0 +1,52 @@ +package sh.sar.basedbank.ui.home.transfer + +import android.graphics.Bitmap +import sh.sar.basedbank.api.bml.BmlQrPayInfo +import sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient +import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient +import sh.sar.basedbank.api.models.BankAccount + +/** + * Everything the Transfer screen has filled in or resolved so far: source, recipient, form + * text and any loaded merchant QR. + * + * Kept on [sh.sar.basedbank.ui.home.HomeViewModel] rather than on the fragment so it outlives + * both the view (switching tabs) and the fragment instance (a theme or language change + * recreates the activity) — the screen is repainted from here instead of re-running lookups. + * A new Transfer screen opened with its own arguments (a scanned QR, a contact) starts a fresh + * draft. + */ +class TransferDraft { + var selectedAccount: BankAccount? = null + + // Resolved recipient — set after a successful lookup or prefill + var resolvedAccountNumber = "" + var resolvedRecipientName = "" + var resolvedBankName = "" + /** Last real profile/contact photo loaded into the "To" card (not an initials placeholder). */ + var loadedToPhoto: Bitmap? = null + var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown + var resolvedToOwnAccount: BankAccount? = null + var toSubtitle = "" + var toColorHex = "#607D8B" + var toImageHash: String? = null + + // Form text, captured when the view goes away + var amount = "" + var remarks = "" + var toText = "" + + // BML merchant QR + var bmlQrInfo: BmlQrPayInfo? = null + /** True for pay.bml.com.mv and POS QRs, which need an extra pre-initiate step. */ + var bmlGatewayQr = false + /** A BML QR whose lookup has not finished — no session yet, or the view went away mid-way. */ + var pendingBmlQrTarget: String? = null + + // M-Faisa + var mfaisaRecipient: MfaisaTransferClient.Recipient? = null + var mfaisaQrInfo: MfaisaQrPayClient.QrMerchant? = null + + // Fahipay + var fahipayService: FahipayService? = null +} diff --git a/fastlane/metadata/android/en-US/changelogs/33.txt b/fastlane/metadata/android/en-US/changelogs/33.txt new file mode 100644 index 0000000..3a2bf75 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/33.txt @@ -0,0 +1,2 @@ +- Fixed payments restarting or showing as failed when rotating or resizing the app, and the To box flickering when loading a merchant +- Transfer screen now keeps your form and merchant when switching tabs or changing theme/language, and waits for payments to finish before applying them