18 Commits
Author SHA1 Message Date
shihaam ffee918258 releave version 1.0.15
Build and Release APK / build (push) Successful in 2m28s
Auto Tag on Version Change / check-version (push) Failing after 13m46s
2026-06-03 04:14:13 +05:00
shihaam fc7fa420b2 auto rotate wheel when tapping icon 2026-06-03 04:12:58 +05:00
shihaam 5f6ec236bf redsign wheel page (reorgnatize wheel)
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-06-03 04:10:05 +05:00
shihaam 890cf15fd0 redsign wheel page (action bar and logo
Auto Tag on Version Change / check-version (push) Failing after 10m26s
2026-06-03 03:57:35 +05:00
shihaam 98a003727b customize circular nav
Auto Tag on Version Change / check-version (push) Failing after 11m21s
2026-06-03 02:21:39 +05:00
shihaam 9ca13d3518 New UI nav mode: Circular
Auto Tag on Version Change / check-version (push) Failing after 14m55s
2026-06-03 01:48:04 +05:00
shihaam 395e2308a0 keep transfer form in cache (no reset on page change
Auto Tag on Version Change / check-version (push) Failing after 12m9s
Build and Release APK / build (push) Failing after 16m4s
2026-05-31 01:25:50 +05:00
shihaam ad7c5a4e5b release version 1.0.14
Auto Tag on Version Change / check-version (push) Has been cancelled
Build and Release APK / build (push) Has been cancelled
2026-05-31 01:14:00 +05:00
shihaam 0ba2396c2c auto pick default account when selecting contact from contact picker or trsnafering from contacts
Auto Tag on Version Change / check-version (push) Has been cancelled
2026-05-31 01:13:36 +05:00
shihaam 173c02ab8f release version 1.0.13
Build and Release APK / build (push) Successful in 3m12s
Auto Tag on Version Change / check-version (push) Failing after 14m33s
2026-05-31 00:28:28 +05:00
shihaam b37b12996f Contacts after selecting account back button Behaviour Enchantment #30
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-31 00:26:10 +05:00
shihaam 21203b39e7 rearrange buttons in contact page #28
Auto Tag on Version Change / check-version (push) Failing after 15m6s
2026-05-31 00:22:55 +05:00
shihaam 0be492ca18 remove custom logic for scan button in PayMV QR generate and just handoff to transfer page to handle auto select account
Auto Tag on Version Change / check-version (push) Failing after 15m1s
2026-05-31 00:18:01 +05:00
shihaam 973576cf6a save static BML QR scans to recents #31
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-31 00:06:37 +05:00
shihaam 4523aed69e remove *** on customer amount set PayMV QRs, #29
Auto Tag on Version Change / check-version (push) Successful in 7s
2026-05-30 23:26:55 +05:00
shihaam f90d83b59e improve share to scan to pay flow: fetching merchant details
Auto Tag on Version Change / check-version (push) Failing after 10m51s
2026-05-30 23:22:10 +05:00
shihaam a03b1b1682 improve QR scan flow part:1 unified
Auto Tag on Version Change / check-version (push) Failing after 14m4s
2026-05-30 22:53:56 +05:00
shihaam bc958e2df6 fallback to use transfer if user scanned a paymv qr after selecting a card 2026-05-30 22:29:28 +05:00
16 changed files with 886 additions and 111 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "sh.sar.basedbank" applicationId = "sh.sar.basedbank"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 11 versionCode = 14
versionName = "1.0.12" versionName = "1.0.15"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -33,6 +33,8 @@ import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentBmlQrPayBinding import sh.sar.basedbank.databinding.FragmentBmlQrPayBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache
import sh.sar.basedbank.util.Totp import sh.sar.basedbank.util.Totp
class BmlQrPayFragment : Fragment() { class BmlQrPayFragment : Fragment() {
@@ -150,6 +152,19 @@ class BmlQrPayFragment : Fragment() {
return@launch return@launch
} }
merchantInfo = info merchantInfo = info
if (info.amount == 0.0) {
val qrUrl = arguments?.getString(ARG_QR_URL)
if (qrUrl != null) {
RecentsCache.save(requireContext(), RecentPick(
accountNumber = "bmlqr:$qrUrl",
displayName = info.merchantName,
subtitle = info.merchantAddress.ifBlank { "BML Merchant" },
colorHex = "#0066A1",
imageHash = null,
isProfileImage = false
))
}
}
populateMerchant(info) populateMerchant(info)
} }
} }
@@ -0,0 +1,467 @@
package sh.sar.basedbank.ui.home
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.*
import android.os.Bundle
import android.util.AttributeSet
import android.util.TypedValue
import android.view.*
import android.view.animation.DecelerateInterpolator
import android.animation.AnimatorListenerAdapter
import android.animation.Animator
import android.widget.FrameLayout
import android.graphics.Typeface
import androidx.annotation.DrawableRes
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.Toolbar
import androidx.core.graphics.drawable.DrawableCompat
import androidx.fragment.app.Fragment
import com.google.android.material.color.MaterialColors
import sh.sar.basedbank.R
import kotlin.math.*
class CircularNavFragment : Fragment() {
private var wheelView: CircularWheelView? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val ctx = requireContext()
val colorPrimary = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorPrimary, Color.RED)
val colorSurface = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSurface, Color.WHITE)
val colorOnSurface = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, Color.DKGRAY)
fun dp(v: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, ctx.resources.displayMetrics)
val root = android.widget.LinearLayout(ctx).apply {
orientation = android.widget.LinearLayout.VERTICAL
setBackgroundColor(colorSurface)
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
// Wheel area (weight 1, fills remaining space)
val wheelContainer = FrameLayout(ctx).apply {
layoutParams = android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f
)
}
val prefs = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
wheelView = CircularWheelView(ctx).apply {
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
wheelAngle = prefs.getFloat("circular_wheel_angle", 0f)
val savedSlots = NavCustomization.getCircularSlots(prefs).map { id ->
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == id }!!
CircularWheelView.WheelItem(def.id, def.iconRes, ctx.getString(def.titleRes))
}
items = listOf(
savedSlots[3], // 4 o'clock (strip slot 3)
CircularWheelView.WheelItem(R.id.nav_dashboard, R.drawable.ic_nav_dashboard, ctx.getString(R.string.nav_dashboard)), // 6 o'clock
CircularWheelView.WheelItem(R.id.nav_more, R.drawable.ic_nav_more, ctx.getString(R.string.nav_more)), // 8 o'clock
savedSlots[0], // 10 o'clock (strip slot 0 — first in strip)
savedSlots[1], // 12 o'clock (strip slot 1)
savedSlots[2], // 2 o'clock (strip slot 2)
)
accentColor = colorPrimary
surfaceColor = colorSurface
labelColor = colorOnSurface
onItemClick = { navId -> (activity as? HomeActivity)?.navigateTo(navId) }
onCenterClick = { (activity as? HomeActivity)?.lockApp() }
}
wheelContainer.addView(wheelView)
// App icon centered at the bottom
val iconSz = dp(48f).toInt()
val footerIcon = android.widget.ImageView(ctx).apply {
setImageDrawable(ctx.packageManager.getApplicationIcon(ctx.packageName))
layoutParams = android.widget.LinearLayout.LayoutParams(iconSz, iconSz).also {
it.gravity = Gravity.CENTER_HORIZONTAL
it.topMargin = dp(12f).toInt()
it.bottomMargin = dp(16f).toInt()
}
}
root.addView(wheelContainer)
root.addView(footerIcon)
return root
}
override fun onResume() {
super.onResume()
requireActivity().invalidateOptionsMenu()
val ctx = requireContext()
val toolbar = requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar)
requireActivity().title = ""
val textColor = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.DKGRAY)
val container = android.widget.TextView(ctx).apply {
text = getString(R.string.app_name)
setTextColor(textColor)
textSize = 20f
typeface = Typeface.DEFAULT_BOLD
tag = "wheel_title"
}
toolbar.addView(container, Toolbar.LayoutParams(
Toolbar.LayoutParams.WRAP_CONTENT,
Toolbar.LayoutParams.WRAP_CONTENT,
Gravity.CENTER
))
}
override fun onPause() {
super.onPause()
wheelView?.let { wv ->
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
.edit().putFloat("circular_wheel_angle", wv.wheelAngle).apply()
}
val toolbar = requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar)
toolbar.findViewWithTag<android.view.View>("wheel_title")?.let { toolbar.removeView(it) }
requireActivity().invalidateOptionsMenu()
}
}
// ---------------------------------------------------------------------------
// Custom wheel view
// ---------------------------------------------------------------------------
class CircularWheelView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
data class WheelItem(
val navId: Int,
@DrawableRes val iconRes: Int,
val label: String
)
// ---- public properties ------------------------------------------------
var items: List<WheelItem> = emptyList()
set(value) {
field = value
iconBitmaps = arrayOfNulls(value.size)
if (cx > 0f) reloadBitmaps()
invalidate()
}
var accentColor: Int = Color.RED
set(value) { field = value; if (cx > 0f) reloadBitmaps(); invalidate() }
var surfaceColor: Int = Color.WHITE
set(value) { field = value; invalidate() }
var labelColor: Int = Color.DKGRAY
set(value) { field = value; invalidate() }
var onItemClick: ((Int) -> Unit)? = null
var onCenterClick: (() -> Unit)? = null
// ---- geometry ---------------------------------------------------------
private var cx = 0f
private var cy = 0f
private var outerRadius = 0f
private var innerRadius = 0f
// ---- paint ------------------------------------------------------------
private val discPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val accentRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
private val accentRing2Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
private val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = Typeface.DEFAULT_BOLD
}
private val centerFillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val centerRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
private val bitmapPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private var iconBitmaps: Array<Bitmap?> = emptyArray()
private var centerBitmap: Bitmap? = null
// ---- touch & fling ----------------------------------------------------
var wheelAngle = 0f
private var isDragging = false
private var snapAnimator: ValueAnimator? = null
// Incremental drag state
private var prevTouchAngle = 0f
private var touchDownX = 0f
private var touchDownY = 0f
// Velocity buffer: stores (cumulative wheel angle, timestamp) for last N samples
private val VEL_SAMPLES = 6
private val velAngles = FloatArray(VEL_SAMPLES)
private val velTimes = LongArray(VEL_SAMPLES)
private var velIdx = 0
private var velCount = 0
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop.toFloat()
// ---- helpers ----------------------------------------------------------
private fun dp(v: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, resources.displayMetrics)
// ---- sizing -----------------------------------------------------------
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
cx = w / 2f
cy = h / 2f
val size = minOf(w, h)
outerRadius = size / 2f * 0.80f
innerRadius = outerRadius * 0.26f
textPaint.textSize = size * 0.034f
dividerPaint.strokeWidth = dp(0.7f)
accentRingPaint.strokeWidth = dp(5f)
accentRing2Paint.strokeWidth = dp(3f)
centerRingPaint.strokeWidth = dp(4f)
reloadBitmaps()
}
private fun reloadBitmaps() {
val iconPx = (outerRadius * 0.24f).toInt().coerceAtLeast(1)
items.forEachIndexed { i, item ->
iconBitmaps[i] = tintedBitmap(item.iconRes, accentColor, iconPx)
}
val centerPx = (innerRadius * 0.64f).toInt().coerceAtLeast(1)
centerBitmap = tintedBitmap(R.drawable.ic_lock, accentColor, centerPx)
}
private fun tintedBitmap(@DrawableRes resId: Int, tint: Int, sizePx: Int): Bitmap? {
if (sizePx <= 0) return null
return try {
val d = AppCompatResources.getDrawable(context, resId)!!.mutate()
DrawableCompat.setTint(DrawableCompat.wrap(d), tint)
val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
Canvas(bmp).also { d.setBounds(0, 0, sizePx, sizePx); d.draw(it) }
bmp
} catch (_: Exception) { null }
}
// ---- drawing ----------------------------------------------------------
override fun onDraw(canvas: Canvas) {
if (items.isEmpty()) return
val segCount = items.size
val segDeg = 360f / segCount
// Wheel disc
discPaint.color = surfaceColor
canvas.drawCircle(cx, cy, outerRadius, discPaint)
// Accent ring around wheel
accentRingPaint.color = accentColor
canvas.drawCircle(cx, cy, outerRadius + dp(20f), accentRingPaint)
// Rotatable layer
canvas.save()
canvas.rotate(wheelAngle, cx, cy)
// Divider lines between segments
dividerPaint.color = (labelColor and 0x00FFFFFF) or (100 shl 24)
for (i in 0 until segCount) {
val rad = Math.toRadians((i * segDeg).toDouble())
val cos = cos(rad).toFloat()
val sin = sin(rad).toFloat()
canvas.drawLine(
cx + cos * (innerRadius + dp(6f)), cy + sin * (innerRadius + dp(6f)),
cx + cos * (outerRadius - dp(12f)), cy + sin * (outerRadius - dp(12f)),
dividerPaint
)
}
// Segment content
for (i in 0 until segCount) {
val midDeg = i * segDeg + segDeg / 2f
drawSegment(canvas, i, midDeg)
}
canvas.restore()
// Center button — always upright
centerRingPaint.color = accentColor
canvas.drawCircle(cx, cy, innerRadius + dp(3f), centerRingPaint)
centerFillPaint.color = surfaceColor
canvas.drawCircle(cx, cy, innerRadius, centerFillPaint)
centerBitmap?.let {
canvas.drawBitmap(it, cx - it.width / 2f, cy - it.height / 2f, bitmapPaint)
}
}
private fun drawSegment(canvas: Canvas, index: Int, midDeg: Float) {
val rad = Math.toRadians(midDeg.toDouble())
val cosA = cos(rad).toFloat()
val sinA = sin(rad).toFloat()
val iconX = cx + cosA * (outerRadius * 0.63f)
val iconY = cy + sinA * (outerRadius * 0.63f)
val textX = cx + cosA * (outerRadius * 0.84f)
val textY = cy + sinA * (outerRadius * 0.84f)
// Icon — radially oriented; top items are naturally upside-down
iconBitmaps.getOrNull(index)?.let { bmp ->
canvas.save()
canvas.translate(iconX, iconY)
canvas.rotate(midDeg - 90f)
canvas.drawBitmap(bmp, -bmp.width / 2f, -bmp.height / 2f, bitmapPaint)
canvas.restore()
}
// Label — same radial rotation
textPaint.color = labelColor
canvas.save()
canvas.translate(textX, textY)
canvas.rotate(midDeg - 90f)
canvas.drawText(items[index].label, 0f, textPaint.textSize * 0.36f, textPaint)
canvas.restore()
}
// ---- touch ------------------------------------------------------------
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
snapAnimator?.cancel()
prevTouchAngle = angleAt(event.x, event.y)
touchDownX = event.x
touchDownY = event.y
isDragging = false
velIdx = 0
velCount = 0
recordVelSample()
return true
}
MotionEvent.ACTION_MOVE -> {
val curr = angleAt(event.x, event.y)
// Incremental delta — normalised to [-180, 180] to survive the ±180° wrap
var dA = curr - prevTouchAngle
if (dA > 180f) dA -= 360f
if (dA < -180f) dA += 360f
prevTouchAngle = curr
val moved = hypot(event.x - touchDownX, event.y - touchDownY)
if (moved > touchSlop || isDragging) {
isDragging = true
wheelAngle += dA
recordVelSample()
invalidate()
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (!isDragging) {
val dist = hypot(event.x - cx, event.y - cy)
when {
dist <= innerRadius -> onCenterClick?.invoke()
dist <= outerRadius -> {
val idx = segmentAt(event.x, event.y)
if (idx in items.indices) animateToSixOClock(idx) { onItemClick?.invoke(items[idx].navId) }
}
}
} else {
val vel = computeVelocity() // degrees per millisecond
if (abs(vel) > 0.05f) fling(vel) else snapToNearest()
}
}
}
return true
}
private fun recordVelSample() {
val slot = velIdx % VEL_SAMPLES
velAngles[slot] = wheelAngle
velTimes[slot] = System.currentTimeMillis()
velIdx++
if (velCount < VEL_SAMPLES) velCount++
}
/** Returns angular velocity in degrees per millisecond, using the oldest available sample. */
private fun computeVelocity(): Float {
if (velCount < 2) return 0f
val newest = (velIdx - 1 + VEL_SAMPLES) % VEL_SAMPLES
// Use the sample that is ~100 ms old for a stable estimate
val oldest = (velIdx - velCount + VEL_SAMPLES) % VEL_SAMPLES
val dt = velTimes[newest] - velTimes[oldest]
if (dt <= 0L) return 0f
return (velAngles[newest] - velAngles[oldest]) / dt
}
/**
* Kick off a physics-based fling: uniform deceleration from [initialVel] to zero,
* then snap to the nearest segment.
* Formula: total_rotation = v0² / (2 * DECEL), duration = v0 / DECEL
* With DecelerateInterpolator(1) the initial animation velocity matches v0.
*/
private fun fling(initialVel: Float) {
val DECEL = 0.0008f // deg / ms² — tune for feel
val duration = (abs(initialVel) / DECEL).toLong().coerceIn(200, 3500)
val sign = if (initialVel >= 0f) 1f else -1f
val totalRot = sign * initialVel * initialVel / (2f * DECEL)
val startAngle = wheelAngle
val endAngle = startAngle + totalRot
snapAnimator = ValueAnimator.ofFloat(startAngle, endAngle).apply {
this.duration = duration
interpolator = DecelerateInterpolator() // matches v0 at t=0
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(a: Animator) { snapToNearest() }
})
start()
}
}
private fun angleAt(x: Float, y: Float): Float =
Math.toDegrees(atan2((y - cy).toDouble(), (x - cx).toDouble())).toFloat()
private fun segmentAt(x: Float, y: Float): Int {
var a = angleAt(x, y) - wheelAngle
a = (a % 360f + 360f) % 360f
return (a / (360f / items.size)).toInt() % items.size
}
private fun animateToSixOClock(index: Int, onDone: () -> Unit) {
val segDeg = 360f / items.size.coerceAtLeast(1)
val midDeg = index * segDeg + segDeg / 2f
// delta needed so this segment's midpoint lands at 90° (6 o'clock in math coords)
var delta = (90f - midDeg) - wheelAngle
// normalise to shortest path [-180, 180]
delta = ((delta % 360f) + 360f) % 360f
if (delta > 180f) delta -= 360f
val endAngle = wheelAngle + delta
snapAnimator?.cancel()
snapAnimator = ValueAnimator.ofFloat(wheelAngle, endAngle).apply {
duration = 350
interpolator = DecelerateInterpolator()
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
addListener(object : AnimatorListenerAdapter() {
private var cancelled = false
override fun onAnimationCancel(a: Animator) { cancelled = true }
override fun onAnimationEnd(a: Animator) { if (!cancelled) onDone() }
})
start()
}
}
private fun snapToNearest() {
val segDeg = 360f / items.size.coerceAtLeast(1)
val target = (wheelAngle / segDeg).roundToInt() * segDeg
snapAnimator = ValueAnimator.ofFloat(wheelAngle, target).apply {
duration = 300
interpolator = DecelerateInterpolator()
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
start()
}
}
}
@@ -168,6 +168,10 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val account = accounts.firstOrNull { it.accountNumber == accountNumber } val account = accounts.firstOrNull { it.accountNumber == accountNumber }
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label) val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
when { when {
accountNumber.startsWith("bmlqr:") -> {
bundle.putString(KEY_SUBTITLE, "BML QR Merchant")
bundle.putString(KEY_COLOR, "#0066A1")
}
account != null -> { account != null -> {
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}") bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
bundle.putString(KEY_COLOR, "#FE860E") bundle.putString(KEY_COLOR, "#FE860E")
@@ -178,7 +178,7 @@ class ContactsFragment : Fragment() {
colorHex = contact.bankColor, colorHex = contact.bankColor,
imageHash = contact.imageHash imageHash = contact.imageHash
) )
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment) (requireActivity() as HomeActivity).showWithBackStack(fragment)
} }
private fun confirmDelete(contact: ContactDisplay) { private fun confirmDelete(contact: ContactDisplay) {
@@ -4,13 +4,13 @@ import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
@@ -25,8 +25,8 @@ import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibCard import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.util.bmlapi.BmlCardParser import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.PaymvQrParser
import kotlin.math.abs import kotlin.math.abs
import sh.sar.basedbank.databinding.FragmentDashboardBinding import sh.sar.basedbank.databinding.FragmentDashboardBinding
import sh.sar.basedbank.databinding.ItemForeignLimitBinding import sh.sar.basedbank.databinding.ItemForeignLimitBinding
@@ -36,21 +36,35 @@ class DashboardFragment : Fragment() {
private var _binding: FragmentDashboardBinding? = null private var _binding: FragmentDashboardBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels() private val viewModel: HomeViewModel by activityViewModels()
private var pendingQrCardNumber: String? = null
private var pendingQrAccountNumber: String? = null
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw) val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) { if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
(requireActivity() as HomeActivity).navigateTo( (requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, pendingQrAccountNumber) R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
)
} else {
val qr = PaymvQrParser.parse(raw)
if (qr?.accountNumber != null) {
Toast.makeText(requireContext(), R.string.card_qr_paymv_unsupported, Toast.LENGTH_SHORT).show()
val defaultFrom = CredentialStore(requireContext()).getDefaultAccountNumber()
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromQr(
accountNumber = qr.accountNumber,
displayName = qr.merchantName ?: qr.accountNumber,
amount = qr.amount,
remarks = qr.purpose,
fromAccountNumber = defaultFrom
)
) )
} else { } else {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
} }
pendingQrAccountNumber = null }
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@@ -120,7 +134,7 @@ class DashboardFragment : Fragment() {
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt() val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets -> ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false) val isBottomNav = NavCustomization.getNavMode(requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE)) == NavCustomization.NAV_MODE_BOTTOM
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars()) val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom) v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
@@ -131,8 +145,7 @@ class DashboardFragment : Fragment() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
val isBottom = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) val isBottom = NavCustomization.getNavMode(requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)) == NavCustomization.NAV_MODE_BOTTOM
.getBoolean("bottom_nav", false)
if (isBottom) { if (isBottom) {
requireActivity().title = getString(R.string.app_name) requireActivity().title = getString(R.string.app_name)
val size = (28 * resources.displayMetrics.density).toInt() val size = (28 * resources.displayMetrics.density).toInt()
@@ -157,7 +170,7 @@ class DashboardFragment : Fragment() {
private fun refreshQuickActions() { private fun refreshQuickActions() {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val isBottom = prefs.getBoolean("bottom_nav", false) val isBottom = NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM
if (isBottom) { if (isBottom) {
binding.buttonBar.visibility = View.GONE binding.buttonBar.visibility = View.GONE
return return
@@ -402,7 +415,7 @@ class DashboardFragment : Fragment() {
if (isMib) { if (isMib) {
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
} else { } else {
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber pendingQrCardNumber = (item as CardItem.Bml).account.accountNumber
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java)) qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
} }
} }
@@ -76,6 +76,7 @@ class HomeActivity : AppCompatActivity() {
private val viewModel: HomeViewModel by viewModels() private val viewModel: HomeViewModel by viewModels()
private lateinit var toggle: ActionBarDrawerToggle private lateinit var toggle: ActionBarDrawerToggle
private var suppressBottomNavCallback = false private var suppressBottomNavCallback = false
private var cachedTransferFragment: TransferFragment? = null
private var backPressedOnce = false private var backPressedOnce = false
private val backPressHandler = Handler(Looper.getMainLooper()) private val backPressHandler = Handler(Looper.getMainLooper())
@@ -98,6 +99,8 @@ class HomeActivity : AppCompatActivity() {
if (securitySet) lock() if (securitySet) lock()
} }
fun lockApp() = lock()
private fun lock() { private fun lock() {
isLocked = true isLocked = true
startActivity( startActivity(
@@ -156,7 +159,7 @@ class HomeActivity : AppCompatActivity() {
R.id.nav_dashboard -> DashboardFragment() R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> AccountsFragment() R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> ContactsFragment() R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> TransferFragment() R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
R.id.nav_pay_mv_qr -> PayMvQrFragment() R.id.nav_pay_mv_qr -> PayMvQrFragment()
R.id.nav_more -> MoreFragment() R.id.nav_more -> MoreFragment()
R.id.nav_activities -> ActivitiesFragment() R.id.nav_activities -> ActivitiesFragment()
@@ -254,11 +257,16 @@ class HomeActivity : AppCompatActivity() {
navigateTo(navDest, fragment) navigateTo(navDest, fragment)
} }
else -> { else -> {
val initPrefs = getSharedPreferences("prefs", MODE_PRIVATE)
if (NavCustomization.getNavMode(initPrefs) == NavCustomization.NAV_MODE_CIRCULAR) {
show(CircularNavFragment())
} else {
show(DashboardFragment()) show(DashboardFragment())
binding.navigationView.setCheckedItem(R.id.nav_dashboard) binding.navigationView.setCheckedItem(R.id.nav_dashboard)
} }
} }
} }
}
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
@@ -270,14 +278,38 @@ class HomeActivity : AppCompatActivity() {
// Let CardsFragment handle back if in manage mode // Let CardsFragment handle back if in manage mode
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame) val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
if (currentFrag is CardsFragment && currentFrag.onBackPressed()) return if (currentFrag is CardsFragment && currentFrag.onBackPressed()) return
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val navMode = NavCustomization.getNavMode(prefs)
// Circular nav mode: back always returns to the wheel
if (navMode == NavCustomization.NAV_MODE_CIRCULAR) {
if (supportFragmentManager.backStackEntryCount > 0) {
supportFragmentManager.popBackStack()
return
}
if (currentFrag is CircularNavFragment) {
if (backPressedOnce) {
backPressHandler.removeCallbacks(resetBackPress)
finish()
} else {
backPressedOnce = true
Toast.makeText(this@HomeActivity, R.string.press_back_to_exit, Toast.LENGTH_SHORT).show()
backPressHandler.postDelayed(resetBackPress, 2000)
}
} else {
show(CircularNavFragment())
}
return
}
// Pop fragment back stack if there's anything on it (e.g. showWithBackStack) // Pop fragment back stack if there's anything on it (e.g. showWithBackStack)
if (supportFragmentManager.backStackEntryCount > 0) { if (supportFragmentManager.backStackEntryCount > 0) {
supportFragmentManager.popBackStack() supportFragmentManager.popBackStack()
return return
} }
// In bottom nav mode, pressing back navigates up the hierarchy // In bottom nav mode, pressing back navigates up the hierarchy
val isBottomNav = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false) if (navMode == NavCustomization.NAV_MODE_BOTTOM && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
if (isBottomNav && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
// Sub-page reached via More (e.g. Settings, Activities) — go back to More // Sub-page reached via More (e.g. Settings, Activities) — go back to More
if (binding.bottomNavigation.selectedItemId == R.id.nav_more && currentFrag !is MoreFragment) { if (binding.bottomNavigation.selectedItemId == R.id.nav_more && currentFrag !is MoreFragment) {
show(MoreFragment()) show(MoreFragment())
@@ -335,21 +367,30 @@ class HomeActivity : AppCompatActivity() {
fun applyNavMode() { fun applyNavMode() {
val prefs = getSharedPreferences("prefs", MODE_PRIVATE) val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val isBottom = prefs.getBoolean("bottom_nav", false) when (NavCustomization.getNavMode(prefs)) {
if (isBottom) { NavCustomization.NAV_MODE_BOTTOM -> {
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED) binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
toggle.isDrawerIndicatorEnabled = false toggle.isDrawerIndicatorEnabled = false
supportActionBar?.setDisplayHomeAsUpEnabled(false) supportActionBar?.setDisplayHomeAsUpEnabled(false)
binding.bottomNavigation.visibility = View.VISIBLE binding.bottomNavigation.visibility = View.VISIBLE
rebuildBottomNav(prefs) rebuildBottomNav(prefs)
applyNavLabelVisibility() applyNavLabelVisibility()
} else { }
NavCustomization.NAV_MODE_CIRCULAR -> {
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
toggle.isDrawerIndicatorEnabled = false
supportActionBar?.setDisplayHomeAsUpEnabled(false)
binding.bottomNavigation.visibility = View.GONE
}
else -> {
supportActionBar?.show()
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED) binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
toggle.isDrawerIndicatorEnabled = true toggle.isDrawerIndicatorEnabled = true
toggle.syncState() toggle.syncState()
binding.bottomNavigation.visibility = View.GONE binding.bottomNavigation.visibility = View.GONE
} }
} }
}
fun rebuildBottomNav(prefs: android.content.SharedPreferences = getSharedPreferences("prefs", MODE_PRIVATE)) { fun rebuildBottomNav(prefs: android.content.SharedPreferences = getSharedPreferences("prefs", MODE_PRIVATE)) {
val slots = NavCustomization.getSlots(prefs) val slots = NavCustomization.getSlots(prefs)
@@ -386,11 +427,15 @@ fun applyNavLabelVisibility() {
} }
fun navigateTo(itemId: Int, fragment: Fragment? = null) { fun navigateTo(itemId: Int, fragment: Fragment? = null) {
// Restore action bar when leaving the circular wheel screen
if (NavCustomization.getNavMode(getSharedPreferences("prefs", MODE_PRIVATE)) == NavCustomization.NAV_MODE_CIRCULAR) {
supportActionBar?.show()
}
val dest = fragment ?: when (itemId) { val dest = fragment ?: when (itemId) {
R.id.nav_dashboard -> DashboardFragment() R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> AccountsFragment() R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> ContactsFragment() R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> TransferFragment() R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
R.id.nav_pay_mv_qr -> PayMvQrFragment() R.id.nav_pay_mv_qr -> PayMvQrFragment()
R.id.nav_activities -> ActivitiesFragment() R.id.nav_activities -> ActivitiesFragment()
R.id.nav_transfer_history -> TransferHistoryFragment() R.id.nav_transfer_history -> TransferHistoryFragment()
@@ -398,6 +443,7 @@ fun applyNavLabelVisibility() {
R.id.nav_otp -> OtpFragment() R.id.nav_otp -> OtpFragment()
R.id.nav_settings -> SettingsFragment() R.id.nav_settings -> SettingsFragment()
R.id.nav_pay_with_card -> CardsFragment() R.id.nav_pay_with_card -> CardsFragment()
R.id.nav_more -> MoreFragment()
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return } else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
} }
show(dest) show(dest)
@@ -415,8 +461,8 @@ fun applyNavLabelVisibility() {
} }
fun setBottomNavVisible(visible: Boolean) { fun setBottomNavVisible(visible: Boolean) {
val isBottom = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false) val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
if (isBottom) { if (NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM) {
binding.bottomNavigation.visibility = if (visible) View.VISIBLE else View.GONE binding.bottomNavigation.visibility = if (visible) View.VISIBLE else View.GONE
} }
} }
@@ -446,9 +492,10 @@ fun applyNavLabelVisibility() {
} }
private fun routeSharedQrText(text: String) { private fun routeSharedQrText(text: String) {
val store = CredentialStore(this)
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text) val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text)
if (text.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) { if (text.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: text)) navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: text, store.getDefaultCardAccountNumber()))
return return
} }
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text) val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
@@ -457,7 +504,8 @@ fun applyNavLabelVisibility() {
accountNumber = qr.accountNumber, accountNumber = qr.accountNumber,
displayName = qr.merchantName ?: qr.accountNumber, displayName = qr.merchantName ?: qr.accountNumber,
amount = qr.amount, amount = qr.amount,
remarks = qr.purpose remarks = qr.purpose,
fromAccountNumber = store.getDefaultAccountNumber()
)) ))
} else { } else {
Toast.makeText(this, R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
@@ -557,6 +605,13 @@ fun applyNavLabelVisibility() {
return true return true
} }
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
val onWheel = supportFragmentManager.findFragmentById(R.id.contentFrame) is CircularNavFragment
menu.findItem(R.id.action_hide_amounts)?.isVisible = !onWheel
menu.findItem(R.id.action_lock)?.isVisible = !onWheel
return super.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.action_lock) { if (item.itemId == R.id.action_lock) {
val avd = getDrawable(R.drawable.avd_lock) as? android.graphics.drawable.AnimatedVectorDrawable val avd = getDrawable(R.drawable.avd_lock) as? android.graphics.drawable.AnimatedVectorDrawable
@@ -7,6 +7,20 @@ import sh.sar.basedbank.R
object NavCustomization { object NavCustomization {
const val NAV_MODE_DRAWER = "drawer"
const val NAV_MODE_BOTTOM = "bottom"
const val NAV_MODE_CIRCULAR = "circular"
fun getNavMode(prefs: SharedPreferences): String {
val explicit = prefs.getString("nav_mode", null)
if (explicit != null) return explicit
return if (prefs.getBoolean("bottom_nav", false)) NAV_MODE_BOTTOM else NAV_MODE_DRAWER
}
fun saveNavMode(prefs: SharedPreferences, mode: String) {
prefs.edit().putString("nav_mode", mode).apply()
}
data class NavItemDef( data class NavItemDef(
val id: Int, val id: Int,
val key: String, val key: String,
@@ -62,8 +76,31 @@ object NavCustomization {
} }
/** Items that belong in the "More" screen — those not occupying a bottom nav slot. */ /** Items that belong in the "More" screen — those not occupying a bottom nav slot. */
fun getCircularSlots(prefs: SharedPreferences): List<Int> = listOf(
keyToId(prefs.getString("circular_slot_1_key", null), R.id.nav_transfer),
keyToId(prefs.getString("circular_slot_2_key", null), R.id.nav_pay_with_card),
keyToId(prefs.getString("circular_slot_3_key", null), R.id.nav_contacts),
keyToId(prefs.getString("circular_slot_4_key", null), R.id.nav_accounts),
)
fun saveCircularSlots(prefs: SharedPreferences, slots: List<Int>) {
prefs.edit()
.putString("circular_slot_1_key", idToKey(slots[0]) ?: "nav_transfer")
.putString("circular_slot_2_key", idToKey(slots[1]) ?: "nav_pay_with_card")
.putString("circular_slot_3_key", idToKey(slots[2]) ?: "nav_contacts")
.putString("circular_slot_4_key", idToKey(slots[3]) ?: "nav_accounts")
.apply()
}
fun getMoreItems(prefs: SharedPreferences): List<NavItemDef> { fun getMoreItems(prefs: SharedPreferences): List<NavItemDef> {
if (getNavMode(prefs) == NAV_MODE_CIRCULAR) return getCircularMoreItems(prefs)
val slots = getSlots(prefs).toSet() val slots = getSlots(prefs).toSet()
return ALL_SWAPPABLE.filter { it.id !in slots } return ALL_SWAPPABLE.filter { it.id !in slots }
} }
/** Items shown in More when circular nav is active — everything not in the saved wheel slots. */
private fun getCircularMoreItems(prefs: SharedPreferences): List<NavItemDef> {
val slotIds = getCircularSlots(prefs).toSet()
return ALL_SWAPPABLE.filter { it.id !in slotIds }
}
} }
@@ -7,13 +7,10 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.app.Activity
import android.content.Intent
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.* import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
@@ -39,7 +36,6 @@ import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.util.AccountListParser import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.bmlapi.BmlCardParser import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
import java.io.File import java.io.File
@@ -56,31 +52,6 @@ class PayMvQrFragment : Fragment() {
private var generateJob: Job? = null private var generateJob: Job? = null
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>() private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
// BML card/gateway QR — hand off to dedicated payment screen
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw))
return@registerForActivityResult
}
val qr = PaymvQrParser.parse(raw)
if (qr == null || qr.accountNumber == null) {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
return@registerForActivityResult
}
val activity = requireActivity() as HomeActivity
activity.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromQr(
accountNumber = qr.accountNumber,
displayName = qr.merchantName ?: qr.accountNumber,
amount = qr.amount,
remarks = qr.purpose
))
}
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View { ): View {
@@ -106,7 +77,7 @@ class PayMvQrFragment : Fragment() {
binding.btnShare.setOnClickListener { shareQr() } binding.btnShare.setOnClickListener { shareQr() }
binding.btnSave.setOnClickListener { saveQr() } binding.btnSave.setOnClickListener { saveQr() }
binding.btnScanQr.setOnClickListener { binding.btnScanQr.setOnClickListener {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java)) (requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceWithAutoScan())
} }
} }
@@ -3,6 +3,7 @@ package sh.sar.basedbank.ui.home
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.activity.result.contract.ActivityResultContracts
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import android.os.Bundle import android.os.Bundle
@@ -13,7 +14,6 @@ import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import android.view.animation.AccelerateInterpolator import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator import android.view.animation.DecelerateInterpolator
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
@@ -60,8 +60,37 @@ class CardsFragment : Fragment() {
private var cards: List<CardItem> = emptyList() private var cards: List<CardItem> = emptyList()
private var currentCardPosition: Int = 0 private var currentCardPosition: Int = 0
private var cardWidth: Int = 0 private var cardWidth: Int = 0
private var pendingQrAccountNumber: String? = null private var pendingQrCardNumber: String? = null
private var isManageMode: Boolean = false private var isManageMode: Boolean = false
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
)
} else {
val qr = PaymvQrParser.parse(raw)
if (qr?.accountNumber != null) {
Toast.makeText(requireContext(), R.string.card_qr_paymv_unsupported, Toast.LENGTH_SHORT).show()
val defaultFrom = store.getDefaultAccountNumber()
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromQr(
accountNumber = qr.accountNumber,
displayName = qr.merchantName ?: qr.accountNumber,
amount = qr.amount,
remarks = qr.purpose,
fromAccountNumber = defaultFrom
)
)
} else {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
}
}
}
private var isTapMode: Boolean = false private var isTapMode: Boolean = false
private var tapAnimView: NfcTapAnimationView? = null private var tapAnimView: NfcTapAnimationView? = null
private var autoTapModeTriggered = false private var autoTapModeTriggered = false
@@ -78,20 +107,6 @@ class CardsFragment : Fragment() {
private lateinit var stackAdapter: CardStackAdapter private lateinit var stackAdapter: CardStackAdapter
private val store by lazy { CredentialStore(requireContext()) } private val store by lazy { CredentialStore(requireContext()) }
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, pendingQrAccountNumber)
)
} else {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
}
pendingQrAccountNumber = null
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCardsBinding.inflate(inflater, container, false) _binding = FragmentCardsBinding.inflate(inflater, container, false)
return binding.root return binding.root
@@ -203,7 +218,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
if (item is CardItem.Mib) { if (item is CardItem.Mib) {
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
} else { } else {
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber pendingQrCardNumber = (item as CardItem.Bml).account.accountNumber
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java)) qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
} }
} }
@@ -36,8 +36,10 @@ class SettingsAppearanceFragment : Fragment() {
private lateinit var prefs: SharedPreferences private lateinit var prefs: SharedPreferences
private val slots = mutableListOf<Int>() private val slots = mutableListOf<Int>()
private val quickActions = mutableListOf<Int>() private val quickActions = mutableListOf<Int>()
private val circularSlots = mutableListOf<Int>()
private lateinit var slotAdapter: NavItemAdapter private lateinit var slotAdapter: NavItemAdapter
private lateinit var quickActionAdapter: NavItemAdapter private lateinit var quickActionAdapter: NavItemAdapter
private lateinit var circularSlotAdapter: NavItemAdapter
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false) _binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false)
@@ -48,11 +50,20 @@ class SettingsAppearanceFragment : Fragment() {
prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
// Navigation mode // Navigation mode
val isBottom = prefs.getBoolean("bottom_nav", false) val currentMode = NavCustomization.getNavMode(prefs)
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer) binding.navModeToggle.check(when (currentMode) {
NavCustomization.NAV_MODE_BOTTOM -> R.id.btnNavBottom
NavCustomization.NAV_MODE_CIRCULAR -> R.id.btnNavCircular
else -> R.id.btnNavDrawer
})
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked -> binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener if (!isChecked) return@addOnButtonCheckedListener
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply() val mode = when (checkedId) {
R.id.btnNavBottom -> NavCustomization.NAV_MODE_BOTTOM
R.id.btnNavCircular -> NavCustomization.NAV_MODE_CIRCULAR
else -> NavCustomization.NAV_MODE_DRAWER
}
NavCustomization.saveNavMode(prefs, mode)
(activity as? HomeActivity)?.applyNavMode() (activity as? HomeActivity)?.applyNavMode()
updateShortcutsVisibility() updateShortcutsVisibility()
} }
@@ -63,10 +74,22 @@ class SettingsAppearanceFragment : Fragment() {
quickActionAdapter = NavItemAdapter( quickActionAdapter = NavItemAdapter(
items = quickActions, items = quickActions,
onSave = { NavCustomization.saveQuickActions(prefs, quickActions) }, onSave = { NavCustomization.saveQuickActions(prefs, quickActions) },
isEnabled = { !prefs.getBoolean("bottom_nav", false) } isEnabled = { NavCustomization.getNavMode(prefs) != NavCustomization.NAV_MODE_BOTTOM }
) )
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions) { setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions) {
!prefs.getBoolean("bottom_nav", false) NavCustomization.getNavMode(prefs) != NavCustomization.NAV_MODE_BOTTOM
}
// Circular nav shortcuts
circularSlots.clear()
circularSlots.addAll(NavCustomization.getCircularSlots(prefs))
circularSlotAdapter = NavItemAdapter(
items = circularSlots,
onSave = { NavCustomization.saveCircularSlots(prefs, circularSlots) },
isEnabled = { NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_CIRCULAR }
)
setupNavItemRecyclerView(binding.rvCircularSlots, circularSlotAdapter, circularSlots) {
NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_CIRCULAR
} }
// Bottom bar shortcuts // Bottom bar shortcuts
@@ -78,10 +101,10 @@ class SettingsAppearanceFragment : Fragment() {
NavCustomization.saveSlots(prefs, slots) NavCustomization.saveSlots(prefs, slots)
(activity as? HomeActivity)?.rebuildBottomNav(prefs) (activity as? HomeActivity)?.rebuildBottomNav(prefs)
}, },
isEnabled = { prefs.getBoolean("bottom_nav", false) } isEnabled = { NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM }
) )
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) { setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) {
prefs.getBoolean("bottom_nav", false) NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM
} }
// Show labels toggle // Show labels toggle
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true) val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
@@ -191,11 +214,15 @@ class SettingsAppearanceFragment : Fragment() {
} }
private fun updateShortcutsVisibility() { private fun updateShortcutsVisibility() {
val isBottom = prefs.getBoolean("bottom_nav", false) val mode = NavCustomization.getNavMode(prefs)
binding.sectionQuickActions.alpha = if (isBottom) 0.38f else 1f val isBottom = mode == NavCustomization.NAV_MODE_BOTTOM
val isCircular = mode == NavCustomization.NAV_MODE_CIRCULAR
binding.sectionQuickActions.alpha = if (!isBottom) 1f else 0.38f
binding.sectionCircularSlots.alpha = if (isCircular) 1f else 0.38f
binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
binding.switchShowLabels.isClickable = isBottom binding.switchShowLabels.isClickable = isBottom
quickActionAdapter.notifyDataSetChanged() quickActionAdapter.notifyDataSetChanged()
circularSlotAdapter.notifyDataSetChanged()
slotAdapter.notifyDataSetChanged() slotAdapter.notifyDataSetChanged()
} }
@@ -262,9 +289,10 @@ class SettingsAppearanceFragment : Fragment() {
} }
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) { private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
val isBottom = prefs.getBoolean("bottom_nav", false) val mode = NavCustomization.getNavMode(prefs)
if (items === slots && !isBottom) return if (items === slots && mode != NavCustomization.NAV_MODE_BOTTOM) return
if (items === quickActions && isBottom) return if (items === quickActions && mode == NavCustomization.NAV_MODE_BOTTOM) return
if (items === circularSlots && mode != NavCustomization.NAV_MODE_CIRCULAR) return
val ctx = requireContext() val ctx = requireContext()
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet() val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds } val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
@@ -92,9 +92,18 @@ class TransferFragment : Fragment() {
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL" // Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
private var selectedFahipayService: String? = null private var selectedFahipayService: String? = null
// 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
// BML QR merchant payment mode (set when navigated from a card QR scan) // BML QR merchant payment mode (set when navigated from a card QR scan)
private var bmlQrInfo: BmlQrPayInfo? = null private var bmlQrInfo: BmlQrPayInfo? = null
private var bmlGatewayQr = false // true for pay.bml.com.mv QRs (requires pre-initiate step) private var bmlGatewayQr = false // true for pay.bml.com.mv QRs (requires pre-initiate step)
private var bmlQrLookupAttempted = false // prevents re-lookup after user clears the merchant
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>() private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
// BML business profile OTP flow state // BML business profile OTP flow state
@@ -139,6 +148,28 @@ class TransferFragment : Fragment() {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
return@registerForActivityResult return@registerForActivityResult
} }
// Cards can't pay PayMV QR — fall back to default account or clear selection
val isCard = selectedAccount?.let {
it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT"
} ?: false
if (isCard) {
Toast.makeText(requireContext(), R.string.card_qr_paymv_unsupported, Toast.LENGTH_SHORT).show()
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
val defaultAcc = defaultNum?.let { num -> viewModel.accounts.value?.firstOrNull { it.accountNumber == num } }
selectedAccount = defaultAcc
binding.tilAmount.prefixText = null
if (defaultAcc != null) {
updateAmountPrefix(defaultAcc)
showFromCard(defaultAcc)
} else {
binding.cardFromInfo.visibility = View.GONE
binding.tilFrom.visibility = View.VISIBLE
binding.actvFrom.setText("", false)
}
updateTransferButton()
}
if (qr.amount != null) binding.etAmount.setText(qr.amount) if (qr.amount != null) binding.etAmount.setText(qr.amount)
if (qr.purpose != null) binding.etRemarks.setText(qr.purpose) if (qr.purpose != null) binding.etRemarks.setText(qr.purpose)
prefillToFromContact(qr.accountNumber, "") prefillToFromContact(qr.accountNumber, "")
@@ -191,13 +222,15 @@ class TransferFragment : Fragment() {
accountNumber: String, accountNumber: String,
displayName: String, displayName: String,
amount: String?, amount: String?,
remarks: String? remarks: String?,
fromAccountNumber: String? = null
) = TransferFragment().apply { ) = TransferFragment().apply {
arguments = Bundle().apply { arguments = Bundle().apply {
putString(ARG_ACCOUNT, accountNumber) putString(ARG_ACCOUNT, accountNumber)
putString(ARG_NAME, displayName) putString(ARG_NAME, displayName)
putString(ARG_SUBTITLE, accountNumber) putString(ARG_SUBTITLE, accountNumber)
putString(ARG_COLOR, "#607D8B") putString(ARG_COLOR, "#607D8B")
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount) if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks) if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
} }
@@ -221,11 +254,27 @@ class TransferFragment : Fragment() {
childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle -> childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener
if (accountNumber.startsWith("bmlqr:")) {
lookupBmlQrMerchant(accountNumber.removePrefix("bmlqr:"))
return@setFragmentResultListener
}
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: "" val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B" val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH) val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash) prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash)
if (selectedAccount == null) {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
if (defaultNum != null) {
val defaultAcc = viewModel.accounts.value?.firstOrNull { it.accountNumber == defaultNum }
if (defaultAcc != null) {
selectedAccount = defaultAcc
updateAmountPrefix(defaultAcc)
showFromCard(defaultAcc)
updateTransferButton()
}
}
}
} }
binding.btnPickContact.setOnClickListener { binding.btnPickContact.setOnClickListener {
@@ -265,9 +314,37 @@ class TransferFragment : Fragment() {
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) { if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java)) qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
} }
// Restore form state when view is recreated on the cached no-args instance
if (arguments == null) {
if (resolvedAccountNumber.isNotEmpty()) {
val ownAccount = viewModel.accounts.value?.firstOrNull { it.accountNumber == resolvedAccountNumber }
if (ownAccount != null) {
showToCard(ownAccount)
} else {
binding.tvToAccountName.text = resolvedRecipientName
binding.tvToBankBic.text = savedToSubtitle
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(resolvedRecipientName, savedToColorHex))
}
binding.tilTo.visibility = View.GONE
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)
}
if (savedAmount.isNotEmpty()) binding.etAmount.setText(savedAmount)
if (savedRemarks.isNotEmpty()) binding.etRemarks.setText(savedRemarks)
updateTransferButton()
}
} }
private fun lookupBmlQrMerchant(qrUrl: String) { private fun lookupBmlQrMerchant(qrUrl: String) {
bmlQrLookupAttempted = true
bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/")
val base64Url = android.util.Base64.encodeToString( val base64Url = android.util.Base64.encodeToString(
qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP) qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP)
@@ -292,6 +369,16 @@ class TransferFragment : Fragment() {
return@launch return@launch
} }
bmlQrInfo = info bmlQrInfo = info
if (info.amount == 0.0) {
RecentsCache.save(requireContext(), RecentPick(
accountNumber = "bmlqr:$qrUrl",
displayName = info.merchantName,
subtitle = info.merchantAddress.ifBlank { "BML Merchant" },
colorHex = "#0066A1",
imageHash = null,
isProfileImage = false
))
}
// Auto-select the user's default BML card if no card was pre-selected // Auto-select the user's default BML card if no card was pre-selected
if (selectedAccount == null) { if (selectedAccount == null) {
@@ -398,6 +485,35 @@ class TransferFragment : Fragment() {
updateTransferButton() updateTransferButton()
} }
} }
// Auto-select default account when arriving from contacts page (TO account already pre-filled)
if (selectedAccount == null && arguments?.getString(ARG_ACCOUNT) != null) {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
if (defaultNum != null) {
val defaultAcc = accounts.firstOrNull { it.accountNumber == defaultNum }
if (defaultAcc != null) {
selectedAccount = defaultAcc
updateAmountPrefix(defaultAcc)
showFromCard(defaultAcc)
updateTransferButton()
}
}
}
// 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 && !bmlQrLookupAttempted) {
val app = requireActivity().application as BasedBankApp
if (app.anyBmlSession() != null) lookupBmlQrMerchant(pendingBmlQrUrl)
}
// Re-render the from card when the view is recreated on a cached instance
if (selectedAccount != null && binding.cardFromInfo.visibility != View.VISIBLE) {
updateAmountPrefix(selectedAccount!!)
showFromCard(selectedAccount!!)
updateTransferButton()
}
} }
} }
@@ -683,6 +799,13 @@ class TransferFragment : Fragment() {
resolvedAccountNumber = info.accountNumber resolvedAccountNumber = info.accountNumber
resolvedRecipientName = info.accountName resolvedRecipientName = info.accountName
resolvedBankName = info.bankId resolvedBankName = info.bankId
savedToSubtitle = "${info.accountNumber} · ${info.bankId}"
savedToColorHex = colorHex
savedToImageHash = when {
matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash
matchedCont?.customerImgHash != null -> matchedCont.customerImgHash
else -> null
}
if (matchedAcc != null) { if (matchedAcc != null) {
showToCard(matchedAcc) showToCard(matchedAcc)
@@ -815,6 +938,9 @@ class TransferFragment : Fragment() {
) { ) {
resolvedAccountNumber = accountNumber resolvedAccountNumber = accountNumber
resolvedRecipientName = displayName resolvedRecipientName = displayName
savedToSubtitle = subtitle
savedToColorHex = colorHex
savedToImageHash = imageHash
val contacts = viewModel.contacts.value ?: emptyList() val contacts = viewModel.contacts.value ?: emptyList()
resolvedBankName = contacts.firstOrNull { it.benefAccount == accountNumber }?.benefBankName ?: "" resolvedBankName = contacts.firstOrNull { it.benefAccount == accountNumber }?.benefBankName ?: ""
@@ -2005,6 +2131,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
savedAmount = binding.etAmount.text?.toString() ?: ""
savedRemarks = binding.etRemarks.text?.toString() ?: ""
savedToText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else ""
// Reset in-progress OTP flow — it cannot sensibly resume after the view is gone
bmlOtpState = BmlOtpState.NONE
pendingBmlTransfer = null
bmlOtpChannel = null
_binding = null _binding = null
} }
@@ -41,7 +41,7 @@ object PaymvQrParser {
PaymvQrData( PaymvQrData(
accountNumber = merchantInfo?.get("03"), accountNumber = merchantInfo?.get("03"),
amount = root["54"], amount = root["54"]?.takeIf { it != "***" },
purpose = additionalData?.get("08"), purpose = additionalData?.get("08"),
merchantName = root["59"] merchantName = root["59"]
) )
@@ -35,6 +35,14 @@
android:layout_weight="1" android:layout_weight="1"
android:text="@string/settings_nav_drawer" /> android:text="@string/settings_nav_drawer" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnNavCircular"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_nav_circular" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnNavBottom" android:id="@+id/btnNavBottom"
style="@style/Widget.Material3.Button.OutlinedButton" style="@style/Widget.Material3.Button.OutlinedButton"
@@ -70,6 +78,31 @@
</LinearLayout> </LinearLayout>
<!-- Circular nav shortcuts — shown only when circular nav is active -->
<LinearLayout
android:id="@+id/sectionCircularSlots"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_circular_shortcuts"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="8dp"
android:layout_marginBottom="12dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvCircularSlots"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
android:overScrollMode="never" />
</LinearLayout>
<!-- Bottom bar shortcuts — shown only when bottom nav is active --> <!-- Bottom bar shortcuts — shown only when bottom nav is active -->
<LinearLayout <LinearLayout
android:id="@+id/sectionBottomBarShortcuts" android:id="@+id/sectionBottomBarShortcuts"
+9 -9
View File
@@ -26,15 +26,6 @@
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"> app:layout_constraintBottom_toBottomOf="parent">
<ImageButton
android:id="@+id/btnTransferContact"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_send"
android:padding="6dp"
android:contentDescription="@string/transfer" />
<ImageButton <ImageButton
android:id="@+id/btnEditContact" android:id="@+id/btnEditContact"
android:layout_width="36dp" android:layout_width="36dp"
@@ -54,6 +45,15 @@
android:tint="?attr/colorError" android:tint="?attr/colorError"
android:contentDescription="@string/contact_delete" /> android:contentDescription="@string/contact_delete" />
<ImageButton
android:id="@+id/btnTransferContact"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_send"
android:padding="6dp"
android:contentDescription="@string/transfer" />
</LinearLayout> </LinearLayout>
<LinearLayout <LinearLayout
+3
View File
@@ -175,7 +175,9 @@
<string name="settings_navigation">Navigation</string> <string name="settings_navigation">Navigation</string>
<string name="settings_nav_drawer">Drawer</string> <string name="settings_nav_drawer">Drawer</string>
<string name="settings_nav_bottom">Bottom Bar</string> <string name="settings_nav_bottom">Bottom Bar</string>
<string name="settings_nav_circular">Circular</string>
<string name="settings_appearance">Appearance</string> <string name="settings_appearance">Appearance</string>
<string name="settings_circular_shortcuts">Circular Nav Shortcuts</string>
<string name="settings_bottom_bar_shortcuts">Bottom Bar Shortcuts</string> <string name="settings_bottom_bar_shortcuts">Bottom Bar Shortcuts</string>
<string name="settings_bottom_bar_show_labels">Always show bottom bar labels</string> <string name="settings_bottom_bar_show_labels">Always show bottom bar labels</string>
<string name="settings_bottom_bar_select">Choose button</string> <string name="settings_bottom_bar_select">Choose button</string>
@@ -241,6 +243,7 @@
<string name="transfer_scan_qr">Scan to Pay</string> <string name="transfer_scan_qr">Scan to Pay</string>
<string name="qr_pick_image">Pick image</string> <string name="qr_pick_image">Pick image</string>
<string name="transfer_qr_invalid">Invalid or unsupported QR code</string> <string name="transfer_qr_invalid">Invalid or unsupported QR code</string>
<string name="card_qr_paymv_unsupported">PayMV QR is not supported for card payments — switching to transfer</string>
<string name="qr_camera_permission_title">Camera permission required</string> <string name="qr_camera_permission_title">Camera permission required</string>
<string name="qr_camera_permission_message">Camera access is needed to scan QR codes. Please grant the permission in Settings.</string> <string name="qr_camera_permission_message">Camera access is needed to scan QR codes. Please grant the permission in Settings.</string>
<string name="camera_permission_profile_message">Camera access is needed to take a photo. Please grant the permission in Settings.</string> <string name="camera_permission_profile_message">Camera access is needed to take a photo. Please grant the permission in Settings.</string>