P2 Stream H: standalone TaskSuggestionsScreen

Port iOS TaskSuggestionsView as a standalone route reachable outside
onboarding. Uses shared suggestions API + accept/skip analytics in
non-onboarding variant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 13:10:47 -05:00
parent 7d71408bcc
commit 19471d780d
19 changed files with 2161 additions and 3 deletions
@@ -0,0 +1,150 @@
package com.tt.honeyDue.ui.haptics
import android.content.Context
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.view.HapticFeedbackConstants
import android.view.View
/**
* Android backend using [HapticFeedbackConstants] when a host [View] is available,
* with graceful [Vibrator] fallback for older APIs or headless contexts.
*
* API-30+ (Android 11+) gets the richer CONFIRM / REJECT / GESTURE_END constants.
* Pre-30 falls back to predefined [VibrationEffect]s (EFFECT_TICK, EFFECT_CLICK,
* EFFECT_HEAVY_CLICK) on API 29+, or one-shot vibrations on API 2628,
* or legacy Vibrator.vibrate(duration) on pre-26.
*
* Call [HapticsInit.install] from your Application / MainActivity so the app
* context is available for vibrator resolution. Without it, the backend is
* silently a no-op (never crashes).
*/
class AndroidDefaultHapticBackend(
private val viewProvider: () -> View? = { null },
private val vibratorProvider: () -> Vibrator? = { null }
) : HapticBackend {
override fun perform(event: HapticEvent) {
val view = viewProvider()
if (view != null && performViaView(view, event)) return
performViaVibrator(event)
}
private fun performViaView(view: View, event: HapticEvent): Boolean {
val constant = when (event) {
HapticEvent.LIGHT -> HapticFeedbackConstants.CONTEXT_CLICK
HapticEvent.MEDIUM -> HapticFeedbackConstants.KEYBOARD_TAP
HapticEvent.HEAVY -> HapticFeedbackConstants.LONG_PRESS
HapticEvent.SUCCESS -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
HapticFeedbackConstants.CONFIRM
} else {
HapticFeedbackConstants.CONTEXT_CLICK
}
HapticEvent.WARNING -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
HapticFeedbackConstants.GESTURE_END
} else {
HapticFeedbackConstants.LONG_PRESS
}
HapticEvent.ERROR -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
HapticFeedbackConstants.REJECT
} else {
HapticFeedbackConstants.LONG_PRESS
}
}
return view.performHapticFeedback(constant)
}
@Suppress("DEPRECATION")
private fun performViaVibrator(event: HapticEvent) {
val vibrator = vibratorProvider() ?: return
if (!vibrator.hasVibrator()) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val predefined = when (event) {
HapticEvent.LIGHT, HapticEvent.SUCCESS -> VibrationEffect.EFFECT_TICK
HapticEvent.MEDIUM, HapticEvent.WARNING -> VibrationEffect.EFFECT_CLICK
HapticEvent.HEAVY, HapticEvent.ERROR -> VibrationEffect.EFFECT_HEAVY_CLICK
}
vibrator.vibrate(VibrationEffect.createPredefined(predefined))
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val (duration, amplitude) = when (event) {
HapticEvent.LIGHT -> 10L to VibrationEffect.DEFAULT_AMPLITUDE
HapticEvent.MEDIUM -> 20L to VibrationEffect.DEFAULT_AMPLITUDE
HapticEvent.HEAVY -> 50L to VibrationEffect.DEFAULT_AMPLITUDE
HapticEvent.SUCCESS -> 30L to VibrationEffect.DEFAULT_AMPLITUDE
HapticEvent.WARNING -> 40L to VibrationEffect.DEFAULT_AMPLITUDE
HapticEvent.ERROR -> 60L to VibrationEffect.DEFAULT_AMPLITUDE
}
vibrator.vibrate(VibrationEffect.createOneShot(duration, amplitude))
return
}
val duration = when (event) {
HapticEvent.LIGHT -> 10L
HapticEvent.MEDIUM -> 20L
HapticEvent.HEAVY -> 50L
HapticEvent.SUCCESS -> 30L
HapticEvent.WARNING -> 40L
HapticEvent.ERROR -> 60L
}
vibrator.vibrate(duration)
}
}
/**
* Android app-wide registry that plumbs an Application Context to the default
* backend. Call [HapticsInit.install] from the Application or Activity init so
* that call-sites in shared code can invoke [Haptics.light] etc. without any
* Compose / View plumbing.
*/
object HapticsInit {
@Volatile private var appContext: Context? = null
@Volatile private var hostView: View? = null
fun install(context: Context) {
appContext = context.applicationContext
}
fun attachView(view: View?) {
hostView = view
}
internal fun defaultBackend(): HapticBackend = AndroidDefaultHapticBackend(
viewProvider = { hostView },
vibratorProvider = { resolveVibrator() }
)
@Suppress("DEPRECATION")
private fun resolveVibrator(): Vibrator? {
val ctx = appContext ?: return null
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(ctx.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
} else {
ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
}
}
actual object Haptics {
@Volatile private var backend: HapticBackend = HapticsInit.defaultBackend()
actual fun light() = backend.perform(HapticEvent.LIGHT)
actual fun medium() = backend.perform(HapticEvent.MEDIUM)
actual fun heavy() = backend.perform(HapticEvent.HEAVY)
actual fun success() = backend.perform(HapticEvent.SUCCESS)
actual fun warning() = backend.perform(HapticEvent.WARNING)
actual fun error() = backend.perform(HapticEvent.ERROR)
actual fun setBackend(backend: HapticBackend) {
this.backend = backend
}
actual fun resetBackend() {
this.backend = HapticsInit.defaultBackend()
}
}