From 9ca13d351834516e2562cfc49e6b966fe521d90b Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Wed, 3 Jun 2026 01:48:04 +0500 Subject: [PATCH] New UI nav mode: Circular --- .../basedbank/ui/home/CircularNavFragment.kt | 426 ++++++++++++++++++ .../sh/sar/basedbank/ui/home/HomeActivity.kt | 83 +++- .../sar/basedbank/ui/home/NavCustomization.kt | 24 + .../ui/home/SettingsAppearanceFragment.kt | 35 +- .../layout/fragment_settings_appearance.xml | 8 + app/src/main/res/values/strings.xml | 1 + 6 files changed, 546 insertions(+), 31 deletions(-) create mode 100644 app/src/main/java/sh/sar/basedbank/ui/home/CircularNavFragment.kt diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/CircularNavFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/CircularNavFragment.kt new file mode 100644 index 0000000..8a1ea10 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/CircularNavFragment.kt @@ -0,0 +1,426 @@ +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.app.AppCompatActivity +import androidx.appcompat.content.res.AppCompatResources +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() { + + 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) + } + + // Header: launcher icon + app name + val header = android.widget.LinearLayout(ctx).apply { + orientation = android.widget.LinearLayout.HORIZONTAL + gravity = Gravity.CENTER + val vp = dp(14f).toInt() + setPadding(0, vp, 0, vp) + layoutParams = android.widget.LinearLayout.LayoutParams( + android.widget.LinearLayout.LayoutParams.MATCH_PARENT, + android.widget.LinearLayout.LayoutParams.WRAP_CONTENT + ) + } + val iconSz = dp(38f).toInt() + val logoView = android.widget.ImageView(ctx).apply { + setImageResource(R.mipmap.ic_launcher_round) + layoutParams = android.widget.LinearLayout.LayoutParams(iconSz, iconSz) + } + val nameView = android.widget.TextView(ctx).apply { + text = getString(R.string.app_name) + setTextColor(colorOnSurface) + textSize = 21f + typeface = Typeface.DEFAULT_BOLD + layoutParams = android.widget.LinearLayout.LayoutParams( + android.widget.LinearLayout.LayoutParams.WRAP_CONTENT, + android.widget.LinearLayout.LayoutParams.WRAP_CONTENT + ).also { it.marginStart = dp(10f).toInt() } + } + header.addView(logoView) + header.addView(nameView) + + // Wheel area (fills remaining height) + val wheelContainer = FrameLayout(ctx).apply { + layoutParams = android.widget.LinearLayout.LayoutParams( + android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f + ) + } + val wheelView = CircularWheelView(ctx).apply { + layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + items = listOf( + CircularWheelView.WheelItem(R.id.nav_dashboard, R.drawable.ic_nav_dashboard, "Dashboard"), // 12 + CircularWheelView.WheelItem(R.id.nav_transfer, R.drawable.ic_send, "Transfer"), // 2 + CircularWheelView.WheelItem(R.id.nav_pay_with_card, R.drawable.ic_nav_card, "Cards"), // 4 + CircularWheelView.WheelItem(R.id.nav_more, R.drawable.ic_nav_more, "More"), // 6 + CircularWheelView.WheelItem(R.id.nav_contacts, R.drawable.ic_contacts, "Contacts"), // 8 + CircularWheelView.WheelItem(R.id.nav_accounts, R.drawable.ic_nav_accounts, "Accounts"), // 10 + ) + accentColor = colorPrimary + surfaceColor = colorSurface + labelColor = colorOnSurface + onItemClick = { navId -> (activity as? HomeActivity)?.navigateTo(navId) } + onCenterClick = { (activity as? HomeActivity)?.lockApp() } + } + wheelContainer.addView(wheelView) + + root.addView(header) + root.addView(wheelContainer) + return root + } + + override fun onResume() { + super.onResume() + (activity as? AppCompatActivity)?.supportActionBar?.hide() + } + + // Action bar is restored by HomeActivity.navigateTo() / applyNavMode() + // so we don't touch it here — avoids flashing on lock. +} + +// --------------------------------------------------------------------------- +// 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 = 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 = emptyArray() + private var centerBitmap: Bitmap? = null + + // ---- touch & fling ---------------------------------------------------- + + private 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((-90.0 + i * segDeg)) + 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 = -90f + 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) 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 + 90f) % 360f + 360f) % 360f + return (a / (360f / items.size)).toInt() % items.size + } + + 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() + } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt index a70509a..6fdcc2c 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt @@ -99,6 +99,8 @@ class HomeActivity : AppCompatActivity() { if (securitySet) lock() } + fun lockApp() = lock() + private fun lock() { isLocked = true startActivity( @@ -255,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) + } } } } @@ -271,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()) @@ -336,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 + } } } @@ -387,6 +427,10 @@ 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() @@ -399,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) @@ -416,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 } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/NavCustomization.kt b/app/src/main/java/sh/sar/basedbank/ui/home/NavCustomization.kt index e275df1..20221b9 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/NavCustomization.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/NavCustomization.kt @@ -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, @@ -63,7 +77,17 @@ object NavCustomization { /** Items that belong in the "More" screen — those not occupying a bottom nav slot. */ fun getMoreItems(prefs: SharedPreferences): List { + if (getNavMode(prefs) == NAV_MODE_CIRCULAR) return getCircularMoreItems() val slots = getSlots(prefs).toSet() return ALL_SWAPPABLE.filter { it.id !in slots } } + + /** Items shown in More when circular nav is active — everything not directly on the wheel. */ + private fun getCircularMoreItems(): List { + val wheelIds = setOf( + R.id.nav_dashboard, R.id.nav_transfer, R.id.nav_pay_with_card, + R.id.nav_contacts, R.id.nav_accounts + ) + return ALL_SWAPPABLE.filter { it.id !in wheelIds } + } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/SettingsAppearanceFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/SettingsAppearanceFragment.kt index 4d2b23f..b9b7bee 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/SettingsAppearanceFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/SettingsAppearanceFragment.kt @@ -48,11 +48,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 +72,10 @@ 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 } // Bottom bar shortcuts @@ -78,10 +87,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,8 +200,10 @@ 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 isDrawer = mode == NavCustomization.NAV_MODE_DRAWER + binding.sectionQuickActions.alpha = if (!isBottom) 1f else 0.38f binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f binding.switchShowLabels.isClickable = isBottom quickActionAdapter.notifyDataSetChanged() @@ -262,9 +273,9 @@ class SettingsAppearanceFragment : Fragment() { } private fun showItemPicker(items: MutableList, 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 val ctx = requireContext() val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet() val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds } diff --git a/app/src/main/res/layout/fragment_settings_appearance.xml b/app/src/main/res/layout/fragment_settings_appearance.xml index d32740f..1b4ced2 100644 --- a/app/src/main/res/layout/fragment_settings_appearance.xml +++ b/app/src/main/res/layout/fragment_settings_appearance.xml @@ -35,6 +35,14 @@ android:layout_weight="1" android:text="@string/settings_nav_drawer" /> + + Navigation Drawer Bottom Bar + Circular Appearance Bottom Bar Shortcuts Always show bottom bar labels