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

View File

@@ -69,8 +69,9 @@ class FcmService : FirebaseMessagingService() {
messageId: String?
): android.app.Notification {
val contentIntent = buildContentIntent(payload, messageId)
val notificationId = (messageId ?: payload.deepLink ?: payload.title).hashCode()
return NotificationCompat.Builder(this, channelId)
val builder = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(payload.title.ifBlank { getString(R.string.app_name) })
.setContentText(payload.body)
@@ -78,7 +79,96 @@ class FcmService : FirebaseMessagingService() {
.setAutoCancel(true)
.setContentIntent(contentIntent)
.setPriority(priorityForChannel(channelId))
.build()
addActionButtons(builder, payload, notificationId, messageId)
return builder.build()
}
/**
* Attach iOS-parity action buttons (`NotificationCategories.swift`) to
* the [builder] based on [payload.type]:
*
* - task_reminder / task_overdue → Complete, Snooze, Open
* - residence_invite → Accept, Decline, Open
* - subscription → no actions (matches iOS TASK_COMPLETED)
*
* All actions fan out to [NotificationActionReceiver] under the
* `com.tt.honeyDue.notifications` package.
*/
private fun addActionButtons(
builder: NotificationCompat.Builder,
payload: NotificationPayload,
notificationId: Int,
messageId: String?
) {
val seed = (messageId ?: payload.deepLink ?: payload.title).hashCode()
val extras: Map<String, Any?> = mapOf(
NotificationActions.EXTRA_TASK_ID to payload.taskId,
NotificationActions.EXTRA_RESIDENCE_ID to payload.residenceId,
NotificationActions.EXTRA_NOTIFICATION_ID to notificationId,
NotificationActions.EXTRA_TITLE to payload.title,
NotificationActions.EXTRA_BODY to payload.body,
NotificationActions.EXTRA_TYPE to payload.type,
NotificationActions.EXTRA_DEEP_LINK to payload.deepLink
)
when (payload.type) {
NotificationChannels.TASK_REMINDER,
NotificationChannels.TASK_OVERDUE -> {
if (payload.taskId != null) {
builder.addAction(
0,
getString(R.string.notif_action_complete),
NotificationActionReceiver.actionPendingIntent(
this, NotificationActions.COMPLETE, seed, extras
)
)
builder.addAction(
0,
getString(R.string.notif_action_snooze),
NotificationActionReceiver.actionPendingIntent(
this, NotificationActions.SNOOZE, seed, extras
)
)
}
builder.addAction(
0,
getString(R.string.notif_action_open),
NotificationActionReceiver.actionPendingIntent(
this, NotificationActions.OPEN, seed, extras
)
)
}
NotificationChannels.RESIDENCE_INVITE -> {
if (payload.residenceId != null) {
builder.addAction(
0,
getString(R.string.notif_action_accept),
NotificationActionReceiver.actionPendingIntent(
this, NotificationActions.ACCEPT_INVITE, seed, extras
)
)
builder.addAction(
0,
getString(R.string.notif_action_decline),
NotificationActionReceiver.actionPendingIntent(
this, NotificationActions.DECLINE_INVITE, seed, extras
)
)
}
builder.addAction(
0,
getString(R.string.notif_action_open),
NotificationActionReceiver.actionPendingIntent(
this, NotificationActions.OPEN, seed, extras
)
)
}
else -> {
// subscription + unknown: tap-to-open only. iOS parity.
}
}
}
private fun buildContentIntent(

View File

@@ -0,0 +1,318 @@
package com.tt.honeyDue.notifications
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.tt.honeyDue.MainActivity
import com.tt.honeyDue.R
import com.tt.honeyDue.models.TaskCompletionCreateRequest
import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.widget.WidgetUpdateManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* BroadcastReceiver for the iOS-parity notification action buttons introduced
* in P4 Stream O. Handles Complete / Snooze / Open for task_reminder +
* task_overdue categories, and Accept / Decline / Open for residence_invite.
*
* Counterpart: `iosApp/iosApp/PushNotifications/PushNotificationManager.swift`
* (handleNotificationAction). Categories defined in
* `iosApp/iosApp/PushNotifications/NotificationCategories.swift`.
*
* There is a pre-existing [com.tt.honeyDue.NotificationActionReceiver] under
* the root package that handles widget-era task-state transitions (mark in
* progress, cancel, etc.). That receiver is untouched by this stream; this
* one lives under `com.tt.honeyDue.notifications` and serves only the push
* action buttons attached by [FcmService].
*/
class NotificationActionReceiver : BroadcastReceiver() {
/**
* Hook for tests. Overridden to intercept async work and force it onto a
* synchronous dispatcher. Production stays on [Dispatchers.IO].
*/
internal var coroutineScopeOverride: CoroutineScope? = null
override fun onReceive(context: Context, intent: Intent) {
val scope = coroutineScopeOverride
if (scope != null) {
// Test path: run synchronously on the provided scope so Robolectric
// assertions can observe post-conditions without goAsync hangs.
scope.launch { handleAction(context.applicationContext, intent) }
return
}
val pending = goAsync()
defaultScope.launch {
try {
handleAction(context.applicationContext, intent)
} catch (t: Throwable) {
Log.e(TAG, "Action handler crashed", t)
} finally {
pending.finish()
}
}
}
internal suspend fun handleAction(context: Context, intent: Intent) {
val action = intent.action ?: run {
Log.w(TAG, "onReceive with null action")
return
}
val taskId = intent.longTaskId()
val residenceId = intent.longResidenceId()
val notificationId = intent.getIntExtra(NotificationActions.EXTRA_NOTIFICATION_ID, 0)
val title = intent.getStringExtra(NotificationActions.EXTRA_TITLE)
val body = intent.getStringExtra(NotificationActions.EXTRA_BODY)
val type = intent.getStringExtra(NotificationActions.EXTRA_TYPE)
val deepLink = intent.getStringExtra(NotificationActions.EXTRA_DEEP_LINK)
Log.d(TAG, "action=$action taskId=$taskId residenceId=$residenceId")
when (action) {
NotificationActions.COMPLETE -> handleComplete(context, taskId, notificationId)
NotificationActions.SNOOZE -> handleSnooze(context, taskId, title, body, type, notificationId)
NotificationActions.OPEN -> handleOpen(context, taskId, residenceId, deepLink, notificationId)
NotificationActions.ACCEPT_INVITE -> handleAcceptInvite(context, residenceId, notificationId)
NotificationActions.DECLINE_INVITE -> handleDeclineInvite(context, residenceId, notificationId)
NotificationActions.SNOOZE_FIRE -> handleSnoozeFire(context, taskId, title, body, type)
else -> Log.w(TAG, "Unknown action: $action")
}
}
// ---------------- Complete ----------------
private suspend fun handleComplete(context: Context, taskId: Long?, notificationId: Int) {
if (taskId == null) {
Log.w(TAG, "COMPLETE without task_id — no-op")
return
}
val request = TaskCompletionCreateRequest(
taskId = taskId.toInt(),
completedAt = null,
notes = "Completed from notification",
actualCost = null,
rating = null,
imageUrls = null
)
when (val result = APILayer.createTaskCompletion(request)) {
is ApiResult.Success -> {
Log.d(TAG, "Task $taskId completed from notification")
cancelNotification(context, notificationId)
WidgetUpdateManager.forceRefresh(context)
}
is ApiResult.Error -> {
// Leave the notification so the user can retry.
Log.e(TAG, "Complete failed: ${result.message}")
}
else -> Log.w(TAG, "Unexpected ApiResult from createTaskCompletion")
}
}
// ---------------- Snooze ----------------
private fun handleSnooze(
context: Context,
taskId: Long?,
title: String?,
body: String?,
type: String?,
notificationId: Int
) {
if (taskId == null) {
Log.w(TAG, "SNOOZE without task_id — no-op")
return
}
SnoozeScheduler.schedule(
context = context,
taskId = taskId,
delayMs = NotificationActions.SNOOZE_DELAY_MS,
title = title,
body = body,
type = type
)
cancelNotification(context, notificationId)
}
/** Fired by [AlarmManager] when a snooze elapses — rebuild + post the notification. */
private fun handleSnoozeFire(
context: Context,
taskId: Long?,
title: String?,
body: String?,
type: String?
) {
if (taskId == null) {
Log.w(TAG, "SNOOZE_FIRE without task_id — no-op")
return
}
NotificationChannels.ensureChannels(context)
val channelId = NotificationChannels.channelIdForType(type ?: NotificationChannels.TASK_REMINDER)
val contentIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(FcmService.EXTRA_TASK_ID, taskId)
putExtra(FcmService.EXTRA_TYPE, type)
}
val pi = PendingIntent.getActivity(
context,
taskId.toInt(),
contentIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title ?: context.getString(R.string.app_name))
.setContentText(body ?: "")
.setStyle(NotificationCompat.BigTextStyle().bigText(body ?: ""))
.setAutoCancel(true)
.setContentIntent(pi)
.build()
NotificationManagerCompat.from(context).apply {
if (areNotificationsEnabled()) {
notify(taskId.hashCode(), notification)
}
}
}
// ---------------- Open ----------------
private fun handleOpen(
context: Context,
taskId: Long?,
residenceId: Long?,
deepLink: String?,
notificationId: Int
) {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP
deepLink?.takeIf { it.isNotBlank() }?.let { data = Uri.parse(it) }
taskId?.let { putExtra(FcmService.EXTRA_TASK_ID, it) }
residenceId?.let { putExtra(FcmService.EXTRA_RESIDENCE_ID, it) }
}
context.startActivity(intent)
cancelNotification(context, notificationId)
}
// ---------------- Accept / Decline invite ----------------
private fun handleAcceptInvite(context: Context, residenceId: Long?, notificationId: Int) {
if (residenceId == null) {
Log.w(TAG, "ACCEPT_INVITE without residence_id — no-op")
return
}
// APILayer.acceptResidenceInvite does not yet exist — see TODO below.
// composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
// (add after createTaskCompletion at ~line 790). Backend endpoint
// intended at POST /api/residences/{id}/invite/accept.
Log.w(
TAG,
"TODO: APILayer.acceptResidenceInvite($residenceId) — implement in " +
"composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt " +
"(follow createTaskCompletion pattern; POST /api/residences/{id}/invite/accept)"
)
// Best-effort UX: cancel the notification so the user isn't stuck
// on a button that does nothing visible. The invite will be picked
// up on next app-open from /api/residences/pending.
cancelNotification(context, notificationId)
}
private fun handleDeclineInvite(context: Context, residenceId: Long?, notificationId: Int) {
if (residenceId == null) {
Log.w(TAG, "DECLINE_INVITE without residence_id — no-op")
return
}
Log.w(
TAG,
"TODO: APILayer.declineResidenceInvite($residenceId) — implement in " +
"composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt " +
"(follow createTaskCompletion pattern; POST /api/residences/{id}/invite/decline)"
)
cancelNotification(context, notificationId)
}
// ---------------- helpers ----------------
private fun cancelNotification(context: Context, notificationId: Int) {
if (notificationId == 0) return
val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
if (mgr != null) {
mgr.cancel(notificationId)
} else {
NotificationManagerCompat.from(context).cancel(notificationId)
}
}
companion object {
private const val TAG = "NotificationAction"
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/**
* Build a PendingIntent pointing at this receiver for the given action.
* Used by [FcmService] to attach action buttons.
*/
fun actionPendingIntent(
context: Context,
action: String,
requestCodeSeed: Int,
extras: Map<String, Any?>
): PendingIntent {
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
this.action = action
extras.forEach { (k, v) ->
when (v) {
is Int -> putExtra(k, v)
is Long -> putExtra(k, v)
is String -> putExtra(k, v)
null -> { /* skip */ }
else -> putExtra(k, v.toString())
}
}
}
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
return PendingIntent.getBroadcast(
context,
(action.hashCode() xor requestCodeSeed),
intent,
flags
)
}
}
}
private fun Intent.longTaskId(): Long? {
if (!hasExtra(NotificationActions.EXTRA_TASK_ID)) return null
val asLong = getLongExtra(NotificationActions.EXTRA_TASK_ID, Long.MIN_VALUE)
if (asLong != Long.MIN_VALUE) return asLong
val asInt = getIntExtra(NotificationActions.EXTRA_TASK_ID, Int.MIN_VALUE)
return if (asInt == Int.MIN_VALUE) null else asInt.toLong()
}
private fun Intent.longResidenceId(): Long? {
if (!hasExtra(NotificationActions.EXTRA_RESIDENCE_ID)) return null
val asLong = getLongExtra(NotificationActions.EXTRA_RESIDENCE_ID, Long.MIN_VALUE)
if (asLong != Long.MIN_VALUE) return asLong
val asInt = getIntExtra(NotificationActions.EXTRA_RESIDENCE_ID, Int.MIN_VALUE)
return if (asInt == Int.MIN_VALUE) null else asInt.toLong()
}

View File

@@ -0,0 +1,37 @@
package com.tt.honeyDue.notifications
/**
* Action + extra constants for the iOS-parity notification action buttons.
*
* Mirrors the action identifiers from
* `iosApp/iosApp/PushNotifications/NotificationCategories.swift`
* (Complete, Snooze, Open for task_reminder / task_overdue; Accept, Decline, Open
* for residence_invite). Consumed by [NotificationActionReceiver] and attached
* to the notifications built by [FcmService].
*
* Action strings are fully-qualified so they never collide with other
* BroadcastReceivers registered in the app (e.g. the legacy
* `com.tt.honeyDue.NotificationActionReceiver` which handles task-state
* transitions from widget-era notifications).
*/
object NotificationActions {
const val COMPLETE: String = "com.tt.honeyDue.action.COMPLETE_TASK"
const val SNOOZE: String = "com.tt.honeyDue.action.SNOOZE_TASK"
const val OPEN: String = "com.tt.honeyDue.action.OPEN"
const val ACCEPT_INVITE: String = "com.tt.honeyDue.action.ACCEPT_INVITE"
const val DECLINE_INVITE: String = "com.tt.honeyDue.action.DECLINE_INVITE"
/** Firing a SNOOZE fires this alarm action when the 30-min wait elapses. */
const val SNOOZE_FIRE: String = "com.tt.honeyDue.action.SNOOZE_FIRE"
const val EXTRA_TASK_ID: String = "task_id"
const val EXTRA_RESIDENCE_ID: String = "residence_id"
const val EXTRA_NOTIFICATION_ID: String = "notification_id"
const val EXTRA_TITLE: String = "title"
const val EXTRA_BODY: String = "body"
const val EXTRA_TYPE: String = "type"
const val EXTRA_DEEP_LINK: String = "deep_link"
/** Snooze delay that matches `iosApp/iosApp/PushNotifications/NotificationCategories.swift`. */
const val SNOOZE_DELAY_MS: Long = 30L * 60L * 1000L
}

View File

@@ -0,0 +1,106 @@
package com.tt.honeyDue.notifications
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
/**
* Schedules a "snooze" redelivery of a task notification using [AlarmManager].
*
* iOS achieves snooze via UNNotificationRequest with a 30-minute
* UNTimeIntervalNotificationTrigger (see
* `iosApp/iosApp/PushNotifications/NotificationCategories.swift`). Android
* has no equivalent, so we lean on AlarmManager to wake us in 30 minutes
* and rebroadcast to [NotificationActionReceiver], which re-posts the
* notification via [FcmService]'s builder path.
*
* Exact alarms (`setExactAndAllowWhileIdle`) require the `SCHEDULE_EXACT_ALARM`
* permission on Android 12+. Because P4 Stream O is scoped to receiver-only
* changes in the manifest, we probe [AlarmManager.canScheduleExactAlarms] at
* runtime and fall back to the inexact `setAndAllowWhileIdle` variant when
* the permission has not been granted — snooze fidelity in that case is
* "roughly 30 min" which is within Android's Doze tolerance and acceptable.
*/
object SnoozeScheduler {
private const val TAG = "SnoozeScheduler"
/**
* Schedule a snooze alarm [delayMs] into the future for the given [taskId].
* If an alarm already exists for this task id, it is replaced (pending
* intents are reused by request code = taskId).
*/
fun schedule(
context: Context,
taskId: Long,
delayMs: Long = NotificationActions.SNOOZE_DELAY_MS,
title: String? = null,
body: String? = null,
type: String? = null
) {
val alarm = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
if (alarm == null) {
Log.w(TAG, "AlarmManager unavailable; cannot schedule snooze for task=$taskId")
return
}
val triggerAt = System.currentTimeMillis() + delayMs
val pi = pendingIntent(context, taskId, title, body, type, create = true)
?: run {
Log.w(TAG, "Unable to build snooze PendingIntent for task=$taskId")
return
}
val useExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
alarm.canScheduleExactAlarms()
if (useExact) {
alarm.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
} else {
// Fallback path when SCHEDULE_EXACT_ALARM is revoked. Inexact but
// still wakes the device from Doze.
alarm.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
}
Log.d(TAG, "Scheduled snooze for task=$taskId at $triggerAt (exact=$useExact)")
}
/** Cancel a scheduled snooze alarm for [taskId], if any. */
fun cancel(context: Context, taskId: Long) {
val alarm = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return
val pi = pendingIntent(context, taskId, null, null, null, create = false) ?: return
alarm.cancel(pi)
pi.cancel()
}
private fun pendingIntent(
context: Context,
taskId: Long,
title: String?,
body: String?,
type: String?,
create: Boolean
): PendingIntent? {
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.SNOOZE_FIRE
putExtra(NotificationActions.EXTRA_TASK_ID, taskId)
title?.let { putExtra(NotificationActions.EXTRA_TITLE, it) }
body?.let { putExtra(NotificationActions.EXTRA_BODY, it) }
type?.let { putExtra(NotificationActions.EXTRA_TYPE, it) }
}
val baseFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val lookupFlags = if (create) baseFlags else PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
val requestCode = requestCode(taskId)
return PendingIntent.getBroadcast(context, requestCode, intent, lookupFlags)
}
/** Stable request code for per-task snoozes so cancel() finds the same PI. */
private fun requestCode(taskId: Long): Int {
// Fold 64-bit task id into a stable 32-bit request code.
return (taskId xor (taskId ushr 32)).toInt() xor SNOOZE_REQUEST_SALT
}
private const val SNOOZE_REQUEST_SALT = 0x534E5A45.toInt() // "SNZE"
}

View File

@@ -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()
}
}