diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6cd18bb..a4b46a5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + 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 index cb226f0..345efd5 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/CircularNavFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/CircularNavFragment.kt @@ -4,10 +4,13 @@ import android.animation.ValueAnimator import android.content.Context import android.graphics.* import android.os.Bundle +import android.os.VibrationEffect +import android.os.Vibrator import android.util.AttributeSet import android.util.TypedValue import android.view.* import android.view.animation.DecelerateInterpolator +import android.view.animation.LinearInterpolator import android.animation.AnimatorListenerAdapter import android.animation.Animator import android.widget.FrameLayout @@ -70,8 +73,9 @@ class CircularNavFragment : Fragment() { accentColor = colorPrimary surfaceColor = colorSurface labelColor = colorOnSurface - onItemClick = { navId -> (activity as? HomeActivity)?.navigateTo(navId) } - onCenterClick = { (activity as? HomeActivity)?.lockApp() } + onItemClick = { navId -> (activity as? HomeActivity)?.navigateTo(navId) } + onCenterClick = { /* unused: tap on unlocked center locks the wheel */ } + onWheelCenterLockedTap = { (activity as? HomeActivity)?.notifyWheelLockTap() } } wheelContainer.addView(wheelView) @@ -133,6 +137,10 @@ class CircularNavFragment : Fragment() { toolbar.findViewWithTag("wheel_title")?.let { toolbar.removeView(it) } requireActivity().invalidateOptionsMenu() } + + fun unlockWheelLock() { + wheelView?.unlockWheel() + } } // --------------------------------------------------------------------------- @@ -169,8 +177,12 @@ class CircularWheelView @JvmOverloads constructor( var labelColor: Int = Color.DKGRAY set(value) { field = value; invalidate() } + var isWheelLocked = false + set(value) { field = value; invalidate() } + var onItemClick: ((Int) -> Unit)? = null var onCenterClick: (() -> Unit)? = null + var onWheelCenterLockedTap: (() -> Unit)? = null // ---- geometry --------------------------------------------------------- @@ -195,6 +207,10 @@ class CircularWheelView @JvmOverloads constructor( private var iconBitmaps: Array = emptyArray() private var centerBitmap: Bitmap? = null + private var centerUnlockedBitmap: Bitmap? = null + private val grayFilter = ColorMatrixColorFilter(ColorMatrix().apply { setSaturation(0f) }) + private var lockShakeAngle = 0f + private var shakeAnimator: ValueAnimator? = null // ---- touch & fling ---------------------------------------------------- @@ -244,7 +260,8 @@ class CircularWheelView @JvmOverloads constructor( iconBitmaps[i] = tintedBitmap(item.iconRes, accentColor, iconPx) } val centerPx = (innerRadius * 0.64f).toInt().coerceAtLeast(1) - centerBitmap = tintedBitmap(R.drawable.ic_lock, accentColor, centerPx) + centerBitmap = tintedBitmap(R.drawable.ic_lock, accentColor, centerPx) + centerUnlockedBitmap = tintedBitmap(R.drawable.ic_lock_open, accentColor, centerPx) } private fun tintedBitmap(@DrawableRes resId: Int, tint: Int, sizePx: Int): Bitmap? { @@ -304,8 +321,13 @@ class CircularWheelView @JvmOverloads constructor( canvas.drawCircle(cx, cy, innerRadius + dp(3f), centerRingPaint) centerFillPaint.color = surfaceColor canvas.drawCircle(cx, cy, innerRadius, centerFillPaint) - centerBitmap?.let { + val activeCenterBitmap = if (isWheelLocked) centerBitmap else centerUnlockedBitmap + activeCenterBitmap?.let { + canvas.save() + // Shake pivots around the bottom-centre of the icon + if (lockShakeAngle != 0f) canvas.rotate(lockShakeAngle, cx, cy + it.height / 2f) canvas.drawBitmap(it, cx - it.width / 2f, cy - it.height / 2f, bitmapPaint) + canvas.restore() } } @@ -322,7 +344,15 @@ class CircularWheelView @JvmOverloads constructor( canvas.save() canvas.translate(iconX, iconY) canvas.rotate(midDeg - 90f) + if (isWheelLocked) { + bitmapPaint.colorFilter = grayFilter + bitmapPaint.alpha = 100 + } canvas.drawBitmap(bmp, -bmp.width / 2f, -bmp.height / 2f, bitmapPaint) + if (isWheelLocked) { + bitmapPaint.colorFilter = null + bitmapPaint.alpha = 255 + } canvas.restore() } @@ -334,7 +364,7 @@ class CircularWheelView @JvmOverloads constructor( val textX = cx + cosA * labelRadius val textY = cy + sinA * labelRadius val label = items[index].label - textPaint.color = labelColor + textPaint.color = if (isWheelLocked) (labelColor and 0x00FFFFFF) or (80 shl 24) else labelColor textPaint.textAlign = Paint.Align.LEFT val halfAngleDeg = Math.toDegrees((textPaint.measureText(label) / 2.0) / labelRadius).toFloat() val localArcRect = RectF(-labelRadius, -2f * labelRadius, labelRadius, 0f) @@ -377,18 +407,38 @@ class CircularWheelView @JvmOverloads constructor( invalidate() } } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + MotionEvent.ACTION_UP -> { if (!isDragging) { val dist = hypot(event.x - cx, event.y - cy) when { - dist <= innerRadius -> onCenterClick?.invoke() + dist <= innerRadius -> { + if (isWheelLocked) { + onWheelCenterLockedTap?.invoke() + } else { + isWheelLocked = true + } + } dist <= outerRadius -> { - val idx = segmentAt(event.x, event.y) - if (idx in items.indices) animateToSixOClock(idx) { onItemClick?.invoke(items[idx].navId) } + if (isWheelLocked) { + val idx = segmentAt(event.x, event.y) + if (idx in items.indices) animateToSixOClock(idx) { + vibrateDevice() + shakeLock() + } + } else { + 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 + val vel = computeVelocity() + if (abs(vel) > 0.05f) fling(vel) else snapToNearest() + } + } + MotionEvent.ACTION_CANCEL -> { + if (isDragging) { + val vel = computeVelocity() if (abs(vel) > 0.05f) fling(vel) else snapToNearest() } } @@ -483,4 +533,29 @@ class CircularWheelView @JvmOverloads constructor( start() } } + + private fun vibrateDevice() { + val v = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + v.vibrate(VibrationEffect.createOneShot(60, VibrationEffect.DEFAULT_AMPLITUDE)) + } + + fun shakeLock() { + shakeAnimator?.cancel() + shakeAnimator = ValueAnimator.ofFloat(0f, -18f, 18f, -12f, 12f, -6f, 6f, 0f).apply { + duration = 500 + interpolator = LinearInterpolator() + addUpdateListener { lockShakeAngle = it.animatedValue as Float; invalidate() } + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(a: Animator) { lockShakeAngle = 0f; invalidate() } + }) + start() + } + } + + fun unlockWheel() { + isWheelLocked = false + lockShakeAngle = 0f + shakeAnimator?.cancel() + invalidate() + } } 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 4038669..e7db253 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 @@ -90,6 +90,7 @@ class HomeActivity : AppCompatActivity() { private val warningRunnable = Runnable { showAutolockWarning() } private var isLocked = false + private var pendingWheelUnlock = false private val autolockRunnable = Runnable { countdownTimer?.cancel(); countdownTimer = null @@ -101,6 +102,19 @@ class HomeActivity : AppCompatActivity() { fun lockApp() = lock() + fun notifyWheelLockTap() { + val securitySet = getSharedPreferences("prefs", MODE_PRIVATE) + .getString("security_method", null) != null + if (securitySet) { + pendingWheelUnlock = true + lock() + } else { + // No security configured — unlock the wheel immediately + (supportFragmentManager.findFragmentById(R.id.contentFrame) as? CircularNavFragment) + ?.unlockWheelLock() + } + } + private fun lock() { isLocked = true startActivity( @@ -520,6 +534,11 @@ fun applyNavLabelVisibility() { pauseTime = 0L resetAutolockTimer() autoRefresh(CredentialStore(this)) + if (pendingWheelUnlock) { + pendingWheelUnlock = false + (supportFragmentManager.findFragmentById(R.id.contentFrame) as? CircularNavFragment) + ?.unlockWheelLock() + } return } // If we were away long enough to have hit the autolock timeout (e.g. while diff --git a/app/src/main/res/drawable/ic_lock_open.xml b/app/src/main/res/drawable/ic_lock_open.xml new file mode 100644 index 0000000..0e119c5 --- /dev/null +++ b/app/src/main/res/drawable/ic_lock_open.xml @@ -0,0 +1,22 @@ + + + + + + + + + +