Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
ffee918258
|
|||
|
fc7fa420b2
|
|||
|
5f6ec236bf
|
|||
|
890cf15fd0
|
|||
|
98a003727b
|
|||
|
9ca13d3518
|
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "sh.sar.basedbank"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 13
|
||||
versionName = "1.0.14"
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -560,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 }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user