diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index b93bd23..744fb4f 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -90,7 +90,7 @@
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="@string/default_notification_channel_id" />
-
+
@@ -103,6 +103,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
= 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(
diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationActionReceiver.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationActionReceiver.kt
new file mode 100644
index 0000000..716b7f4
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationActionReceiver.kt
@@ -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
+ ): 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()
+}
diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationActions.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationActions.kt
new file mode 100644
index 0000000..a7c083f
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationActions.kt
@@ -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
+}
diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/SnoozeScheduler.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/SnoozeScheduler.kt
new file mode 100644
index 0000000..e8b366e
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/SnoozeScheduler.kt
@@ -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"
+}
diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/ui/haptics/Haptics.android.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/ui/haptics/Haptics.android.kt
new file mode 100644
index 0000000..d2939c3
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/ui/haptics/Haptics.android.kt
@@ -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 26–28,
+ * 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()
+ }
+}
diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml
index 62c7b9c..979aab7 100644
--- a/composeApp/src/androidMain/res/values/strings.xml
+++ b/composeApp/src/androidMain/res/values/strings.xml
@@ -11,4 +11,11 @@
honeyDue Dashboard
Full task dashboard with stats and interactive actions (Pro feature)
+
+
+ Complete
+ Snooze
+ Open
+ Accept
+ Decline
\ No newline at end of file
diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/NotificationActionReceiverTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/NotificationActionReceiverTest.kt
new file mode 100644
index 0000000..4f35e60
--- /dev/null
+++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/NotificationActionReceiverTest.kt
@@ -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 {
+ 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()
+ }
+}
diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/SnoozeSchedulerTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/SnoozeSchedulerTest.kt
new file mode 100644
index 0000000..d33bf45
--- /dev/null
+++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/SnoozeSchedulerTest.kt
@@ -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)
+ }
+}
diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/ui/haptics/HapticsAndroidTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/ui/haptics/HapticsAndroidTest.kt
new file mode 100644
index 0000000..1db1330
--- /dev/null
+++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/ui/haptics/HapticsAndroidTest.kt
@@ -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()
+ override fun perform(event: HapticEvent) {
+ events += event
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/navigation/Routes.kt
index 0c043cd..ca4f461 100644
--- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/navigation/Routes.kt
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/navigation/Routes.kt
@@ -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)
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/haptics/Haptics.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/haptics/Haptics.kt
new file mode 100644
index 0000000..97f8d38
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/haptics/Haptics.kt
@@ -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()
+}
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskSuggestionsScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskSuggestionsScreen.kt
new file mode 100644
index 0000000..6e65a10
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskSuggestionsScreen.kt
@@ -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,
+ 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
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskSuggestionsViewModel.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskSuggestionsViewModel.kt
new file mode 100644
index 0000000..7c1af42
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskSuggestionsViewModel.kt
@@ -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 = {
+ APILayer.getTaskSuggestions(residenceId)
+ },
+ private val createTask: suspend (TaskCreateRequest) -> ApiResult = { req ->
+ APILayer.createTask(req)
+ },
+ private val analytics: (String, Map) -> Unit = { name, props ->
+ PostHogAnalytics.capture(name, props)
+ }
+) : ViewModel() {
+
+ private val _suggestionsState =
+ MutableStateFlow>(ApiResult.Idle)
+ val suggestionsState: StateFlow> =
+ _suggestionsState.asStateFlow()
+
+ private val _acceptState = MutableStateFlow>(ApiResult.Idle)
+ val acceptState: StateFlow> = _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"
+ }
+}
diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/task/TaskSuggestionsViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/task/TaskSuggestionsViewModelTest.kt
new file mode 100644
index 0000000..988cf84
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/task/TaskSuggestionsViewModelTest.kt
@@ -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 = ApiResult.Success(suggestionsResponse),
+ createResult: ApiResult = ApiResult.Success(fakeCreatedTask(100)),
+ onCreateCall: (TaskCreateRequest) -> Unit = {},
+ onAnalytics: (String, Map) -> Unit = { _, _ -> },
+ residenceId: Int = 42
+ ) = TaskSuggestionsViewModel(
+ residenceId = residenceId,
+ loadSuggestions = { loadResult },
+ createTask = { request ->
+ onCreateCall(request)
+ createResult
+ },
+ analytics = onAnalytics
+ )
+
+ @Test
+ fun initialStateIsIdle() {
+ val vm = makeViewModel()
+ assertIs(vm.suggestionsState.value)
+ assertIs(vm.acceptState.value)
+ }
+
+ @Test
+ fun loadTransitionsToSuccessWithSuggestions() = runTest(dispatcher) {
+ val vm = makeViewModel()
+ vm.load()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ val state = vm.suggestionsState.value
+ assertIs>(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>(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>>()
+ 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>(state)
+ }
+
+ @Test
+ fun loadErrorSurfacesErrorAndRetryReloads() = runTest(dispatcher) {
+ var callCount = 0
+ var nextResult: ApiResult = 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(vm.suggestionsState.value)
+ assertEquals(1, callCount)
+
+ nextResult = ApiResult.Success(suggestionsResponse)
+ vm.retry()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ assertEquals(2, callCount)
+ assertIs>(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>>()
+ 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(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(vm.acceptState.value)
+
+ vm.resetAcceptState()
+ assertIs(vm.acceptState.value)
+ assertNull(vm.lastAcceptedTemplateId)
+ }
+}
diff --git a/composeApp/src/iosMain/kotlin/com/tt/honeyDue/ui/haptics/Haptics.ios.kt b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/ui/haptics/Haptics.ios.kt
new file mode 100644
index 0000000..b5aa2c7
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/ui/haptics/Haptics.ios.kt
@@ -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()
+ }
+}
diff --git a/composeApp/src/jsMain/kotlin/com/tt/honeyDue/ui/haptics/Haptics.js.kt b/composeApp/src/jsMain/kotlin/com/tt/honeyDue/ui/haptics/Haptics.js.kt
new file mode 100644
index 0000000..db201c0
--- /dev/null
+++ b/composeApp/src/jsMain/kotlin/com/tt/honeyDue/ui/haptics/Haptics.js.kt
@@ -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
+ }
+}
diff --git a/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/ui/haptics/Haptics.jvm.kt b/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/ui/haptics/Haptics.jvm.kt
new file mode 100644
index 0000000..11cc6f3
--- /dev/null
+++ b/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/ui/haptics/Haptics.jvm.kt
@@ -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
+ }
+}
diff --git a/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/ui/haptics/Haptics.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/ui/haptics/Haptics.wasmJs.kt
new file mode 100644
index 0000000..7ee81b6
--- /dev/null
+++ b/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/ui/haptics/Haptics.wasmJs.kt
@@ -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
+ }
+}