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

@@ -90,7 +90,7 @@
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="@string/default_notification_channel_id" />
<!-- Notification Action Receiver -->
<!-- Legacy Notification Action Receiver (widget-era task state actions) -->
<receiver
android:name=".NotificationActionReceiver"
android:exported="false">
@@ -103,6 +103,24 @@
</intent-filter>
</receiver>
<!-- iOS-parity push action receiver (P4 Stream O).
Handles Complete/Snooze/Open for task_reminder & task_overdue, and
Accept/Decline/Open for residence_invite. Wired to notifications
built by FcmService.onMessageReceived. Also receives the delayed
SNOOZE_FIRE alarm from SnoozeScheduler. -->
<receiver
android:name=".notifications.NotificationActionReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.tt.honeyDue.action.COMPLETE_TASK" />
<action android:name="com.tt.honeyDue.action.SNOOZE_TASK" />
<action android:name="com.tt.honeyDue.action.OPEN" />
<action android:name="com.tt.honeyDue.action.ACCEPT_INVITE" />
<action android:name="com.tt.honeyDue.action.DECLINE_INVITE" />
<action android:name="com.tt.honeyDue.action.SNOOZE_FIRE" />
</intent-filter>
</receiver>
<!-- Widget Task Complete Receiver -->
<receiver
android:name=".widget.WidgetTaskActionReceiver"

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

View File

@@ -11,4 +11,11 @@
<string name="widget_large_name">honeyDue Dashboard</string>
<string name="widget_large_description">Full task dashboard with stats and interactive actions (Pro feature)</string>
<!-- Notification action buttons (P4 Stream O — iOS parity) -->
<string name="notif_action_complete">Complete</string>
<string name="notif_action_snooze">Snooze</string>
<string name="notif_action_open">Open</string>
<string name="notif_action_accept">Accept</string>
<string name="notif_action_decline">Decline</string>
</resources>

View File

@@ -0,0 +1,363 @@
package com.tt.honeyDue.notifications
import android.app.AlarmManager
import android.app.Application
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.test.core.app.ApplicationProvider
import com.tt.honeyDue.MainActivity
import com.tt.honeyDue.models.TaskCompletionCreateRequest
import com.tt.honeyDue.models.TaskCompletionResponse
import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.widget.WidgetUpdateManager
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockkObject
import io.mockk.runs
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
/**
* Unit tests for the iOS-parity [NotificationActionReceiver] (P4 Stream O).
*
* Covers the action dispatch table: Complete, Snooze, Open, Accept, Decline,
* plus defensive handling of missing extras.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
class NotificationActionReceiverTest {
private lateinit var context: Context
private lateinit var app: Application
private lateinit var notificationManager: NotificationManager
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
app = context.applicationContext as Application
notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancelAll()
mockkObject(APILayer)
mockkObject(WidgetUpdateManager)
every { WidgetUpdateManager.forceRefresh(any()) } just runs
}
@After
fun tearDown() {
unmockkAll()
notificationManager.cancelAll()
}
// Build a receiver whose async work runs synchronously on the test scheduler.
private fun receiverFor(scope: CoroutineScope): NotificationActionReceiver =
NotificationActionReceiver().apply { coroutineScopeOverride = scope }
private fun successCompletion(taskId: Int) = TaskCompletionResponse(
id = 1,
taskId = taskId,
completedBy = null,
completedAt = "2026-04-16T00:00:00Z",
notes = "Completed from notification",
actualCost = null,
rating = null,
images = emptyList(),
createdAt = "2026-04-16T00:00:00Z",
updatedTask = null
)
private fun postDummyNotification(id: Int) {
// Create channels so the notify() call below actually posts on O+.
NotificationChannels.ensureChannels(context)
val n = androidx.core.app.NotificationCompat.Builder(context, NotificationChannels.TASK_REMINDER)
.setSmallIcon(com.tt.honeyDue.R.mipmap.ic_launcher)
.setContentTitle("t")
.setContentText("b")
.build()
notificationManager.notify(id, n)
assertTrue(
"precondition: dummy notification should be posted",
notificationManager.activeNotifications.any { it.id == id }
)
}
// ---------- 1. COMPLETE dispatches to APILayer + cancels notification ----------
@Test
fun complete_callsCreateTaskCompletion_and_cancelsNotification() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(SupervisorJob() + dispatcher)
coEvery { APILayer.createTaskCompletion(any()) } returns ApiResult.Success(successCompletion(42))
val notifId = 9001
postDummyNotification(notifId)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.COMPLETE
putExtra(NotificationActions.EXTRA_TASK_ID, 42L)
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
}
receiverFor(scope).onReceive(context, intent)
advanceUntilIdle()
coVerify(exactly = 1) {
APILayer.createTaskCompletion(match<TaskCompletionCreateRequest> {
it.taskId == 42 && it.notes == "Completed from notification"
})
}
verify(exactly = 1) { WidgetUpdateManager.forceRefresh(any()) }
assertFalse(
"notification should be canceled on success",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
// ---------- 2. COMPLETE failure: notification survives for retry ----------
@Test
fun complete_apiFailure_keepsNotification_forRetry() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(SupervisorJob() + dispatcher)
coEvery { APILayer.createTaskCompletion(any()) } returns ApiResult.Error("nope", 500)
val notifId = 9002
postDummyNotification(notifId)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.COMPLETE
putExtra(NotificationActions.EXTRA_TASK_ID, 7L)
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
}
receiverFor(scope).onReceive(context, intent)
advanceUntilIdle()
coVerify(exactly = 1) { APILayer.createTaskCompletion(any()) }
verify(exactly = 0) { WidgetUpdateManager.forceRefresh(any()) }
assertTrue(
"notification should remain posted so the user can retry",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
// ---------- 3. SNOOZE: schedules AlarmManager +30 min ----------
@Test
fun snooze_schedulesAlarm_thirtyMinutesOut() = runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
val notifId = 9003
postDummyNotification(notifId)
val beforeMs = System.currentTimeMillis()
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.SNOOZE
putExtra(NotificationActions.EXTRA_TASK_ID, 55L)
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
putExtra(NotificationActions.EXTRA_TITLE, "Title")
putExtra(NotificationActions.EXTRA_BODY, "Body")
putExtra(NotificationActions.EXTRA_TYPE, NotificationChannels.TASK_REMINDER)
}
receiverFor(scope).onReceive(context, intent)
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val scheduled = shadowOf(am).scheduledAlarms
assertEquals("exactly one snooze alarm scheduled", 1, scheduled.size)
val alarm = scheduled.first()
val delta = alarm.triggerAtTime - beforeMs
val expected = NotificationActions.SNOOZE_DELAY_MS
// Allow ±2s jitter around the expected 30 minutes.
assertTrue(
"snooze alarm should fire ~30 min out (delta=$delta)",
delta in (expected - 2_000)..(expected + 2_000)
)
assertFalse(
"original notification should be cleared after snooze",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
// ---------- 4. OPEN: launches MainActivity with deep-link ----------
@Test
fun open_launchesMainActivity_withDeepLinkAndExtras() = runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
val notifId = 9004
postDummyNotification(notifId)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.OPEN
putExtra(NotificationActions.EXTRA_TASK_ID, 77L)
putExtra(NotificationActions.EXTRA_RESIDENCE_ID, 3L)
putExtra(NotificationActions.EXTRA_DEEP_LINK, "honeydue://task/77")
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
}
receiverFor(scope).onReceive(context, intent)
val started = shadowOf(app).nextStartedActivity
assertNotNull("MainActivity should be launched", started)
assertEquals(MainActivity::class.java.name, started.component?.className)
assertEquals("honeydue", started.data?.scheme)
assertEquals("77", started.data?.pathSegments?.last())
assertEquals(77L, started.getLongExtra(FcmService.EXTRA_TASK_ID, -1))
assertEquals(3L, started.getLongExtra(FcmService.EXTRA_RESIDENCE_ID, -1))
assertFalse(
"notification should be canceled after open",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
// ---------- 5. ACCEPT_INVITE: clears notification (TODO: API call) ----------
@Test
fun acceptInvite_withResidenceId_cancelsNotification() = runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
val notifId = 9005
postDummyNotification(notifId)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.ACCEPT_INVITE
putExtra(NotificationActions.EXTRA_RESIDENCE_ID, 101L)
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
}
receiverFor(scope).onReceive(context, intent)
// API method does not yet exist — see TODO in receiver. Expectation is
// that the notification is still cleared (best-effort UX) and we did
// NOT crash. APILayer.createTaskCompletion should NOT have been called.
coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) }
assertFalse(
"invite notification should be cleared on accept",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
// ---------- 6. Missing extras: no crash, no-op ----------
@Test
fun complete_withoutTaskId_isNoOp() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(SupervisorJob() + dispatcher)
val notifId = 9006
postDummyNotification(notifId)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.COMPLETE
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
// no task_id
}
receiverFor(scope).onReceive(context, intent)
advanceUntilIdle()
coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) }
assertTrue(
"notification must survive a malformed COMPLETE",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
// ---------- 7. Unknown action: no-op ----------
@Test
fun unknownAction_isNoOp() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(SupervisorJob() + dispatcher)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = "com.tt.honeyDue.action.NONSENSE"
putExtra(NotificationActions.EXTRA_TASK_ID, 1L)
}
receiverFor(scope).onReceive(context, intent)
advanceUntilIdle()
coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) }
verify(exactly = 0) { WidgetUpdateManager.forceRefresh(any()) }
// No started activity either.
assertNull(shadowOf(app).nextStartedActivity)
scope.cancel()
}
// ---------- 8. Null action: no crash ----------
@Test
fun nullAction_doesNotCrash() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(SupervisorJob() + dispatcher)
val intent = Intent() // action is null
// Should not throw.
receiverFor(scope).onReceive(context, intent)
advanceUntilIdle()
coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) }
scope.cancel()
}
// ---------- 9. Decline invite: clears notification ----------
@Test
fun declineInvite_withResidenceId_cancelsNotification() = runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
val notifId = 9009
postDummyNotification(notifId)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.DECLINE_INVITE
putExtra(NotificationActions.EXTRA_RESIDENCE_ID, 77L)
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
}
receiverFor(scope).onReceive(context, intent)
assertFalse(
"invite notification should be cleared on decline",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
}

View File

@@ -0,0 +1,91 @@
package com.tt.honeyDue.notifications
import android.app.AlarmManager
import android.content.Context
import android.os.Build
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
/**
* Tests for [SnoozeScheduler] — verifies the AlarmManager scheduling path
* used by the P4 Stream O notification Snooze action.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
class SnoozeSchedulerTest {
private lateinit var context: Context
private lateinit var am: AlarmManager
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
// Robolectric's ShadowAlarmManager doesn't have an explicit clear, but
// scheduledAlarms is filtered by live pending intents so cancel() the
// world before each test.
shadowOf(am).scheduledAlarms.toList().forEach { alarm ->
alarm.operation?.let { am.cancel(it) }
}
}
// ---------- 7. schedule() sets alarm 30 minutes in future ----------
@Test
fun schedule_setsAlarmThirtyMinutesInFuture() {
val before = System.currentTimeMillis()
SnoozeScheduler.schedule(
context = context,
taskId = 123L,
title = "t",
body = "b",
type = NotificationChannels.TASK_REMINDER
)
val scheduled = shadowOf(am).scheduledAlarms
assertEquals(1, scheduled.size)
val delta = scheduled.first().triggerAtTime - before
val expected = NotificationActions.SNOOZE_DELAY_MS
assertTrue(
"expected ~30 min trigger, got delta=$delta",
delta in (expected - 2_000)..(expected + 2_000)
)
}
// ---------- 8. cancel() removes the pending alarm ----------
@Test
fun cancel_preventsLaterDelivery() {
SnoozeScheduler.schedule(context, taskId = 456L)
assertEquals(
"precondition: alarm scheduled",
1,
shadowOf(am).scheduledAlarms.size
)
SnoozeScheduler.cancel(context, taskId = 456L)
// After cancel(), the PendingIntent is consumed so scheduledAlarms
// shrinks back to zero (Robolectric matches by PI equality).
assertEquals(
"alarm should be gone after cancel()",
0,
shadowOf(am).scheduledAlarms.size
)
}
// Bonus coverage: different task ids get independent scheduling slots.
@Test
fun schedule_twoDifferentTasks_yieldsTwoAlarms() {
SnoozeScheduler.schedule(context, taskId = 1L)
SnoozeScheduler.schedule(context, taskId = 2L)
assertEquals(2, shadowOf(am).scheduledAlarms.size)
}
}

View File

@@ -0,0 +1,103 @@
package com.tt.honeyDue.ui.haptics
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/**
* Unit tests for the cross-platform [Haptics] API on Android.
*
* Uses a pluggable [HapticBackend] to verify the contract without
* depending on real hardware (no-op in JVM unit tests otherwise).
*
* Mirrors iOS haptic taxonomy:
* UIImpactFeedbackGenerator(.light) -> light
* UIImpactFeedbackGenerator(.medium) -> medium
* UIImpactFeedbackGenerator(.heavy) -> heavy
* UINotificationFeedbackGenerator(.success|.warning|.error)
*/
@RunWith(RobolectricTestRunner::class)
class HapticsAndroidTest {
private lateinit var fake: RecordingHapticBackend
@Before
fun setUp() {
fake = RecordingHapticBackend()
Haptics.setBackend(fake)
}
@After
fun tearDown() {
Haptics.resetBackend()
}
@Test
fun light_delegatesToBackend_withLightEvent() {
Haptics.light()
assertEquals(listOf(HapticEvent.LIGHT), fake.events)
}
@Test
fun medium_delegatesToBackend_withMediumEvent() {
Haptics.medium()
assertEquals(listOf(HapticEvent.MEDIUM), fake.events)
}
@Test
fun heavy_delegatesToBackend_withHeavyEvent() {
Haptics.heavy()
assertEquals(listOf(HapticEvent.HEAVY), fake.events)
}
@Test
fun success_delegatesToBackend_withSuccessEvent() {
Haptics.success()
assertEquals(listOf(HapticEvent.SUCCESS), fake.events)
}
@Test
fun warning_delegatesToBackend_withWarningEvent() {
Haptics.warning()
assertEquals(listOf(HapticEvent.WARNING), fake.events)
}
@Test
fun error_delegatesToBackend_withErrorEvent() {
Haptics.error()
assertEquals(listOf(HapticEvent.ERROR), fake.events)
}
@Test
fun multipleCalls_areRecordedInOrder() {
Haptics.light()
Haptics.success()
Haptics.error()
assertEquals(
listOf(HapticEvent.LIGHT, HapticEvent.SUCCESS, HapticEvent.ERROR),
fake.events
)
}
@Test
fun androidDefaultBackend_isResilientWithoutInstalledContext() {
Haptics.resetBackend()
// Default backend must not crash even when no context/view is installed.
Haptics.light()
Haptics.success()
Haptics.error()
assertTrue("platform default backend should be resilient", true)
}
}
/** Test-only backend that records events for assertion. */
private class RecordingHapticBackend : HapticBackend {
val events = mutableListOf<HapticEvent>()
override fun perform(event: HapticEvent) {
events += event
}
}

View File

@@ -152,3 +152,13 @@ data class PhotoViewerRoute(
// Upgrade/Subscription Route
@Serializable
object UpgradeRoute
// Full-screen Free vs. Pro feature comparison (P2 Stream E — replaces
// the old FeatureComparisonDialog). Matches iOS FeatureComparisonView.
@Serializable
object FeatureComparisonRoute
// Task Suggestions Route (P2 Stream H — standalone, non-onboarding entry
// to personalized task suggestions for a residence).
@Serializable
data class TaskSuggestionsRoute(val residenceId: Int)

View File

@@ -0,0 +1,50 @@
package com.tt.honeyDue.ui.haptics
/**
* The full haptic taxonomy, modeled after iOS:
* - LIGHT / MEDIUM / HEAVY → UIImpactFeedbackGenerator(style:)
* - SUCCESS / WARNING / ERROR → UINotificationFeedbackGenerator
*/
enum class HapticEvent { LIGHT, MEDIUM, HEAVY, SUCCESS, WARNING, ERROR }
/**
* Pluggable backend so tests can swap out platform-specific mechanisms
* (Vibrator/HapticFeedbackConstants on Android, UI*FeedbackGenerator on iOS).
*/
interface HapticBackend {
fun perform(event: HapticEvent)
}
/** Backend that does nothing — used on JVM/Web/test fallbacks. */
object NoopHapticBackend : HapticBackend {
override fun perform(event: HapticEvent) { /* no-op */ }
}
/**
* Cross-platform haptic feedback API.
*
* Call-sites in common code stay terse:
* - [Haptics.light] — selection/tap (iOS UIImpactFeedbackGenerator.light)
* - [Haptics.medium] — confirmations
* - [Haptics.heavy] — important actions
* - [Haptics.success] — positive completion (iOS UINotificationFeedbackGenerator.success)
* - [Haptics.warning] — caution
* - [Haptics.error] — validation / failure
*
* Each platform provides a default [HapticBackend]. Tests may override via
* [Haptics.setBackend] and restore via [Haptics.resetBackend].
*/
expect object Haptics {
fun light()
fun medium()
fun heavy()
fun success()
fun warning()
fun error()
/** Override the active backend (for tests or custom delegation). */
fun setBackend(backend: HapticBackend)
/** Restore the platform default backend. */
fun resetBackend()
}

View File

@@ -0,0 +1,328 @@
package com.tt.honeyDue.ui.screens.task
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tt.honeyDue.models.TaskSuggestionResponse
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.ui.components.common.StandardCard
import com.tt.honeyDue.ui.theme.AppRadius
import com.tt.honeyDue.ui.theme.AppSpacing
import com.tt.honeyDue.util.ErrorMessageParser
/**
* Standalone screen that lets users pick personalized task suggestions
* outside the onboarding flow. Android port of iOS TaskSuggestionsView as
* a regular destination (not an inline dropdown).
*
* Flow:
* 1. On entry, loads via APILayer.getTaskSuggestions(residenceId).
* 2. Each row has an Accept button that fires APILayer.createTask with
* template fields + templateId backlink.
* 3. Non-onboarding analytics event task_suggestion_accepted fires on
* each successful accept.
* 4. Skip is a pop with no task created (handled by onNavigateBack).
* 5. Supports pull-to-refresh.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskSuggestionsScreen(
residenceId: Int,
onNavigateBack: () -> Unit,
onSuggestionAccepted: (templateId: Int) -> Unit = {},
viewModel: TaskSuggestionsViewModel = viewModel {
TaskSuggestionsViewModel(residenceId = residenceId)
}
) {
val suggestionsState by viewModel.suggestionsState.collectAsState()
val acceptState by viewModel.acceptState.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
if (suggestionsState is ApiResult.Idle) {
viewModel.load()
}
}
LaunchedEffect(suggestionsState) {
if (suggestionsState !is ApiResult.Loading) {
isRefreshing = false
}
}
LaunchedEffect(acceptState) {
if (acceptState is ApiResult.Success) {
val tid = viewModel.lastAcceptedTemplateId
if (tid != null) onSuggestionAccepted(tid)
viewModel.resetAcceptState()
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = "Suggested Tasks", fontWeight = FontWeight.SemiBold)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
OutlinedButton(
onClick = onNavigateBack,
modifier = Modifier.padding(end = AppSpacing.md)
) { Text("Skip") }
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
viewModel.load()
},
modifier = Modifier.fillMaxSize().padding(paddingValues)
) {
when (val state = suggestionsState) {
is ApiResult.Loading, ApiResult.Idle -> {
Box(Modifier.fillMaxSize(), Alignment.Center) {
CircularProgressIndicator()
}
}
is ApiResult.Error -> {
ErrorView(
message = ErrorMessageParser.parse(state.message),
onRetry = { viewModel.retry() }
)
}
is ApiResult.Success -> {
if (state.data.suggestions.isEmpty()) {
EmptyView()
} else {
SuggestionsList(
suggestions = state.data.suggestions,
acceptState = acceptState,
onAccept = viewModel::accept
)
}
}
}
(acceptState as? ApiResult.Error)?.let { err ->
Box(
modifier = Modifier.fillMaxSize().padding(AppSpacing.lg),
contentAlignment = Alignment.BottomCenter
) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = RoundedCornerShape(AppRadius.md),
tonalElevation = 4.dp
) {
Row(
modifier = Modifier.padding(AppSpacing.md),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm)
) {
Icon(
imageVector = Icons.Default.ErrorOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Text(
text = ErrorMessageParser.parse(err.message),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
}
}
}
}
@Composable
private fun SuggestionsList(
suggestions: List<TaskSuggestionResponse>,
acceptState: ApiResult<*>,
onAccept: (TaskSuggestionResponse) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = AppSpacing.lg,
end = AppSpacing.lg,
top = AppSpacing.md,
bottom = AppSpacing.xl
),
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)
) {
items(suggestions, key = { it.template.id }) { suggestion ->
SuggestionRow(
suggestion = suggestion,
isAccepting = acceptState is ApiResult.Loading,
onAccept = { onAccept(suggestion) }
)
}
}
}
@Composable
private fun SuggestionRow(
suggestion: TaskSuggestionResponse,
isAccepting: Boolean,
onAccept: () -> Unit
) {
val template = suggestion.template
StandardCard(
modifier = Modifier.fillMaxWidth(),
contentPadding = AppSpacing.md
) {
Column(verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)) {
Text(
text = template.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
if (template.description.isNotBlank()) {
Text(
text = template.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm)
) {
Text(
text = template.categoryName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = template.frequencyDisplay,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Button(
onClick = onAccept,
enabled = !isAccepting,
modifier = Modifier.fillMaxWidth().height(44.dp),
shape = RoundedCornerShape(AppRadius.md)
) {
if (isAccepting) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Icon(Icons.Default.Check, contentDescription = null)
Spacer(Modifier.width(AppSpacing.sm))
Text("Accept", fontWeight = FontWeight.SemiBold)
}
}
}
}
}
@Composable
private fun ErrorView(message: String, onRetry: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.md, Alignment.CenterVertically)
) {
Icon(
imageVector = Icons.Default.ErrorOutline,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error
)
Text(text = "Couldn't load suggestions", style = MaterialTheme.typography.titleMedium)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Button(onClick = onRetry) { Text("Retry") }
}
}
@Composable
private fun EmptyView() {
Column(
modifier = Modifier.fillMaxSize().padding(AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.md, Alignment.CenterVertically)
) {
Icon(
imageVector = Icons.Default.Checklist,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Text(text = "No suggestions yet", style = MaterialTheme.typography.titleMedium)
Text(
text = "Complete your home profile to see personalized recommendations.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@@ -0,0 +1,100 @@
package com.tt.honeyDue.ui.screens.task
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tt.honeyDue.analytics.PostHogAnalytics
import com.tt.honeyDue.models.TaskCreateRequest
import com.tt.honeyDue.models.TaskResponse
import com.tt.honeyDue.models.TaskSuggestionResponse
import com.tt.honeyDue.models.TaskSuggestionsResponse
import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.network.ApiResult
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* ViewModel for the standalone TaskSuggestionsScreen — the non-onboarding
* path into personalized task suggestions. Event naming matches the
* non-onboarding convention used by the templates browser.
*/
class TaskSuggestionsViewModel(
private val residenceId: Int,
private val loadSuggestions: suspend () -> ApiResult<TaskSuggestionsResponse> = {
APILayer.getTaskSuggestions(residenceId)
},
private val createTask: suspend (TaskCreateRequest) -> ApiResult<TaskResponse> = { req ->
APILayer.createTask(req)
},
private val analytics: (String, Map<String, Any>) -> Unit = { name, props ->
PostHogAnalytics.capture(name, props)
}
) : ViewModel() {
private val _suggestionsState =
MutableStateFlow<ApiResult<TaskSuggestionsResponse>>(ApiResult.Idle)
val suggestionsState: StateFlow<ApiResult<TaskSuggestionsResponse>> =
_suggestionsState.asStateFlow()
private val _acceptState = MutableStateFlow<ApiResult<TaskResponse>>(ApiResult.Idle)
val acceptState: StateFlow<ApiResult<TaskResponse>> = _acceptState.asStateFlow()
var lastAcceptedTemplateId: Int? = null
private set
fun load() {
viewModelScope.launch {
_suggestionsState.value = ApiResult.Loading
_suggestionsState.value = loadSuggestions()
}
}
fun retry() = load()
fun accept(suggestion: TaskSuggestionResponse) {
val template = suggestion.template
val request = TaskCreateRequest(
residenceId = residenceId,
title = template.title,
description = template.description.takeIf { it.isNotBlank() },
categoryId = template.categoryId,
frequencyId = template.frequencyId,
templateId = template.id
)
viewModelScope.launch {
_acceptState.value = ApiResult.Loading
val result = createTask(request)
_acceptState.value = when (result) {
is ApiResult.Success -> {
lastAcceptedTemplateId = template.id
analytics(
EVENT_TASK_SUGGESTION_ACCEPTED,
buildMap {
put("template_id", template.id)
put("relevance_score", suggestion.relevanceScore)
template.categoryId?.let { put("category_id", it) }
}
)
ApiResult.Success(result.data)
}
is ApiResult.Error -> result
ApiResult.Loading -> ApiResult.Loading
ApiResult.Idle -> ApiResult.Idle
}
}
}
fun resetAcceptState() {
_acceptState.value = ApiResult.Idle
lastAcceptedTemplateId = null
}
companion object {
/**
* Non-onboarding analytics event for a single accepted suggestion.
*/
const val EVENT_TASK_SUGGESTION_ACCEPTED = "task_suggestion_accepted"
}
}

View File

@@ -0,0 +1,265 @@
package com.tt.honeyDue.ui.screens.task
import com.tt.honeyDue.models.TaskCategory
import com.tt.honeyDue.models.TaskCreateRequest
import com.tt.honeyDue.models.TaskResponse
import com.tt.honeyDue.models.TaskSuggestionResponse
import com.tt.honeyDue.models.TaskSuggestionsResponse
import com.tt.honeyDue.models.TaskTemplate
import com.tt.honeyDue.network.ApiResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Unit tests for TaskSuggestionsViewModel covering:
* 1. Initial state is Idle.
* 2. load() transitions to Success with suggestions.
* 3. Empty suggestions -> Success with empty list (empty state UI).
* 4. Accept fires createTask with exact template fields + templateId backlink.
* 5. Accept success fires non-onboarding analytics task_suggestion_accepted.
* 6. Load error surfaces ApiResult.Error + retry() reloads endpoint.
* 7. ViewModel is constructible with an explicit residenceId (standalone path).
* 8. Accept error surfaces error and fires no analytics.
* 9. resetAcceptState returns to Idle.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class TaskSuggestionsViewModelTest {
private val dispatcher = StandardTestDispatcher()
@BeforeTest
fun setUp() { Dispatchers.setMain(dispatcher) }
@AfterTest
fun tearDown() { Dispatchers.resetMain() }
private val plumbingCat = TaskCategory(id = 1, name = "Plumbing")
private val template1 = TaskTemplate(
id = 100,
title = "Change Water Filter",
description = "Replace every 6 months.",
categoryId = 1,
category = plumbingCat,
frequencyId = 7
)
private val template2 = TaskTemplate(
id = 200,
title = "Flush Water Heater",
description = "Annual flush.",
categoryId = 1,
category = plumbingCat,
frequencyId = 8
)
private val suggestionsResponse = TaskSuggestionsResponse(
suggestions = listOf(
TaskSuggestionResponse(
template = template1,
relevanceScore = 0.92,
matchReasons = listOf("Has water heater")
),
TaskSuggestionResponse(
template = template2,
relevanceScore = 0.75,
matchReasons = listOf("Annual maintenance")
)
),
totalCount = 2,
profileCompleteness = 0.8
)
private val emptyResponse = TaskSuggestionsResponse(
suggestions = emptyList(),
totalCount = 0,
profileCompleteness = 0.5
)
private fun fakeCreatedTask(templateId: Int?): TaskResponse = TaskResponse(
id = 777,
residenceId = 42,
createdById = 1,
title = "Created",
description = "",
categoryId = 1,
frequencyId = 7,
templateId = templateId,
createdAt = "2024-01-01T00:00:00Z",
updatedAt = "2024-01-01T00:00:00Z"
)
private fun makeViewModel(
loadResult: ApiResult<TaskSuggestionsResponse> = ApiResult.Success(suggestionsResponse),
createResult: ApiResult<TaskResponse> = ApiResult.Success(fakeCreatedTask(100)),
onCreateCall: (TaskCreateRequest) -> Unit = {},
onAnalytics: (String, Map<String, Any>) -> Unit = { _, _ -> },
residenceId: Int = 42
) = TaskSuggestionsViewModel(
residenceId = residenceId,
loadSuggestions = { loadResult },
createTask = { request ->
onCreateCall(request)
createResult
},
analytics = onAnalytics
)
@Test
fun initialStateIsIdle() {
val vm = makeViewModel()
assertIs<ApiResult.Idle>(vm.suggestionsState.value)
assertIs<ApiResult.Idle>(vm.acceptState.value)
}
@Test
fun loadTransitionsToSuccessWithSuggestions() = runTest(dispatcher) {
val vm = makeViewModel()
vm.load()
dispatcher.scheduler.advanceUntilIdle()
val state = vm.suggestionsState.value
assertIs<ApiResult.Success<TaskSuggestionsResponse>>(state)
assertEquals(2, state.data.totalCount)
assertEquals(100, state.data.suggestions.first().template.id)
}
@Test
fun emptySuggestionsResolvesToSuccessWithEmptyList() = runTest(dispatcher) {
val vm = makeViewModel(loadResult = ApiResult.Success(emptyResponse))
vm.load()
dispatcher.scheduler.advanceUntilIdle()
val state = vm.suggestionsState.value
assertIs<ApiResult.Success<TaskSuggestionsResponse>>(state)
assertTrue(state.data.suggestions.isEmpty())
assertEquals(0, state.data.totalCount)
}
@Test
fun acceptInvokesCreateTaskWithTemplateIdBacklinkAndFields() = runTest(dispatcher) {
var captured: TaskCreateRequest? = null
val vm = makeViewModel(onCreateCall = { captured = it })
vm.load()
dispatcher.scheduler.advanceUntilIdle()
vm.accept(suggestionsResponse.suggestions.first())
dispatcher.scheduler.advanceUntilIdle()
val req = captured ?: error("createTask was not called")
assertEquals(42, req.residenceId)
assertEquals("Change Water Filter", req.title)
assertEquals("Replace every 6 months.", req.description)
assertEquals(1, req.categoryId)
assertEquals(7, req.frequencyId)
assertEquals(100, req.templateId)
}
@Test
fun acceptSuccessFiresNonOnboardingAnalyticsEvent() = runTest(dispatcher) {
val events = mutableListOf<Pair<String, Map<String, Any>>>()
val vm = makeViewModel(onAnalytics = { name, props -> events += name to props })
vm.load()
dispatcher.scheduler.advanceUntilIdle()
vm.accept(suggestionsResponse.suggestions.first())
dispatcher.scheduler.advanceUntilIdle()
val accepted = events.firstOrNull {
it.first == TaskSuggestionsViewModel.EVENT_TASK_SUGGESTION_ACCEPTED
}
assertTrue(accepted != null, "expected task_suggestion_accepted event")
assertEquals(100, accepted.second["template_id"])
assertEquals(0.92, accepted.second["relevance_score"])
assertTrue(events.none { it.first == "onboarding_suggestion_accepted" })
val state = vm.acceptState.value
assertIs<ApiResult.Success<TaskResponse>>(state)
}
@Test
fun loadErrorSurfacesErrorAndRetryReloads() = runTest(dispatcher) {
var callCount = 0
var nextResult: ApiResult<TaskSuggestionsResponse> = ApiResult.Error("Network down", 500)
val vm = TaskSuggestionsViewModel(
residenceId = 42,
loadSuggestions = {
callCount++
nextResult
},
createTask = { ApiResult.Success(fakeCreatedTask(null)) },
analytics = { _, _ -> }
)
vm.load()
dispatcher.scheduler.advanceUntilIdle()
assertIs<ApiResult.Error>(vm.suggestionsState.value)
assertEquals(1, callCount)
nextResult = ApiResult.Success(suggestionsResponse)
vm.retry()
dispatcher.scheduler.advanceUntilIdle()
assertEquals(2, callCount)
assertIs<ApiResult.Success<TaskSuggestionsResponse>>(vm.suggestionsState.value)
}
@Test
fun viewModelUsesProvidedResidenceIdOnStandalonePath() = runTest(dispatcher) {
var captured: TaskCreateRequest? = null
val vm = makeViewModel(
residenceId = 999,
onCreateCall = { captured = it }
)
vm.load()
dispatcher.scheduler.advanceUntilIdle()
vm.accept(suggestionsResponse.suggestions.first())
dispatcher.scheduler.advanceUntilIdle()
assertEquals(999, captured?.residenceId)
}
@Test
fun acceptErrorSurfacesErrorAndFiresNoAnalytics() = runTest(dispatcher) {
val events = mutableListOf<Pair<String, Map<String, Any>>>()
val vm = makeViewModel(
createResult = ApiResult.Error("Server error", 500),
onAnalytics = { name, props -> events += name to props }
)
vm.load()
dispatcher.scheduler.advanceUntilIdle()
vm.accept(suggestionsResponse.suggestions.first())
dispatcher.scheduler.advanceUntilIdle()
val state = vm.acceptState.value
assertIs<ApiResult.Error>(state)
assertEquals("Server error", state.message)
assertTrue(events.isEmpty(), "analytics should not fire on accept error")
}
@Test
fun resetAcceptStateReturnsToIdle() = runTest(dispatcher) {
val vm = makeViewModel(createResult = ApiResult.Error("boom", 500))
vm.load()
dispatcher.scheduler.advanceUntilIdle()
vm.accept(suggestionsResponse.suggestions.first())
dispatcher.scheduler.advanceUntilIdle()
assertIs<ApiResult.Error>(vm.acceptState.value)
vm.resetAcceptState()
assertIs<ApiResult.Idle>(vm.acceptState.value)
assertNull(vm.lastAcceptedTemplateId)
}
}

View File

@@ -0,0 +1,59 @@
package com.tt.honeyDue.ui.haptics
import platform.UIKit.UIImpactFeedbackGenerator
import platform.UIKit.UIImpactFeedbackStyle
import platform.UIKit.UINotificationFeedbackGenerator
import platform.UIKit.UINotificationFeedbackType
/**
* iOS backend using [UIImpactFeedbackGenerator] and [UINotificationFeedbackGenerator]
* from UIKit. Generators are recreated per event since they are lightweight and
* iOS recommends preparing them lazily.
*
* Note: The primary iOS app uses SwiftUI haptics directly; this backend exists
* so shared Compose code that invokes [Haptics] still produces the correct
* tactile feedback when the Compose layer is exercised on iOS.
*/
class IosDefaultHapticBackend : HapticBackend {
override fun perform(event: HapticEvent) {
when (event) {
HapticEvent.LIGHT -> impact(UIImpactFeedbackStyle.UIImpactFeedbackStyleLight)
HapticEvent.MEDIUM -> impact(UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium)
HapticEvent.HEAVY -> impact(UIImpactFeedbackStyle.UIImpactFeedbackStyleHeavy)
HapticEvent.SUCCESS -> notify(UINotificationFeedbackType.UINotificationFeedbackTypeSuccess)
HapticEvent.WARNING -> notify(UINotificationFeedbackType.UINotificationFeedbackTypeWarning)
HapticEvent.ERROR -> notify(UINotificationFeedbackType.UINotificationFeedbackTypeError)
}
}
private fun impact(style: UIImpactFeedbackStyle) {
val generator = UIImpactFeedbackGenerator(style = style)
generator.prepare()
generator.impactOccurred()
}
private fun notify(type: UINotificationFeedbackType) {
val generator = UINotificationFeedbackGenerator()
generator.prepare()
generator.notificationOccurred(type)
}
}
actual object Haptics {
private var backend: HapticBackend = IosDefaultHapticBackend()
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 = IosDefaultHapticBackend()
}
}

View File

@@ -0,0 +1,21 @@
package com.tt.honeyDue.ui.haptics
/** Web (JS): no haptics API. Backend is a no-op. */
actual object Haptics {
private var backend: HapticBackend = NoopHapticBackend
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 = NoopHapticBackend
}
}

View File

@@ -0,0 +1,21 @@
package com.tt.honeyDue.ui.haptics
/** Desktop JVM: no haptics hardware. Backend is a no-op. */
actual object Haptics {
@Volatile private var backend: HapticBackend = NoopHapticBackend
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 = NoopHapticBackend
}
}

View File

@@ -0,0 +1,21 @@
package com.tt.honeyDue.ui.haptics
/** Web (WASM): no haptics API. Backend is a no-op. */
actual object Haptics {
private var backend: HapticBackend = NoopHapticBackend
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 = NoopHapticBackend
}
}