forked from thijooree/android
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b97d0c5a18
|
||
|
|
4ac328cf52
|
||
|
|
c2f473a6a3
|
||
|
|
9f40c56b49
|
||
|
|
0747fbdbfd
|
||
|
|
528e9eeef8
|
@@ -21,8 +21,8 @@ android {
|
|||||||
applicationId = "sh.sar.basedbank"
|
applicationId = "sh.sar.basedbank"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 32
|
versionCode = 33
|
||||||
versionName = "1.0.31"
|
versionName = "1.0.32"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name">
|
android:label="@string/app_name">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -45,20 +46,24 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".LockActivity"
|
android:name=".LockActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.onboarding.OnboardingActivity"
|
android:name=".ui.onboarding.OnboardingActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.login.LoginActivity"
|
android:name=".ui.login.LoginActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.home.HomeActivity"
|
android:name=".ui.home.HomeActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|locale|layoutDirection|fontScale|density"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:windowSoftInputMode="adjustPan" />
|
android:windowSoftInputMode="adjustPan" />
|
||||||
|
|
||||||
@@ -69,6 +74,7 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".nfc.BmlTapToPayActivity"
|
android:name=".nfc.BmlTapToPayActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:theme="@style/Theme.BasedBank" />
|
android:theme="@style/Theme.BasedBank" />
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ import androidx.core.view.WindowInsetsCompat
|
|||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -104,6 +106,57 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
if (securitySet) lock()
|
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 lockApp() = lock()
|
||||||
|
|
||||||
fun notifyWheelLockTap() {
|
fun notifyWheelLockTap() {
|
||||||
@@ -136,6 +189,7 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
}
|
}
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
lastConfig = Configuration(resources.configuration)
|
||||||
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
|
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
|
||||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
isAppearanceLightStatusBars = isLight
|
isAppearanceLightStatusBars = isLight
|
||||||
@@ -171,6 +225,16 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
insets
|
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 ->
|
binding.bottomNavigation.setOnItemSelectedListener { item ->
|
||||||
if (suppressBottomNavCallback) return@setOnItemSelectedListener true
|
if (suppressBottomNavCallback) return@setOnItemSelectedListener true
|
||||||
val frag = when (item.itemId) {
|
val frag = when (item.itemId) {
|
||||||
|
|||||||
@@ -56,4 +56,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
* for HTTP 5xx server errors from specific banks.
|
* for HTTP 5xx server errors from specific banks.
|
||||||
*/
|
*/
|
||||||
val connectivityErrors = MutableLiveData<Set<String>>(emptySet())
|
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?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
val screenW = resources.displayMetrics.widthPixels
|
stackAdapter = CardStackAdapter()
|
||||||
val peekPx = screenW / 8
|
|
||||||
cardWidth = screenW - 2 * peekPx
|
|
||||||
|
|
||||||
stackAdapter = CardStackAdapter(cardWidth)
|
|
||||||
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
||||||
binding.rvCards.adapter = stackAdapter
|
binding.rvCards.adapter = stackAdapter
|
||||||
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
|
|
||||||
binding.rvCards.clipToPadding = false
|
binding.rvCards.clipToPadding = false
|
||||||
|
applyCarouselWidth()
|
||||||
|
|
||||||
val snapHelper = PagerSnapHelper()
|
val snapHelper = PagerSnapHelper()
|
||||||
snapHelper.attachToRecyclerView(binding.rvCards)
|
snapHelper.attachToRecyclerView(binding.rvCards)
|
||||||
@@ -637,8 +633,13 @@ class CardsFragment : Fragment() {
|
|||||||
|
|
||||||
// ── Tap-to-pay mode ────────────────────────────────────────────────────────
|
// ── 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) {
|
private fun setTapMode(enabled: Boolean, item: CardItem.Bml? = null) {
|
||||||
isTapMode = enabled
|
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)
|
requireActivity().title = getString(if (enabled) R.string.card_pay_nfc else R.string.nav_pay_with_card)
|
||||||
if (enabled) enterTapMode(item!!) else exitTapMode()
|
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() {
|
private fun applyCardScales() {
|
||||||
val rv = binding.rvCards
|
val rv = binding.rvCards
|
||||||
val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f
|
val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f
|
||||||
@@ -1055,7 +1075,7 @@ class CardsFragment : Fragment() {
|
|||||||
_binding = null
|
_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()
|
private var items: List<CardItem> = emptyList()
|
||||||
|
|
||||||
fun update(newItems: List<CardItem>) {
|
fun update(newItems: List<CardItem>) {
|
||||||
@@ -1070,6 +1090,8 @@ class CardsFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||||
holder.bind(items[position])
|
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
|
// 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 fraction = abs(position - currentCardPosition).toFloat().coerceIn(0f, 1f)
|
||||||
val scale = 1f - 0.18f * fraction
|
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.FahipayTransferHandler
|
||||||
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
|
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
|
||||||
import sh.sar.basedbank.ui.home.transfer.MibTransferHandler
|
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.AccountListParser
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.util.AccountInputParser
|
import sh.sar.basedbank.util.AccountInputParser
|
||||||
@@ -67,7 +68,16 @@ class TransferFragment : Fragment() {
|
|||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
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)
|
private fun bmlSessionFor(account: BankAccount?) = bmlHandler().sessionFor(account)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,21 +88,36 @@ class TransferFragment : Fragment() {
|
|||||||
private val mibHandler by lazy { MibTransferHandler(this) { selectedAccount } }
|
private val mibHandler by lazy { MibTransferHandler(this) { selectedAccount } }
|
||||||
|
|
||||||
// Resolved recipient info — set after successful lookup or prefill
|
// Resolved recipient info — set after successful lookup or prefill
|
||||||
private var resolvedAccountNumber = ""
|
private var resolvedAccountNumber: String
|
||||||
private var resolvedRecipientName = ""
|
get() = draft.resolvedAccountNumber
|
||||||
private var resolvedBankName = ""
|
set(value) { draft.resolvedAccountNumber = value }
|
||||||
/** Last real profile/contact photo loaded into the "To" card (not an initials placeholder). */
|
private var resolvedRecipientName: String
|
||||||
private var loadedToPhoto: Bitmap? = null
|
get() = draft.resolvedRecipientName
|
||||||
private var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown
|
set(value) { draft.resolvedRecipientName = value }
|
||||||
private var resolvedToOwnAccount: BankAccount? = null
|
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
|
/** Set when this view applied the fragment's arguments, so the accounts observer may too. */
|
||||||
private var savedAmount = ""
|
private var argsAppliedThisView = false
|
||||||
private var savedRemarks = ""
|
|
||||||
private var savedToText = ""
|
|
||||||
private var savedToSubtitle = ""
|
|
||||||
private var savedToColorHex = "#607D8B"
|
|
||||||
private var savedToImageHash: String? = null
|
|
||||||
|
|
||||||
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
||||||
|
|
||||||
@@ -112,6 +137,7 @@ class TransferFragment : Fragment() {
|
|||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
currentSource = { selectedAccount },
|
currentSource = { selectedAccount },
|
||||||
selectSource = ::selectSourceAccount,
|
selectSource = ::selectSourceAccount,
|
||||||
|
clearSource = ::clearSourceAccount,
|
||||||
onStateChanged = { updateTransferButton() },
|
onStateChanged = { updateTransferButton() },
|
||||||
onTransferSuccess = { receipt, avatar ->
|
onTransferSuccess = { receipt, avatar ->
|
||||||
ReceiptStore.save(requireContext(), receipt)
|
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_BML_QR_URL = "bml_qr_url"
|
||||||
private const val ARG_AUTO_SCAN = "auto_scan"
|
private const val ARG_AUTO_SCAN = "auto_scan"
|
||||||
private const val ARG_BML_TXN_ID = "bml_txn_id"
|
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 {
|
fun newInstanceWithAutoScan() = TransferFragment().apply {
|
||||||
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
|
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
|
||||||
@@ -315,6 +343,16 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
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(
|
qrLauncher = requireActivity().activityResultRegistry.register(
|
||||||
qrLauncherKey, viewLifecycleOwner, ActivityResultContracts.StartActivityForResult()
|
qrLauncherKey, viewLifecycleOwner, ActivityResultContracts.StartActivityForResult()
|
||||||
) { onQrScanned(it) }
|
) { onQrScanned(it) }
|
||||||
@@ -378,6 +416,19 @@ class TransferFragment : Fragment() {
|
|||||||
|
|
||||||
binding.etAmount.addTextChangedListener { updateTransferButton() }
|
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
|
// Pre-select contact if navigated from contacts page or QR scan
|
||||||
arguments?.getString(ARG_ACCOUNT)?.let { account ->
|
arguments?.getString(ARG_ACCOUNT)?.let { account ->
|
||||||
prefillToDirectly(
|
prefillToDirectly(
|
||||||
@@ -391,7 +442,7 @@ class TransferFragment : Fragment() {
|
|||||||
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
|
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
|
||||||
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.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 {
|
arguments?.getString(ARG_BML_TXN_ID)?.let {
|
||||||
// Shown in the To field so a failed lookup leaves the ID there to retry or correct.
|
// Shown in the To field so a failed lookup leaves the ID there to retry or correct.
|
||||||
binding.etTo.setText(it)
|
binding.etTo.setText(it)
|
||||||
@@ -401,10 +452,24 @@ class TransferFragment : Fragment() {
|
|||||||
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
|
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
|
||||||
launchQrScanner()
|
launchQrScanner()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Restore form state when view is recreated on the cached no-args instance
|
/**
|
||||||
if (arguments == null) {
|
* Repaints a recreated view from [draft] — nothing is looked up again. The From card is
|
||||||
if (resolvedAccountNumber.isNotEmpty()) {
|
* 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 }
|
val ownAccount = viewModel.accounts.value?.firstOrNull { it.accountNumber == resolvedAccountNumber }
|
||||||
if (ownAccount != null) {
|
if (ownAccount != null) {
|
||||||
showToCard(ownAccount)
|
showToCard(ownAccount)
|
||||||
@@ -420,22 +485,40 @@ class TransferFragment : Fragment() {
|
|||||||
binding.btnPickContact.visibility = View.GONE
|
binding.btnPickContact.visibility = View.GONE
|
||||||
binding.btnScanQr.visibility = View.GONE
|
binding.btnScanQr.visibility = View.GONE
|
||||||
binding.cardToInfo.visibility = View.VISIBLE
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
if (savedToImageHash != null) loadToPhoto(savedToImageHash!!, isProfile = resolvedToOwnAccount != null)
|
val photo = loadedToPhoto
|
||||||
} else if (savedToText.isNotEmpty()) {
|
if (photo != null) {
|
||||||
binding.etTo.setText(savedToText, false)
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
|
binding.ivToPhoto.setImageBitmap(photo)
|
||||||
|
} else {
|
||||||
|
savedToImageHash?.let { loadToPhoto(it, isProfile = resolvedToOwnAccount != null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draft.toText.isNotEmpty() -> binding.etTo.setText(draft.toText, false)
|
||||||
}
|
}
|
||||||
if (savedAmount.isNotEmpty()) binding.etAmount.setText(savedAmount)
|
|
||||||
if (savedRemarks.isNotEmpty()) binding.etRemarks.setText(savedRemarks)
|
|
||||||
updateTransferButton()
|
updateTransferButton()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Restores the To-input row to its default state when a QR lookup fails. */
|
/**
|
||||||
internal fun resetToFieldVisibility() {
|
* Freezes the amount at a merchant's preset value. Unlike disabling the field this keeps it
|
||||||
binding.cardToInfo.visibility = View.GONE
|
* at full colour — it's the figure being paid, so it should read clearly — and a lock icon
|
||||||
binding.tilTo.visibility = View.VISIBLE
|
* says why it can't be typed into.
|
||||||
binding.btnPickContact.visibility = View.VISIBLE
|
*/
|
||||||
binding.btnScanQr.visibility = View.VISIBLE
|
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() {
|
internal fun startLookupLoading() {
|
||||||
@@ -462,14 +545,7 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setupFromDropdown() {
|
private fun setupFromDropdown() {
|
||||||
binding.btnClearFromInfo.setOnClickListener {
|
binding.btnClearFromInfo.setOnClickListener { clearSourceAccount() }
|
||||||
selectedAccount = null
|
|
||||||
binding.tilAmount.prefixText = null
|
|
||||||
binding.cardFromInfo.visibility = View.GONE
|
|
||||||
binding.tilFrom.visibility = View.VISIBLE
|
|
||||||
binding.actvFrom.setText("", false)
|
|
||||||
updateTransferButton()
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
accountDropdownAdapter = AccountDropdownAdapter(requireContext(), accounts)
|
accountDropdownAdapter = AccountDropdownAdapter(requireContext(), accounts)
|
||||||
@@ -496,7 +572,7 @@ class TransferFragment : Fragment() {
|
|||||||
updateTransferButton()
|
updateTransferButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)
|
val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)?.takeIf { argsAppliedThisView }
|
||||||
if (fromNumber != null && selectedAccount == null) {
|
if (fromNumber != null && selectedAccount == null) {
|
||||||
val match = accounts.firstOrNull { it.accountNumber == fromNumber }
|
val match = accounts.firstOrNull { it.accountNumber == fromNumber }
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
@@ -508,7 +584,7 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-select default account when arriving from contacts page (TO account already pre-filled)
|
// 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()
|
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||||
if (defaultNum != null) {
|
if (defaultNum != null) {
|
||||||
val defaultAcc = accounts.firstOrNull { it.accountNumber == defaultNum }
|
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
|
// On a cold start (e.g. share intent), anyBmlSession() may be null when
|
||||||
// onViewCreated runs. Retry the lookup once sessions are available.
|
// onViewCreated runs; a lookup can also have been cut off by leaving the tab.
|
||||||
val pendingBmlQrUrl = arguments?.getString(ARG_BML_QR_URL)
|
// Retry it once sessions are available.
|
||||||
if (pendingBmlQrUrl != null && !bmlHandler().qrLookupAttempted) {
|
draft.pendingBmlQrTarget?.let { bmlHandler().lookupQrMerchant(it) }
|
||||||
val app = requireActivity().application as BasedBankApp
|
|
||||||
if (app.anyBmlSession() != null) bmlHandler().lookupQrMerchant(pendingBmlQrUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-render the from card when the view is recreated on a cached instance
|
// Re-render the from card when the view is recreated on a cached instance
|
||||||
if (selectedAccount != null && binding.cardFromInfo.visibility != View.VISIBLE) {
|
if (selectedAccount != null && binding.cardFromInfo.visibility != View.VISIBLE) {
|
||||||
@@ -544,7 +617,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding.tilTo.hint = getString(R.string.ooredoo_phone)
|
binding.tilTo.hint = getString(R.string.ooredoo_phone)
|
||||||
binding.etTo.inputType = android.text.InputType.TYPE_CLASS_PHONE
|
binding.etTo.inputType = android.text.InputType.TYPE_CLASS_PHONE
|
||||||
// Any previously-resolved non-MFAISA recipient (or stale state) is no longer valid
|
// 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 = ""
|
resolvedAccountNumber = ""
|
||||||
resolvedRecipientName = ""
|
resolvedRecipientName = ""
|
||||||
resolvedDestCurrency = ""
|
resolvedDestCurrency = ""
|
||||||
@@ -557,8 +630,8 @@ class TransferFragment : Fragment() {
|
|||||||
binding.tilTo.hint = getString(R.string.transfer_to)
|
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
|
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
|
// Drop any M-Faisa-resolved recipient when switching banks
|
||||||
if (mfaisaHandler?.recipient != null) {
|
if (draft.mfaisaRecipient != null) {
|
||||||
mfaisaHandler?.clearState()
|
mfaisaHandler().clearState()
|
||||||
resolvedAccountNumber = ""
|
resolvedAccountNumber = ""
|
||||||
resolvedRecipientName = ""
|
resolvedRecipientName = ""
|
||||||
resolvedDestCurrency = ""
|
resolvedDestCurrency = ""
|
||||||
@@ -729,6 +802,15 @@ class TransferFragment : Fragment() {
|
|||||||
updateTransferButton()
|
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) {
|
private fun updateAmountPrefix(account: BankAccount) {
|
||||||
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
|
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
|
||||||
}
|
}
|
||||||
@@ -781,12 +863,13 @@ class TransferFragment : Fragment() {
|
|||||||
setupContactDropdown()
|
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) {
|
private fun openBmlQr(bmlTarget: String) {
|
||||||
val fromCard = selectedAccount?.takeIf {
|
bmlHandler().lookupQrMerchant(bmlTarget)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1445,6 +1528,10 @@ class TransferFragment : Fragment() {
|
|||||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE
|
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE
|
||||||
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
|
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
|
||||||
dialog.setCancelable(false)
|
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 ctx = requireContext()
|
||||||
val dp = resources.displayMetrics.density
|
val dp = resources.displayMetrics.density
|
||||||
val spinner = CircularProgressDrawable(ctx).apply {
|
val spinner = CircularProgressDrawable(ctx).apply {
|
||||||
@@ -1605,6 +1692,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding.tilFrom.visibility = View.VISIBLE
|
binding.tilFrom.visibility = View.VISIBLE
|
||||||
binding.tilAmount.prefixText = null
|
binding.tilAmount.prefixText = null
|
||||||
binding.tilAmount.isEnabled = true
|
binding.tilAmount.isEnabled = true
|
||||||
|
setAmountLocked(false)
|
||||||
binding.tilRemarks.isEnabled = true
|
binding.tilRemarks.isEnabled = true
|
||||||
binding.tilRemarks.alpha = 1f
|
binding.tilRemarks.alpha = 1f
|
||||||
binding.etAmount.setText("")
|
binding.etAmount.setText("")
|
||||||
@@ -1706,15 +1794,14 @@ class TransferFragment : Fragment() {
|
|||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
// Persist form state so it can be restored when the view is recreated
|
// Persist form state so it can be restored when the view is recreated
|
||||||
savedAmount = binding.etAmount.text?.toString() ?: ""
|
draft.amount = binding.etAmount.text?.toString() ?: ""
|
||||||
savedRemarks = binding.etRemarks.text?.toString() ?: ""
|
draft.remarks = binding.etRemarks.text?.toString() ?: ""
|
||||||
savedToText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else ""
|
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.
|
// The bank handlers hold binding refs; drop them so the next view gets fresh ones. What
|
||||||
// Clearing also resets any in-progress OTP flow, which cannot sensibly resume.
|
// they resolved lives in the draft; an in-progress BML OTP flow cannot sensibly resume.
|
||||||
bmlHandler?.clearState()
|
bmlHandler?.clearState()
|
||||||
bmlHandler = null
|
bmlHandler = null
|
||||||
fahipayHandler = null
|
fahipayHandler = null
|
||||||
mfaisaHandler?.clearState()
|
|
||||||
mfaisaHandler = null
|
mfaisaHandler = null
|
||||||
// Unregistered automatically with viewLifecycleOwner; drop the stale handle.
|
// Unregistered automatically with viewLifecycleOwner; drop the stale handle.
|
||||||
qrLauncher = null
|
qrLauncher = null
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ class BmlTransferHandler(
|
|||||||
private val currentSource: () -> BankAccount?,
|
private val currentSource: () -> BankAccount?,
|
||||||
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
||||||
private val selectSource: (BankAccount) -> Unit,
|
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. */
|
/** Hook called whenever handler state changes in a way that affects the Send button. */
|
||||||
private val onStateChanged: () -> Unit,
|
private val onStateChanged: () -> Unit,
|
||||||
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes balances. */
|
/** 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. */
|
/** Business-profile OTP flow. NONE means the Send button behaves normally. */
|
||||||
private enum class OtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
|
private enum class OtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
|
||||||
private var otpState = OtpState.NONE
|
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 var otpChannel: String? = null
|
||||||
|
|
||||||
private data class PendingTransfer(
|
private data class PendingTransfer(
|
||||||
@@ -93,14 +107,15 @@ class BmlTransferHandler(
|
|||||||
)
|
)
|
||||||
private var pendingTransfer: PendingTransfer? = null
|
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). */
|
/** Merchant QR payment mode (set when navigated from a card/gateway QR scan). */
|
||||||
var qrInfo: BmlQrPayInfo? = null
|
val qrInfo: BmlQrPayInfo? get() = draft.bmlQrInfo
|
||||||
private set
|
|
||||||
/** True for pay.bml.com.mv QRs, which need an extra pre-initiate step. */
|
/** True for pay.bml.com.mv QRs, which need an extra pre-initiate step. */
|
||||||
private var gatewayQr = false
|
private val gatewayQr: Boolean get() = draft.bmlGatewayQr
|
||||||
/** Prevents re-running the lookup after the user clears the merchant. */
|
/** Stops the accounts observer re-firing a lookup that is already running on this view. */
|
||||||
var qrLookupAttempted = false
|
private var qrLookupInFlight = false
|
||||||
private set
|
|
||||||
|
|
||||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
// ─── 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. */
|
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
|
||||||
fun clearQrMerchant() {
|
fun clearQrMerchant() {
|
||||||
|
draft.pendingBmlQrTarget = null
|
||||||
if (qrInfo == null) return
|
if (qrInfo == null) return
|
||||||
qrInfo = null
|
draft.bmlQrInfo = null
|
||||||
gatewayQr = false
|
draft.bmlGatewayQr = false
|
||||||
binding.tilAmount.isEnabled = true
|
fragment.setAmountLocked(false)
|
||||||
binding.tilRemarks.isEnabled = true
|
binding.tilRemarks.isEnabled = true
|
||||||
binding.tilRemarks.alpha = 1f
|
binding.tilRemarks.alpha = 1f
|
||||||
binding.etAmount.setText("")
|
binding.etAmount.setText("")
|
||||||
@@ -173,18 +189,25 @@ class BmlTransferHandler(
|
|||||||
|
|
||||||
// ─── Merchant QR ─────────────────────────────────────────────────────────
|
// ─── 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) {
|
fun lookupQrMerchant(qrUrl: String) {
|
||||||
qrLookupAttempted = true
|
// Captured so a lookup finishing after a fresh draft replaced this one can't leak into it
|
||||||
// Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount and
|
val draft = this.draft
|
||||||
// need the extra pre-initiate POST; ebanking qrpay URLs do not.
|
draft.pendingBmlQrTarget = qrUrl
|
||||||
gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
|
if (qrLookupInFlight) return
|
||||||
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
|
|
||||||
val session = app.anyBmlSession() ?: return
|
val session = app.anyBmlSession() ?: return
|
||||||
|
qrLookupInFlight = true
|
||||||
|
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
|
||||||
|
|
||||||
// Lock the "To" input row while loading
|
// The To row stays on screen with a spinner while loading and is only swapped for the
|
||||||
binding.tilTo.visibility = View.GONE
|
// merchant card once there is a merchant to show — hiding it up front left a gap that
|
||||||
binding.btnPickContact.visibility = View.GONE
|
// made the form jump twice.
|
||||||
binding.btnScanQr.visibility = View.GONE
|
fragment.startLookupLoading()
|
||||||
host?.setRefreshing(true)
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
@@ -192,19 +215,32 @@ class BmlTransferHandler(
|
|||||||
runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
|
runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
|
||||||
}
|
}
|
||||||
host?.setRefreshing(false)
|
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()
|
val info = result.getOrNull()
|
||||||
if (info == null) {
|
if (info == null) {
|
||||||
// An expired or rejected QR is BML telling us something specific — show its own
|
// 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
|
// wording and stay put with the To row as it was, rather than bouncing the user
|
||||||
// of the screen they just scanned from.
|
// out of the screen they just scanned from.
|
||||||
val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message
|
val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message
|
||||||
?: ctx.getString(R.string.bml_qr_lookup_failed)
|
?: ctx.getString(R.string.bml_qr_lookup_failed)
|
||||||
Toast.makeText(ctx, message, Toast.LENGTH_LONG).show()
|
Toast.makeText(ctx, message, Toast.LENGTH_LONG).show()
|
||||||
fragment.resetToFieldVisibility()
|
|
||||||
onStateChanged()
|
onStateChanged()
|
||||||
return@launch
|
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) {
|
if (info.amount == 0.0) {
|
||||||
RecentsCache.save(ctx, RecentPick(
|
RecentsCache.save(ctx, RecentPick(
|
||||||
accountNumber = "bmlqr:$qrUrl",
|
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) {
|
if (currentSource() == null) {
|
||||||
val defaultNum = CredentialStore(ctx).getDefaultCardAccountNumber()
|
val defaultNum = CredentialStore(ctx).getDefaultCardAccountNumber()
|
||||||
if (defaultNum != null) {
|
if (defaultNum != null) {
|
||||||
@@ -229,7 +271,17 @@ class BmlTransferHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show merchant in the "To" card — clear button hidden (can't change recipient for QR)
|
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.tvToAccountName.text = info.merchantName
|
||||||
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
|
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
|
||||||
binding.tvToAccountDetails.visibility = View.GONE
|
binding.tvToAccountDetails.visibility = View.GONE
|
||||||
@@ -241,7 +293,7 @@ class BmlTransferHandler(
|
|||||||
// Pre-fill amount if dynamic QR
|
// Pre-fill amount if dynamic QR
|
||||||
if (info.amount > 0.0) {
|
if (info.amount > 0.0) {
|
||||||
binding.etAmount.setText("%.2f".format(info.amount))
|
binding.etAmount.setText("%.2f".format(info.amount))
|
||||||
binding.tilAmount.isEnabled = false
|
fragment.setAmountLocked(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remarks not applicable for merchant QR payments
|
// Remarks not applicable for merchant QR payments
|
||||||
@@ -250,6 +302,11 @@ class BmlTransferHandler(
|
|||||||
|
|
||||||
onStateChanged()
|
onStateChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun hideToRow() {
|
||||||
|
binding.tilTo.visibility = View.GONE
|
||||||
|
binding.btnPickContact.visibility = View.GONE
|
||||||
|
binding.btnScanQr.visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -54,8 +54,9 @@ class FahipayTransferHandler(
|
|||||||
private val ctx get() = fragment.requireContext()
|
private val ctx get() = fragment.requireContext()
|
||||||
|
|
||||||
/** The service picked for the current recipient; null until a lookup resolves one. */
|
/** The service picked for the current recipient; null until a lookup resolves one. */
|
||||||
var service: FahipayService? = null
|
var service: FahipayService?
|
||||||
private set
|
get() = viewModel.transferDraft.fahipayService
|
||||||
|
private set(value) { viewModel.transferDraft.fahipayService = value }
|
||||||
|
|
||||||
/** How the confirm dialog names the destination, or "" when nothing is selected. */
|
/** How the confirm dialog names the destination, or "" when nothing is selected. */
|
||||||
val destinationLabel: String get() = service?.destinationLabel.orEmpty()
|
val destinationLabel: String get() = service?.destinationLabel.orEmpty()
|
||||||
|
|||||||
@@ -68,16 +68,29 @@ class MfaisaTransferHandler(
|
|||||||
private val ctx get() = fragment.requireContext()
|
private val ctx get() = fragment.requireContext()
|
||||||
private val host get() = fragment.activity as? HomeActivity
|
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. */
|
/** Set to the resolved recipient after a successful search; null otherwise. */
|
||||||
var recipient: MfaisaTransferClient.Recipient? = null
|
var recipient: MfaisaTransferClient.Recipient?
|
||||||
private set
|
get() = draft.mfaisaRecipient
|
||||||
|
private set(value) { draft.mfaisaRecipient = value }
|
||||||
|
|
||||||
/** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */
|
/** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */
|
||||||
var qrInfo: MfaisaQrPayClient.QrMerchant? = null
|
var qrInfo: MfaisaQrPayClient.QrMerchant?
|
||||||
private set
|
get() = draft.mfaisaQrInfo
|
||||||
|
private set(value) { draft.mfaisaQrInfo = value }
|
||||||
|
|
||||||
private var lookupInFlight = false
|
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 ───────────────────────────────────────
|
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||||
|
|
||||||
/** Whether the recipient lookup has resolved — gates the Send button. */
|
/** Whether the recipient lookup has resolved — gates the Send button. */
|
||||||
@@ -154,6 +167,8 @@ class MfaisaTransferHandler(
|
|||||||
|
|
||||||
binding.btnTransfer.isEnabled = false
|
binding.btnTransfer.isEnabled = false
|
||||||
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
endTransferFlow()
|
||||||
|
transferGuard = host?.beginPayment(fragment.viewLifecycleOwner)
|
||||||
|
|
||||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val refId = try {
|
val refId = try {
|
||||||
@@ -161,6 +176,7 @@ class MfaisaTransferHandler(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
binding.btnTransfer.isEnabled = true
|
binding.btnTransfer.isEnabled = true
|
||||||
|
endTransferFlow()
|
||||||
showError(e)
|
showError(e)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -183,7 +199,7 @@ class MfaisaTransferHandler(
|
|||||||
fun clearQrMerchant() {
|
fun clearQrMerchant() {
|
||||||
if (qrInfo == null) return
|
if (qrInfo == null) return
|
||||||
qrInfo = null
|
qrInfo = null
|
||||||
binding.tilAmount.isEnabled = true
|
fragment.setAmountLocked(false)
|
||||||
binding.tilRemarks.isEnabled = true
|
binding.tilRemarks.isEnabled = true
|
||||||
binding.tilRemarks.alpha = 1f
|
binding.tilRemarks.alpha = 1f
|
||||||
binding.etAmount.setText("")
|
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
|
// Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually
|
||||||
if (currentSource()?.bank != "MFAISA") selectSource(source)
|
if (currentSource()?.bank != "MFAISA") selectSource(source)
|
||||||
|
|
||||||
// Lock the "To" input row while loading
|
// The To row stays up with a spinner until there is a merchant to swap in
|
||||||
binding.tilTo.visibility = View.GONE
|
fragment.startLookupLoading()
|
||||||
binding.btnPickContact.visibility = View.GONE
|
|
||||||
binding.btnScanQr.visibility = View.GONE
|
|
||||||
host?.setRefreshing(true)
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
@@ -227,9 +241,10 @@ class MfaisaTransferHandler(
|
|||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
host?.setRefreshing(false)
|
host?.setRefreshing(false)
|
||||||
|
if (fragment.view == null) return@launch
|
||||||
|
fragment.stopLookupLoading()
|
||||||
if (merchant == null) {
|
if (merchant == null) {
|
||||||
Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
|
Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
|
||||||
fragment.resetToFieldVisibility()
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
qrInfo = merchant
|
qrInfo = merchant
|
||||||
@@ -248,7 +263,19 @@ class MfaisaTransferHandler(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show merchant in the "To" card — clear button is the only way to back out
|
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.tvToAccountName.text = merchant.merchantName
|
||||||
binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}"
|
binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}"
|
||||||
binding.tvToAccountDetails.visibility = View.GONE
|
binding.tvToAccountDetails.visibility = View.GONE
|
||||||
@@ -261,12 +288,11 @@ class MfaisaTransferHandler(
|
|||||||
val dynamicAmount = merchant.txnAmount?.toDoubleOrNull()
|
val dynamicAmount = merchant.txnAmount?.toDoubleOrNull()
|
||||||
if (dynamicAmount != null && dynamicAmount > 0.0) {
|
if (dynamicAmount != null && dynamicAmount > 0.0) {
|
||||||
binding.etAmount.setText("%.2f".format(dynamicAmount))
|
binding.etAmount.setText("%.2f".format(dynamicAmount))
|
||||||
binding.tilAmount.isEnabled = false
|
fragment.setAmountLocked(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
onRecipientChanged()
|
onRecipientChanged()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog —
|
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog —
|
||||||
@@ -397,7 +423,8 @@ class MfaisaTransferHandler(
|
|||||||
currentSource()?.takeIf { it.bank == "MFAISA" }
|
currentSource()?.takeIf { it.bank == "MFAISA" }
|
||||||
?: viewModel.accounts.value?.firstOrNull { 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
|
// 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.
|
// card view, so we just populate its text fields and toggle visibility.
|
||||||
binding.tvToAccountName.text = r.name.ifBlank { r.msisdn }
|
binding.tvToAccountName.text = r.name.ifBlank { r.msisdn }
|
||||||
@@ -413,7 +440,7 @@ class MfaisaTransferHandler(
|
|||||||
binding.btnScanQr.visibility = View.GONE
|
binding.btnScanQr.visibility = View.GONE
|
||||||
binding.cardToInfo.visibility = View.VISIBLE
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
RecentsCache.save(ctx, RecentPick(
|
if (saveRecent) RecentsCache.save(ctx, RecentPick(
|
||||||
accountNumber = r.msisdn,
|
accountNumber = r.msisdn,
|
||||||
displayName = r.name.ifBlank { r.msisdn },
|
displayName = r.name.ifBlank { r.msisdn },
|
||||||
subtitle = "Ooredoo M-Faisa · ${r.msisdn}",
|
subtitle = "Ooredoo M-Faisa · ${r.msisdn}",
|
||||||
@@ -470,7 +497,7 @@ class MfaisaTransferHandler(
|
|||||||
refId: String,
|
refId: String,
|
||||||
errorMsg: String?
|
errorMsg: String?
|
||||||
) {
|
) {
|
||||||
val view = fragment.view ?: return
|
val view = fragment.view ?: run { endTransferFlow(); return }
|
||||||
val dp = ctx.resources.displayMetrics.density
|
val dp = ctx.resources.displayMetrics.density
|
||||||
val colorMuted = MaterialColors.getColor(
|
val colorMuted = MaterialColors.getColor(
|
||||||
view, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
|
view, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
|
||||||
@@ -559,6 +586,7 @@ class MfaisaTransferHandler(
|
|||||||
.setNegativeButton(R.string.cancel) { d, _ ->
|
.setNegativeButton(R.string.cancel) { d, _ ->
|
||||||
d.dismiss()
|
d.dismiss()
|
||||||
binding.btnTransfer.isEnabled = true
|
binding.btnTransfer.isEnabled = true
|
||||||
|
endTransferFlow()
|
||||||
}
|
}
|
||||||
.setCancelable(false)
|
.setCancelable(false)
|
||||||
.show()
|
.show()
|
||||||
@@ -583,6 +611,7 @@ class MfaisaTransferHandler(
|
|||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) { confirmWithRetry(source, refId, otp) }
|
withContext(Dispatchers.IO) { confirmWithRetry(source, refId, otp) }
|
||||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
endTransferFlow()
|
||||||
val receipt = TransferReceiptData(
|
val receipt = TransferReceiptData(
|
||||||
bank = "MFAISA",
|
bank = "MFAISA",
|
||||||
amount = amountValue,
|
amount = amountValue,
|
||||||
@@ -607,6 +636,7 @@ class MfaisaTransferHandler(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
binding.btnTransfer.isEnabled = true
|
binding.btnTransfer.isEnabled = true
|
||||||
|
endTransferFlow()
|
||||||
showError(e)
|
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
|
### Setup
|
||||||
|
|
||||||
1. [Set up BML](01-setup-bml.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) |
|
||||||
2. [Set up MIB](02-setup-mib.md)
|
|:---:|:---:|
|
||||||
|
| [1. Set up BML](01-setup-bml.md) | [2. Set up MIB](02-setup-mib.md) |
|
||||||
|
|
||||||
### Export
|
### Export
|
||||||
|
|
||||||
3. [Export from Google Authenticator](03-export-googleauthenticator.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) |
|
||||||
4. [Export from Microsoft Authenticator](04-export-microsoft.md)
|
|:---:|:---:|:---:|
|
||||||
5. [Export from Bitwarden](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