forked from shihaam/thijooree
New UI nav mode: Circular
This commit is contained in:
@@ -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<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 ----------------------------------------------------
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,6 +99,8 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
if (securitySet) lock()
|
if (securitySet) lock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun lockApp() = lock()
|
||||||
|
|
||||||
private fun lock() {
|
private fun lock() {
|
||||||
isLocked = true
|
isLocked = true
|
||||||
startActivity(
|
startActivity(
|
||||||
@@ -255,8 +257,13 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
navigateTo(navDest, fragment)
|
navigateTo(navDest, fragment)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
show(DashboardFragment())
|
val initPrefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
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
|
// Let CardsFragment handle back if in manage mode
|
||||||
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
|
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
|
||||||
if (currentFrag is CardsFragment && currentFrag.onBackPressed()) return
|
if (currentFrag is CardsFragment && currentFrag.onBackPressed()) return
|
||||||
|
|
||||||
|
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
|
val navMode = NavCustomization.getNavMode(prefs)
|
||||||
|
|
||||||
|
// Circular nav mode: back always returns to the wheel
|
||||||
|
if (navMode == NavCustomization.NAV_MODE_CIRCULAR) {
|
||||||
|
if (supportFragmentManager.backStackEntryCount > 0) {
|
||||||
|
supportFragmentManager.popBackStack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentFrag is CircularNavFragment) {
|
||||||
|
if (backPressedOnce) {
|
||||||
|
backPressHandler.removeCallbacks(resetBackPress)
|
||||||
|
finish()
|
||||||
|
} else {
|
||||||
|
backPressedOnce = true
|
||||||
|
Toast.makeText(this@HomeActivity, R.string.press_back_to_exit, Toast.LENGTH_SHORT).show()
|
||||||
|
backPressHandler.postDelayed(resetBackPress, 2000)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
show(CircularNavFragment())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Pop fragment back stack if there's anything on it (e.g. showWithBackStack)
|
// Pop fragment back stack if there's anything on it (e.g. showWithBackStack)
|
||||||
if (supportFragmentManager.backStackEntryCount > 0) {
|
if (supportFragmentManager.backStackEntryCount > 0) {
|
||||||
supportFragmentManager.popBackStack()
|
supportFragmentManager.popBackStack()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// In bottom nav mode, pressing back navigates up the hierarchy
|
// In bottom nav mode, pressing back navigates up the hierarchy
|
||||||
val isBottomNav = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
|
if (navMode == NavCustomization.NAV_MODE_BOTTOM && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
|
||||||
if (isBottomNav && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
|
|
||||||
// Sub-page reached via More (e.g. Settings, Activities) — go back to More
|
// Sub-page reached via More (e.g. Settings, Activities) — go back to More
|
||||||
if (binding.bottomNavigation.selectedItemId == R.id.nav_more && currentFrag !is MoreFragment) {
|
if (binding.bottomNavigation.selectedItemId == R.id.nav_more && currentFrag !is MoreFragment) {
|
||||||
show(MoreFragment())
|
show(MoreFragment())
|
||||||
@@ -336,19 +367,28 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
fun applyNavMode() {
|
fun applyNavMode() {
|
||||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
when (NavCustomization.getNavMode(prefs)) {
|
||||||
if (isBottom) {
|
NavCustomization.NAV_MODE_BOTTOM -> {
|
||||||
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
||||||
toggle.isDrawerIndicatorEnabled = false
|
toggle.isDrawerIndicatorEnabled = false
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
||||||
binding.bottomNavigation.visibility = View.VISIBLE
|
binding.bottomNavigation.visibility = View.VISIBLE
|
||||||
rebuildBottomNav(prefs)
|
rebuildBottomNav(prefs)
|
||||||
applyNavLabelVisibility()
|
applyNavLabelVisibility()
|
||||||
} else {
|
}
|
||||||
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
|
NavCustomization.NAV_MODE_CIRCULAR -> {
|
||||||
toggle.isDrawerIndicatorEnabled = true
|
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
||||||
toggle.syncState()
|
toggle.isDrawerIndicatorEnabled = false
|
||||||
binding.bottomNavigation.visibility = View.GONE
|
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) {
|
fun navigateTo(itemId: Int, fragment: Fragment? = null) {
|
||||||
|
// Restore action bar when leaving the circular wheel screen
|
||||||
|
if (NavCustomization.getNavMode(getSharedPreferences("prefs", MODE_PRIVATE)) == NavCustomization.NAV_MODE_CIRCULAR) {
|
||||||
|
supportActionBar?.show()
|
||||||
|
}
|
||||||
val dest = fragment ?: when (itemId) {
|
val dest = fragment ?: when (itemId) {
|
||||||
R.id.nav_dashboard -> DashboardFragment()
|
R.id.nav_dashboard -> DashboardFragment()
|
||||||
R.id.nav_accounts -> AccountsFragment()
|
R.id.nav_accounts -> AccountsFragment()
|
||||||
@@ -399,6 +443,7 @@ fun applyNavLabelVisibility() {
|
|||||||
R.id.nav_otp -> OtpFragment()
|
R.id.nav_otp -> OtpFragment()
|
||||||
R.id.nav_settings -> SettingsFragment()
|
R.id.nav_settings -> SettingsFragment()
|
||||||
R.id.nav_pay_with_card -> CardsFragment()
|
R.id.nav_pay_with_card -> CardsFragment()
|
||||||
|
R.id.nav_more -> MoreFragment()
|
||||||
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
|
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
|
||||||
}
|
}
|
||||||
show(dest)
|
show(dest)
|
||||||
@@ -416,8 +461,8 @@ fun applyNavLabelVisibility() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setBottomNavVisible(visible: Boolean) {
|
fun setBottomNavVisible(visible: Boolean) {
|
||||||
val isBottom = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
|
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
if (isBottom) {
|
if (NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM) {
|
||||||
binding.bottomNavigation.visibility = if (visible) View.VISIBLE else View.GONE
|
binding.bottomNavigation.visibility = if (visible) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,20 @@ import sh.sar.basedbank.R
|
|||||||
|
|
||||||
object NavCustomization {
|
object NavCustomization {
|
||||||
|
|
||||||
|
const val NAV_MODE_DRAWER = "drawer"
|
||||||
|
const val NAV_MODE_BOTTOM = "bottom"
|
||||||
|
const val NAV_MODE_CIRCULAR = "circular"
|
||||||
|
|
||||||
|
fun getNavMode(prefs: SharedPreferences): String {
|
||||||
|
val explicit = prefs.getString("nav_mode", null)
|
||||||
|
if (explicit != null) return explicit
|
||||||
|
return if (prefs.getBoolean("bottom_nav", false)) NAV_MODE_BOTTOM else NAV_MODE_DRAWER
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveNavMode(prefs: SharedPreferences, mode: String) {
|
||||||
|
prefs.edit().putString("nav_mode", mode).apply()
|
||||||
|
}
|
||||||
|
|
||||||
data class NavItemDef(
|
data class NavItemDef(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
val key: String,
|
val key: String,
|
||||||
@@ -63,7 +77,17 @@ object NavCustomization {
|
|||||||
|
|
||||||
/** Items that belong in the "More" screen — those not occupying a bottom nav slot. */
|
/** Items that belong in the "More" screen — those not occupying a bottom nav slot. */
|
||||||
fun getMoreItems(prefs: SharedPreferences): List<NavItemDef> {
|
fun getMoreItems(prefs: SharedPreferences): List<NavItemDef> {
|
||||||
|
if (getNavMode(prefs) == NAV_MODE_CIRCULAR) return getCircularMoreItems()
|
||||||
val slots = getSlots(prefs).toSet()
|
val slots = getSlots(prefs).toSet()
|
||||||
return ALL_SWAPPABLE.filter { it.id !in slots }
|
return ALL_SWAPPABLE.filter { it.id !in slots }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Items shown in More when circular nav is active — everything not directly on the wheel. */
|
||||||
|
private fun getCircularMoreItems(): List<NavItemDef> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,11 +48,20 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
// Navigation mode
|
// Navigation mode
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
val currentMode = NavCustomization.getNavMode(prefs)
|
||||||
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer)
|
binding.navModeToggle.check(when (currentMode) {
|
||||||
|
NavCustomization.NAV_MODE_BOTTOM -> R.id.btnNavBottom
|
||||||
|
NavCustomization.NAV_MODE_CIRCULAR -> R.id.btnNavCircular
|
||||||
|
else -> R.id.btnNavDrawer
|
||||||
|
})
|
||||||
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
if (!isChecked) return@addOnButtonCheckedListener
|
if (!isChecked) return@addOnButtonCheckedListener
|
||||||
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
|
val mode = when (checkedId) {
|
||||||
|
R.id.btnNavBottom -> NavCustomization.NAV_MODE_BOTTOM
|
||||||
|
R.id.btnNavCircular -> NavCustomization.NAV_MODE_CIRCULAR
|
||||||
|
else -> NavCustomization.NAV_MODE_DRAWER
|
||||||
|
}
|
||||||
|
NavCustomization.saveNavMode(prefs, mode)
|
||||||
(activity as? HomeActivity)?.applyNavMode()
|
(activity as? HomeActivity)?.applyNavMode()
|
||||||
updateShortcutsVisibility()
|
updateShortcutsVisibility()
|
||||||
}
|
}
|
||||||
@@ -63,10 +72,10 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
quickActionAdapter = NavItemAdapter(
|
quickActionAdapter = NavItemAdapter(
|
||||||
items = quickActions,
|
items = quickActions,
|
||||||
onSave = { NavCustomization.saveQuickActions(prefs, quickActions) },
|
onSave = { NavCustomization.saveQuickActions(prefs, quickActions) },
|
||||||
isEnabled = { !prefs.getBoolean("bottom_nav", false) }
|
isEnabled = { NavCustomization.getNavMode(prefs) != NavCustomization.NAV_MODE_BOTTOM }
|
||||||
)
|
)
|
||||||
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions) {
|
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions) {
|
||||||
!prefs.getBoolean("bottom_nav", false)
|
NavCustomization.getNavMode(prefs) != NavCustomization.NAV_MODE_BOTTOM
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bottom bar shortcuts
|
// Bottom bar shortcuts
|
||||||
@@ -78,10 +87,10 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
NavCustomization.saveSlots(prefs, slots)
|
NavCustomization.saveSlots(prefs, slots)
|
||||||
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
|
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
|
||||||
},
|
},
|
||||||
isEnabled = { prefs.getBoolean("bottom_nav", false) }
|
isEnabled = { NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM }
|
||||||
)
|
)
|
||||||
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) {
|
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) {
|
||||||
prefs.getBoolean("bottom_nav", false)
|
NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM
|
||||||
}
|
}
|
||||||
// Show labels toggle
|
// Show labels toggle
|
||||||
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
|
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
|
||||||
@@ -191,8 +200,10 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateShortcutsVisibility() {
|
private fun updateShortcutsVisibility() {
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
val mode = NavCustomization.getNavMode(prefs)
|
||||||
binding.sectionQuickActions.alpha = if (isBottom) 0.38f else 1f
|
val isBottom = mode == NavCustomization.NAV_MODE_BOTTOM
|
||||||
|
val 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.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
|
||||||
binding.switchShowLabels.isClickable = isBottom
|
binding.switchShowLabels.isClickable = isBottom
|
||||||
quickActionAdapter.notifyDataSetChanged()
|
quickActionAdapter.notifyDataSetChanged()
|
||||||
@@ -262,9 +273,9 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
|
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
val mode = NavCustomization.getNavMode(prefs)
|
||||||
if (items === slots && !isBottom) return
|
if (items === slots && mode != NavCustomization.NAV_MODE_BOTTOM) return
|
||||||
if (items === quickActions && isBottom) return
|
if (items === quickActions && mode == NavCustomization.NAV_MODE_BOTTOM) return
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
|
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
|
||||||
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
|
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
|
||||||
|
|||||||
@@ -35,6 +35,14 @@
|
|||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="@string/settings_nav_drawer" />
|
android:text="@string/settings_nav_drawer" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnNavCircular"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/settings_nav_circular" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btnNavBottom"
|
android:id="@+id/btnNavBottom"
|
||||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
<string name="settings_navigation">Navigation</string>
|
<string name="settings_navigation">Navigation</string>
|
||||||
<string name="settings_nav_drawer">Drawer</string>
|
<string name="settings_nav_drawer">Drawer</string>
|
||||||
<string name="settings_nav_bottom">Bottom Bar</string>
|
<string name="settings_nav_bottom">Bottom Bar</string>
|
||||||
|
<string name="settings_nav_circular">Circular</string>
|
||||||
<string name="settings_appearance">Appearance</string>
|
<string name="settings_appearance">Appearance</string>
|
||||||
<string name="settings_bottom_bar_shortcuts">Bottom Bar Shortcuts</string>
|
<string name="settings_bottom_bar_shortcuts">Bottom Bar Shortcuts</string>
|
||||||
<string name="settings_bottom_bar_show_labels">Always show bottom bar labels</string>
|
<string name="settings_bottom_bar_show_labels">Always show bottom bar labels</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user