forked from thijooree/android
Compare commits
6
Commits
feat/chats
...
v1.0.32
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b97d0c5a18
|
||
|
|
4ac328cf52
|
||
|
|
c2f473a6a3
|
||
|
|
9f40c56b49
|
||
|
|
0747fbdbfd
|
||
|
|
528e9eeef8
|
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "sh.sar.basedbank"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 32
|
||||
versionName = "1.0.31"
|
||||
versionCode = 33
|
||||
versionName = "1.0.32"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
@@ -45,20 +46,24 @@
|
||||
|
||||
<activity
|
||||
android:name=".LockActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.onboarding.OnboardingActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.login.LoginActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.home.HomeActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|locale|layoutDirection|fontScale|density"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="adjustPan" />
|
||||
|
||||
@@ -69,6 +74,7 @@
|
||||
|
||||
<activity
|
||||
android:name=".nfc.BmlTapToPayActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/Theme.BasedBank" />
|
||||
|
||||
@@ -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
|
||||
@@ -171,6 +225,16 @@ class HomeActivity : AppCompatActivity() {
|
||||
insets
|
||||
}
|
||||
|
||||
// The app bar only pads for the status bar. In landscape the navigation bar (and any
|
||||
// cutout) sits at a side edge, and the toolbar's end icons — the lock button — would
|
||||
// draw underneath it, out of reach.
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.toolbar) { v, insets ->
|
||||
val sides = insets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
|
||||
v.updatePadding(left = sides.left, right = sides.right)
|
||||
insets
|
||||
}
|
||||
|
||||
binding.bottomNavigation.setOnItemSelectedListener { item ->
|
||||
if (suppressBottomNavCallback) return@setOnItemSelectedListener true
|
||||
val frag = when (item.itemId) {
|
||||
|
||||
@@ -56,4 +56,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* for HTTP 5xx server errors from specific banks.
|
||||
*/
|
||||
val connectivityErrors = MutableLiveData<Set<String>>(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()
|
||||
}
|
||||
|
||||
@@ -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<CardStackAdapter.VH>() {
|
||||
private inner class CardStackAdapter : RecyclerView.Adapter<CardStackAdapter.VH>() {
|
||||
private var items: List<CardItem> = emptyList()
|
||||
|
||||
fun update(newItems: List<CardItem>) {
|
||||
@@ -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
|
||||
|
||||
@@ -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<String, Bitmap>()
|
||||
|
||||
@@ -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,40 @@ 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)
|
||||
}
|
||||
updateTransferButton()
|
||||
}
|
||||
|
||||
/** 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
|
||||
/**
|
||||
* Freezes the amount at a merchant's preset value. Unlike disabling the field this keeps it
|
||||
* at full colour — it's the figure being paid, so it should read clearly — and a lock icon
|
||||
* says why it can't be typed into.
|
||||
*/
|
||||
internal fun setAmountLocked(locked: Boolean) {
|
||||
binding.etAmount.apply {
|
||||
isFocusable = !locked
|
||||
isFocusableInTouchMode = !locked
|
||||
isCursorVisible = !locked
|
||||
isLongClickable = !locked
|
||||
if (locked) clearFocus()
|
||||
}
|
||||
binding.tilAmount.apply {
|
||||
if (locked) {
|
||||
endIconMode = com.google.android.material.textfield.TextInputLayout.END_ICON_CUSTOM
|
||||
endIconDrawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_lock)
|
||||
} else {
|
||||
endIconMode = com.google.android.material.textfield.TextInputLayout.END_ICON_NONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun startLookupLoading() {
|
||||
@@ -462,14 +545,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 +572,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 +584,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 +598,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 +617,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 +630,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 +802,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 +863,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 +1528,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 {
|
||||
@@ -1605,6 +1692,7 @@ class TransferFragment : Fragment() {
|
||||
binding.tilFrom.visibility = View.VISIBLE
|
||||
binding.tilAmount.prefixText = null
|
||||
binding.tilAmount.isEnabled = true
|
||||
setAmountLocked(false)
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
binding.etAmount.setText("")
|
||||
@@ -1706,15 +1794,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
|
||||
|
||||
@@ -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,10 +170,11 @@ 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
|
||||
binding.tilAmount.isEnabled = true
|
||||
draft.bmlQrInfo = null
|
||||
draft.bmlGatewayQr = false
|
||||
fragment.setAmountLocked(false)
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
binding.etAmount.setText("")
|
||||
@@ -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))
|
||||
fragment.setAmountLocked(true)
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -183,7 +199,7 @@ class MfaisaTransferHandler(
|
||||
fun clearQrMerchant() {
|
||||
if (qrInfo == null) return
|
||||
qrInfo = null
|
||||
binding.tilAmount.isEnabled = true
|
||||
fragment.setAmountLocked(false)
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
binding.etAmount.setText("")
|
||||
@@ -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))
|
||||
fragment.setAmountLocked(true)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 27.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" baseProfile="basic" id="Layer_1"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512"
|
||||
xml:space="preserve">
|
||||
<path fill="#1A73E8" d="M440,255.99997v0.00006C440,273.12085,426.12085,287,409.00003,287H302l-46-93.01001l49.6507-85.9951
|
||||
c8.56021-14.82629,27.51834-19.9065,42.34518-11.34724l0.00586,0.0034c14.82776,8.55979,19.90875,27.51928,11.34857,42.34682
|
||||
L309.70001,225h99.30002C426.12085,225,440,238.87917,440,255.99997z"/>
|
||||
<path fill="#EA4335" d="M348.00174,415.34897l-0.00586,0.00339c-14.82684,8.55927-33.78497,3.47903-42.34518-11.34723L256,318.01001
|
||||
l-49.65065,85.99509c-8.5602,14.82629-27.51834,19.90652-42.34517,11.34729l-0.00591-0.00342
|
||||
c-14.82777-8.55978-19.90875-27.51929-11.34859-42.34683L202.29999,287L256,285l53.70001,2l49.6503,86.00214
|
||||
C367.91049,387.82968,362.8295,406.78918,348.00174,415.34897z"/>
|
||||
<path fill="#FBBC04" d="M256,193.98999L242,232l-39.70001-7l-49.6503-86.00212
|
||||
c-8.56017-14.82755-3.47919-33.78705,11.34859-42.34684l0.00591-0.00341c14.82683-8.55925,33.78497-3.47903,42.34517,11.34726
|
||||
L256,193.98999z"/>
|
||||
<path fill="#34A853" d="M248,225l-36,62H102.99997C85.87916,287,72,273.12085,72,256.00003v-0.00006
|
||||
C72,238.87917,85.87916,225,102.99997,225H248z"/>
|
||||
<polygon fill="#185DB7" points="309.70001,287 202.29999,287 256,193.98999 "/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -17,11 +17,12 @@ You get your seed in one of two ways:
|
||||
|
||||
### Setup
|
||||
|
||||
1. [Set up BML](01-setup-bml.md)
|
||||
2. [Set up MIB](02-setup-mib.md)
|
||||
| [<img src="../../../logos/bml_logo.png" alt="BML" height="64">](01-setup-bml.md) | [<img src="../../../logos/mib_logo.png" alt="MIB" height="64">](02-setup-mib.md) |
|
||||
|:---:|:---:|
|
||||
| [1. Set up BML](01-setup-bml.md) | [2. Set up MIB](02-setup-mib.md) |
|
||||
|
||||
### Export
|
||||
|
||||
3. [Export from Google Authenticator](03-export-googleauthenticator.md)
|
||||
4. [Export from Microsoft Authenticator](04-export-microsoft.md)
|
||||
5. [Export from Bitwarden](05-export-bitwarden.md)
|
||||
| [<img src="../../../logos/google_authenticator_logo.svg" alt="Google Authenticator" height="64">](03-export-googleauthenticator.md) | [<img src="../../../logos/microsoft_authenticator_logo.png" alt="Microsoft Authenticator" height="64">](04-export-microsoft.md) | [<img src="../../../logos/bitwarden_logo.png" alt="Bitwarden" height="64">](05-export-bitwarden.md) |
|
||||
|:---:|:---:|:---:|
|
||||
| [3. Export from Google Authenticator](03-export-googleauthenticator.md) | [4. Export from Microsoft Authenticator](04-export-microsoft.md) | [5. Export from Bitwarden](05-export-bitwarden.md) |
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
- 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
|
||||
- Fixed-amount merchant QR or Gateway now show the amount clearly with a lock icon instead of greying it out
|
||||
- App lock icon does not go behind navigation bar in landscape mode
|
||||
Reference in New Issue
Block a user