9 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
9 changed files with 744 additions and 39 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 36
versionCode = 12
versionName = "1.0.13"
versionCode = 14
versionName = "1.0.15"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -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()
}
}
}
@@ -134,7 +134,7 @@ class DashboardFragment : Fragment() {
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
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 extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
@@ -145,8 +145,7 @@ class DashboardFragment : Fragment() {
override fun onResume() {
super.onResume()
val isBottom = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getBoolean("bottom_nav", false)
val isBottom = NavCustomization.getNavMode(requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)) == NavCustomization.NAV_MODE_BOTTOM
if (isBottom) {
requireActivity().title = getString(R.string.app_name)
val size = (28 * resources.displayMetrics.density).toInt()
@@ -171,7 +170,7 @@ class DashboardFragment : Fragment() {
private fun refreshQuickActions() {
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) {
binding.buttonBar.visibility = View.GONE
return
@@ -76,6 +76,7 @@ class HomeActivity : AppCompatActivity() {
private val viewModel: HomeViewModel by viewModels()
private lateinit var toggle: ActionBarDrawerToggle
private var suppressBottomNavCallback = false
private var cachedTransferFragment: TransferFragment? = null
private var backPressedOnce = false
private val backPressHandler = Handler(Looper.getMainLooper())
@@ -98,6 +99,8 @@ class HomeActivity : AppCompatActivity() {
if (securitySet) lock()
}
fun lockApp() = lock()
private fun lock() {
isLocked = true
startActivity(
@@ -156,7 +159,7 @@ class HomeActivity : AppCompatActivity() {
R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> AccountsFragment()
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_more -> MoreFragment()
R.id.nav_activities -> ActivitiesFragment()
@@ -254,8 +257,13 @@ class HomeActivity : AppCompatActivity() {
navigateTo(navDest, fragment)
}
else -> {
show(DashboardFragment())
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
val initPrefs = getSharedPreferences("prefs", MODE_PRIVATE)
if (NavCustomization.getNavMode(initPrefs) == NavCustomization.NAV_MODE_CIRCULAR) {
show(CircularNavFragment())
} else {
show(DashboardFragment())
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
}
}
}
}
@@ -270,14 +278,38 @@ class HomeActivity : AppCompatActivity() {
// Let CardsFragment handle back if in manage mode
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
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)
if (supportFragmentManager.backStackEntryCount > 0) {
supportFragmentManager.popBackStack()
return
}
// In bottom nav mode, pressing back navigates up the hierarchy
val isBottomNav = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
if (isBottomNav && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
if (navMode == NavCustomization.NAV_MODE_BOTTOM && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
// Sub-page reached via More (e.g. Settings, Activities) — go back to More
if (binding.bottomNavigation.selectedItemId == R.id.nav_more && currentFrag !is MoreFragment) {
show(MoreFragment())
@@ -335,19 +367,28 @@ class HomeActivity : AppCompatActivity() {
fun applyNavMode() {
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val isBottom = prefs.getBoolean("bottom_nav", false)
if (isBottom) {
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
toggle.isDrawerIndicatorEnabled = false
supportActionBar?.setDisplayHomeAsUpEnabled(false)
binding.bottomNavigation.visibility = View.VISIBLE
rebuildBottomNav(prefs)
applyNavLabelVisibility()
} else {
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
toggle.isDrawerIndicatorEnabled = true
toggle.syncState()
binding.bottomNavigation.visibility = View.GONE
when (NavCustomization.getNavMode(prefs)) {
NavCustomization.NAV_MODE_BOTTOM -> {
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
toggle.isDrawerIndicatorEnabled = false
supportActionBar?.setDisplayHomeAsUpEnabled(false)
binding.bottomNavigation.visibility = View.VISIBLE
rebuildBottomNav(prefs)
applyNavLabelVisibility()
}
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)
toggle.isDrawerIndicatorEnabled = true
toggle.syncState()
binding.bottomNavigation.visibility = View.GONE
}
}
}
@@ -386,11 +427,15 @@ fun applyNavLabelVisibility() {
}
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) {
R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> AccountsFragment()
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_activities -> ActivitiesFragment()
R.id.nav_transfer_history -> TransferHistoryFragment()
@@ -398,6 +443,7 @@ fun applyNavLabelVisibility() {
R.id.nav_otp -> OtpFragment()
R.id.nav_settings -> SettingsFragment()
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 }
}
show(dest)
@@ -415,8 +461,8 @@ fun applyNavLabelVisibility() {
}
fun setBottomNavVisible(visible: Boolean) {
val isBottom = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
if (isBottom) {
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
if (NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM) {
binding.bottomNavigation.visibility = if (visible) View.VISIBLE else View.GONE
}
}
@@ -559,6 +605,13 @@ fun applyNavLabelVisibility() {
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 {
if (item.itemId == R.id.action_lock) {
val avd = getDrawable(R.drawable.avd_lock) as? android.graphics.drawable.AnimatedVectorDrawable
@@ -7,6 +7,20 @@ import sh.sar.basedbank.R
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(
val id: Int,
val key: String,
@@ -62,8 +76,31 @@ object NavCustomization {
}
/** 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> {
if (getNavMode(prefs) == NAV_MODE_CIRCULAR) return getCircularMoreItems(prefs)
val slots = getSlots(prefs).toSet()
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 }
}
}
@@ -36,8 +36,10 @@ class SettingsAppearanceFragment : Fragment() {
private lateinit var prefs: SharedPreferences
private val slots = mutableListOf<Int>()
private val quickActions = mutableListOf<Int>()
private val circularSlots = mutableListOf<Int>()
private lateinit var slotAdapter: NavItemAdapter
private lateinit var quickActionAdapter: NavItemAdapter
private lateinit var circularSlotAdapter: NavItemAdapter
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false)
@@ -48,11 +50,20 @@ class SettingsAppearanceFragment : Fragment() {
prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
// Navigation mode
val isBottom = prefs.getBoolean("bottom_nav", false)
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer)
val currentMode = NavCustomization.getNavMode(prefs)
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 ->
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()
updateShortcutsVisibility()
}
@@ -63,10 +74,22 @@ class SettingsAppearanceFragment : Fragment() {
quickActionAdapter = NavItemAdapter(
items = 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) {
!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
@@ -78,10 +101,10 @@ class SettingsAppearanceFragment : Fragment() {
NavCustomization.saveSlots(prefs, slots)
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
},
isEnabled = { prefs.getBoolean("bottom_nav", false) }
isEnabled = { NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM }
)
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) {
prefs.getBoolean("bottom_nav", false)
NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM
}
// Show labels toggle
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
@@ -191,11 +214,15 @@ class SettingsAppearanceFragment : Fragment() {
}
private fun updateShortcutsVisibility() {
val isBottom = prefs.getBoolean("bottom_nav", false)
binding.sectionQuickActions.alpha = if (isBottom) 0.38f else 1f
val mode = NavCustomization.getNavMode(prefs)
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.switchShowLabels.isClickable = isBottom
quickActionAdapter.notifyDataSetChanged()
circularSlotAdapter.notifyDataSetChanged()
slotAdapter.notifyDataSetChanged()
}
@@ -262,9 +289,10 @@ class SettingsAppearanceFragment : Fragment() {
}
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
val isBottom = prefs.getBoolean("bottom_nav", false)
if (items === slots && !isBottom) return
if (items === quickActions && isBottom) return
val mode = NavCustomization.getNavMode(prefs)
if (items === slots && mode != NavCustomization.NAV_MODE_BOTTOM) return
if (items === quickActions && mode == NavCustomization.NAV_MODE_BOTTOM) return
if (items === circularSlots && mode != NavCustomization.NAV_MODE_CIRCULAR) return
val ctx = requireContext()
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
@@ -92,6 +92,14 @@ class TransferFragment : Fragment() {
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
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)
private var bmlQrInfo: BmlQrPayInfo? = null
private var bmlGatewayQr = false // true for pay.bml.com.mv QRs (requires pre-initiate step)
@@ -255,6 +263,18 @@ class TransferFragment : Fragment() {
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
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 {
@@ -294,6 +314,33 @@ class TransferFragment : Fragment() {
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
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) {
@@ -439,6 +486,20 @@ class TransferFragment : Fragment() {
}
}
// Auto-select default account when arriving from contacts page (TO account already pre-filled)
if (selectedAccount == null && arguments?.getString(ARG_ACCOUNT) != null) {
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)
@@ -446,6 +507,13 @@ class TransferFragment : Fragment() {
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()
}
}
}
@@ -731,6 +799,13 @@ class TransferFragment : Fragment() {
resolvedAccountNumber = info.accountNumber
resolvedRecipientName = info.accountName
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) {
showToCard(matchedAcc)
@@ -863,6 +938,9 @@ class TransferFragment : Fragment() {
) {
resolvedAccountNumber = accountNumber
resolvedRecipientName = displayName
savedToSubtitle = subtitle
savedToColorHex = colorHex
savedToImageHash = imageHash
val contacts = viewModel.contacts.value ?: emptyList()
resolvedBankName = contacts.firstOrNull { it.benefAccount == accountNumber }?.benefBankName ?: ""
@@ -2053,6 +2131,14 @@ class TransferFragment : Fragment() {
override fun onDestroyView() {
super.onDestroyView()
// Persist form state so it can be restored when the view is recreated
savedAmount = binding.etAmount.text?.toString() ?: ""
savedRemarks = binding.etRemarks.text?.toString() ?: ""
savedToText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else ""
// Reset in-progress OTP flow — it cannot sensibly resume after the view is gone
bmlOtpState = BmlOtpState.NONE
pendingBmlTransfer = null
bmlOtpChannel = null
_binding = null
}
@@ -35,6 +35,14 @@
android:layout_weight="1"
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
android:id="@+id/btnNavBottom"
style="@style/Widget.Material3.Button.OutlinedButton"
@@ -70,6 +78,31 @@
</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 -->
<LinearLayout
android:id="@+id/sectionBottomBarShortcuts"
+2
View File
@@ -175,7 +175,9 @@
<string name="settings_navigation">Navigation</string>
<string name="settings_nav_drawer">Drawer</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_circular_shortcuts">Circular Nav 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_select">Choose button</string>