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 + } +}