P2 Stream H: standalone TaskSuggestionsScreen
Port iOS TaskSuggestionsView as a standalone route reachable outside onboarding. Uses shared suggestions API + accept/skip analytics in non-onboarding variant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -90,7 +90,7 @@
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="@string/default_notification_channel_id" />
|
||||
|
||||
<!-- Notification Action Receiver -->
|
||||
<!-- Legacy Notification Action Receiver (widget-era task state actions) -->
|
||||
<receiver
|
||||
android:name=".NotificationActionReceiver"
|
||||
android:exported="false">
|
||||
@@ -103,6 +103,24 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- iOS-parity push action receiver (P4 Stream O).
|
||||
Handles Complete/Snooze/Open for task_reminder & task_overdue, and
|
||||
Accept/Decline/Open for residence_invite. Wired to notifications
|
||||
built by FcmService.onMessageReceived. Also receives the delayed
|
||||
SNOOZE_FIRE alarm from SnoozeScheduler. -->
|
||||
<receiver
|
||||
android:name=".notifications.NotificationActionReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.tt.honeyDue.action.COMPLETE_TASK" />
|
||||
<action android:name="com.tt.honeyDue.action.SNOOZE_TASK" />
|
||||
<action android:name="com.tt.honeyDue.action.OPEN" />
|
||||
<action android:name="com.tt.honeyDue.action.ACCEPT_INVITE" />
|
||||
<action android:name="com.tt.honeyDue.action.DECLINE_INVITE" />
|
||||
<action android:name="com.tt.honeyDue.action.SNOOZE_FIRE" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Widget Task Complete Receiver -->
|
||||
<receiver
|
||||
android:name=".widget.WidgetTaskActionReceiver"
|
||||
|
||||
@@ -69,8 +69,9 @@ class FcmService : FirebaseMessagingService() {
|
||||
messageId: String?
|
||||
): android.app.Notification {
|
||||
val contentIntent = buildContentIntent(payload, messageId)
|
||||
val notificationId = (messageId ?: payload.deepLink ?: payload.title).hashCode()
|
||||
|
||||
return NotificationCompat.Builder(this, channelId)
|
||||
val builder = NotificationCompat.Builder(this, channelId)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(payload.title.ifBlank { getString(R.string.app_name) })
|
||||
.setContentText(payload.body)
|
||||
@@ -78,7 +79,96 @@ class FcmService : FirebaseMessagingService() {
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(contentIntent)
|
||||
.setPriority(priorityForChannel(channelId))
|
||||
.build()
|
||||
|
||||
addActionButtons(builder, payload, notificationId, messageId)
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach iOS-parity action buttons (`NotificationCategories.swift`) to
|
||||
* the [builder] based on [payload.type]:
|
||||
*
|
||||
* - task_reminder / task_overdue → Complete, Snooze, Open
|
||||
* - residence_invite → Accept, Decline, Open
|
||||
* - subscription → no actions (matches iOS TASK_COMPLETED)
|
||||
*
|
||||
* All actions fan out to [NotificationActionReceiver] under the
|
||||
* `com.tt.honeyDue.notifications` package.
|
||||
*/
|
||||
private fun addActionButtons(
|
||||
builder: NotificationCompat.Builder,
|
||||
payload: NotificationPayload,
|
||||
notificationId: Int,
|
||||
messageId: String?
|
||||
) {
|
||||
val seed = (messageId ?: payload.deepLink ?: payload.title).hashCode()
|
||||
val extras: Map<String, Any?> = mapOf(
|
||||
NotificationActions.EXTRA_TASK_ID to payload.taskId,
|
||||
NotificationActions.EXTRA_RESIDENCE_ID to payload.residenceId,
|
||||
NotificationActions.EXTRA_NOTIFICATION_ID to notificationId,
|
||||
NotificationActions.EXTRA_TITLE to payload.title,
|
||||
NotificationActions.EXTRA_BODY to payload.body,
|
||||
NotificationActions.EXTRA_TYPE to payload.type,
|
||||
NotificationActions.EXTRA_DEEP_LINK to payload.deepLink
|
||||
)
|
||||
|
||||
when (payload.type) {
|
||||
NotificationChannels.TASK_REMINDER,
|
||||
NotificationChannels.TASK_OVERDUE -> {
|
||||
if (payload.taskId != null) {
|
||||
builder.addAction(
|
||||
0,
|
||||
getString(R.string.notif_action_complete),
|
||||
NotificationActionReceiver.actionPendingIntent(
|
||||
this, NotificationActions.COMPLETE, seed, extras
|
||||
)
|
||||
)
|
||||
builder.addAction(
|
||||
0,
|
||||
getString(R.string.notif_action_snooze),
|
||||
NotificationActionReceiver.actionPendingIntent(
|
||||
this, NotificationActions.SNOOZE, seed, extras
|
||||
)
|
||||
)
|
||||
}
|
||||
builder.addAction(
|
||||
0,
|
||||
getString(R.string.notif_action_open),
|
||||
NotificationActionReceiver.actionPendingIntent(
|
||||
this, NotificationActions.OPEN, seed, extras
|
||||
)
|
||||
)
|
||||
}
|
||||
NotificationChannels.RESIDENCE_INVITE -> {
|
||||
if (payload.residenceId != null) {
|
||||
builder.addAction(
|
||||
0,
|
||||
getString(R.string.notif_action_accept),
|
||||
NotificationActionReceiver.actionPendingIntent(
|
||||
this, NotificationActions.ACCEPT_INVITE, seed, extras
|
||||
)
|
||||
)
|
||||
builder.addAction(
|
||||
0,
|
||||
getString(R.string.notif_action_decline),
|
||||
NotificationActionReceiver.actionPendingIntent(
|
||||
this, NotificationActions.DECLINE_INVITE, seed, extras
|
||||
)
|
||||
)
|
||||
}
|
||||
builder.addAction(
|
||||
0,
|
||||
getString(R.string.notif_action_open),
|
||||
NotificationActionReceiver.actionPendingIntent(
|
||||
this, NotificationActions.OPEN, seed, extras
|
||||
)
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
// subscription + unknown: tap-to-open only. iOS parity.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildContentIntent(
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
package com.tt.honeyDue.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.tt.honeyDue.MainActivity
|
||||
import com.tt.honeyDue.R
|
||||
import com.tt.honeyDue.models.TaskCompletionCreateRequest
|
||||
import com.tt.honeyDue.network.APILayer
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.widget.WidgetUpdateManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* BroadcastReceiver for the iOS-parity notification action buttons introduced
|
||||
* in P4 Stream O. Handles Complete / Snooze / Open for task_reminder +
|
||||
* task_overdue categories, and Accept / Decline / Open for residence_invite.
|
||||
*
|
||||
* Counterpart: `iosApp/iosApp/PushNotifications/PushNotificationManager.swift`
|
||||
* (handleNotificationAction). Categories defined in
|
||||
* `iosApp/iosApp/PushNotifications/NotificationCategories.swift`.
|
||||
*
|
||||
* There is a pre-existing [com.tt.honeyDue.NotificationActionReceiver] under
|
||||
* the root package that handles widget-era task-state transitions (mark in
|
||||
* progress, cancel, etc.). That receiver is untouched by this stream; this
|
||||
* one lives under `com.tt.honeyDue.notifications` and serves only the push
|
||||
* action buttons attached by [FcmService].
|
||||
*/
|
||||
class NotificationActionReceiver : BroadcastReceiver() {
|
||||
|
||||
/**
|
||||
* Hook for tests. Overridden to intercept async work and force it onto a
|
||||
* synchronous dispatcher. Production stays on [Dispatchers.IO].
|
||||
*/
|
||||
internal var coroutineScopeOverride: CoroutineScope? = null
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val scope = coroutineScopeOverride
|
||||
if (scope != null) {
|
||||
// Test path: run synchronously on the provided scope so Robolectric
|
||||
// assertions can observe post-conditions without goAsync hangs.
|
||||
scope.launch { handleAction(context.applicationContext, intent) }
|
||||
return
|
||||
}
|
||||
|
||||
val pending = goAsync()
|
||||
defaultScope.launch {
|
||||
try {
|
||||
handleAction(context.applicationContext, intent)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Action handler crashed", t)
|
||||
} finally {
|
||||
pending.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun handleAction(context: Context, intent: Intent) {
|
||||
val action = intent.action ?: run {
|
||||
Log.w(TAG, "onReceive with null action")
|
||||
return
|
||||
}
|
||||
|
||||
val taskId = intent.longTaskId()
|
||||
val residenceId = intent.longResidenceId()
|
||||
val notificationId = intent.getIntExtra(NotificationActions.EXTRA_NOTIFICATION_ID, 0)
|
||||
val title = intent.getStringExtra(NotificationActions.EXTRA_TITLE)
|
||||
val body = intent.getStringExtra(NotificationActions.EXTRA_BODY)
|
||||
val type = intent.getStringExtra(NotificationActions.EXTRA_TYPE)
|
||||
val deepLink = intent.getStringExtra(NotificationActions.EXTRA_DEEP_LINK)
|
||||
|
||||
Log.d(TAG, "action=$action taskId=$taskId residenceId=$residenceId")
|
||||
|
||||
when (action) {
|
||||
NotificationActions.COMPLETE -> handleComplete(context, taskId, notificationId)
|
||||
NotificationActions.SNOOZE -> handleSnooze(context, taskId, title, body, type, notificationId)
|
||||
NotificationActions.OPEN -> handleOpen(context, taskId, residenceId, deepLink, notificationId)
|
||||
NotificationActions.ACCEPT_INVITE -> handleAcceptInvite(context, residenceId, notificationId)
|
||||
NotificationActions.DECLINE_INVITE -> handleDeclineInvite(context, residenceId, notificationId)
|
||||
NotificationActions.SNOOZE_FIRE -> handleSnoozeFire(context, taskId, title, body, type)
|
||||
else -> Log.w(TAG, "Unknown action: $action")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Complete ----------------
|
||||
|
||||
private suspend fun handleComplete(context: Context, taskId: Long?, notificationId: Int) {
|
||||
if (taskId == null) {
|
||||
Log.w(TAG, "COMPLETE without task_id — no-op")
|
||||
return
|
||||
}
|
||||
val request = TaskCompletionCreateRequest(
|
||||
taskId = taskId.toInt(),
|
||||
completedAt = null,
|
||||
notes = "Completed from notification",
|
||||
actualCost = null,
|
||||
rating = null,
|
||||
imageUrls = null
|
||||
)
|
||||
when (val result = APILayer.createTaskCompletion(request)) {
|
||||
is ApiResult.Success -> {
|
||||
Log.d(TAG, "Task $taskId completed from notification")
|
||||
cancelNotification(context, notificationId)
|
||||
WidgetUpdateManager.forceRefresh(context)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
// Leave the notification so the user can retry.
|
||||
Log.e(TAG, "Complete failed: ${result.message}")
|
||||
}
|
||||
else -> Log.w(TAG, "Unexpected ApiResult from createTaskCompletion")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Snooze ----------------
|
||||
|
||||
private fun handleSnooze(
|
||||
context: Context,
|
||||
taskId: Long?,
|
||||
title: String?,
|
||||
body: String?,
|
||||
type: String?,
|
||||
notificationId: Int
|
||||
) {
|
||||
if (taskId == null) {
|
||||
Log.w(TAG, "SNOOZE without task_id — no-op")
|
||||
return
|
||||
}
|
||||
SnoozeScheduler.schedule(
|
||||
context = context,
|
||||
taskId = taskId,
|
||||
delayMs = NotificationActions.SNOOZE_DELAY_MS,
|
||||
title = title,
|
||||
body = body,
|
||||
type = type
|
||||
)
|
||||
cancelNotification(context, notificationId)
|
||||
}
|
||||
|
||||
/** Fired by [AlarmManager] when a snooze elapses — rebuild + post the notification. */
|
||||
private fun handleSnoozeFire(
|
||||
context: Context,
|
||||
taskId: Long?,
|
||||
title: String?,
|
||||
body: String?,
|
||||
type: String?
|
||||
) {
|
||||
if (taskId == null) {
|
||||
Log.w(TAG, "SNOOZE_FIRE without task_id — no-op")
|
||||
return
|
||||
}
|
||||
NotificationChannels.ensureChannels(context)
|
||||
val channelId = NotificationChannels.channelIdForType(type ?: NotificationChannels.TASK_REMINDER)
|
||||
|
||||
val contentIntent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
putExtra(FcmService.EXTRA_TASK_ID, taskId)
|
||||
putExtra(FcmService.EXTRA_TYPE, type)
|
||||
}
|
||||
val pi = PendingIntent.getActivity(
|
||||
context,
|
||||
taskId.toInt(),
|
||||
contentIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title ?: context.getString(R.string.app_name))
|
||||
.setContentText(body ?: "")
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body ?: ""))
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pi)
|
||||
.build()
|
||||
|
||||
NotificationManagerCompat.from(context).apply {
|
||||
if (areNotificationsEnabled()) {
|
||||
notify(taskId.hashCode(), notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Open ----------------
|
||||
|
||||
private fun handleOpen(
|
||||
context: Context,
|
||||
taskId: Long?,
|
||||
residenceId: Long?,
|
||||
deepLink: String?,
|
||||
notificationId: Int
|
||||
) {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
deepLink?.takeIf { it.isNotBlank() }?.let { data = Uri.parse(it) }
|
||||
taskId?.let { putExtra(FcmService.EXTRA_TASK_ID, it) }
|
||||
residenceId?.let { putExtra(FcmService.EXTRA_RESIDENCE_ID, it) }
|
||||
}
|
||||
context.startActivity(intent)
|
||||
cancelNotification(context, notificationId)
|
||||
}
|
||||
|
||||
// ---------------- Accept / Decline invite ----------------
|
||||
|
||||
private fun handleAcceptInvite(context: Context, residenceId: Long?, notificationId: Int) {
|
||||
if (residenceId == null) {
|
||||
Log.w(TAG, "ACCEPT_INVITE without residence_id — no-op")
|
||||
return
|
||||
}
|
||||
// APILayer.acceptResidenceInvite does not yet exist — see TODO below.
|
||||
// composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
|
||||
// (add after createTaskCompletion at ~line 790). Backend endpoint
|
||||
// intended at POST /api/residences/{id}/invite/accept.
|
||||
Log.w(
|
||||
TAG,
|
||||
"TODO: APILayer.acceptResidenceInvite($residenceId) — implement in " +
|
||||
"composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt " +
|
||||
"(follow createTaskCompletion pattern; POST /api/residences/{id}/invite/accept)"
|
||||
)
|
||||
// Best-effort UX: cancel the notification so the user isn't stuck
|
||||
// on a button that does nothing visible. The invite will be picked
|
||||
// up on next app-open from /api/residences/pending.
|
||||
cancelNotification(context, notificationId)
|
||||
}
|
||||
|
||||
private fun handleDeclineInvite(context: Context, residenceId: Long?, notificationId: Int) {
|
||||
if (residenceId == null) {
|
||||
Log.w(TAG, "DECLINE_INVITE without residence_id — no-op")
|
||||
return
|
||||
}
|
||||
Log.w(
|
||||
TAG,
|
||||
"TODO: APILayer.declineResidenceInvite($residenceId) — implement in " +
|
||||
"composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt " +
|
||||
"(follow createTaskCompletion pattern; POST /api/residences/{id}/invite/decline)"
|
||||
)
|
||||
cancelNotification(context, notificationId)
|
||||
}
|
||||
|
||||
// ---------------- helpers ----------------
|
||||
|
||||
private fun cancelNotification(context: Context, notificationId: Int) {
|
||||
if (notificationId == 0) return
|
||||
val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||
if (mgr != null) {
|
||||
mgr.cancel(notificationId)
|
||||
} else {
|
||||
NotificationManagerCompat.from(context).cancel(notificationId)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NotificationAction"
|
||||
|
||||
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/**
|
||||
* Build a PendingIntent pointing at this receiver for the given action.
|
||||
* Used by [FcmService] to attach action buttons.
|
||||
*/
|
||||
fun actionPendingIntent(
|
||||
context: Context,
|
||||
action: String,
|
||||
requestCodeSeed: Int,
|
||||
extras: Map<String, Any?>
|
||||
): PendingIntent {
|
||||
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
|
||||
this.action = action
|
||||
extras.forEach { (k, v) ->
|
||||
when (v) {
|
||||
is Int -> putExtra(k, v)
|
||||
is Long -> putExtra(k, v)
|
||||
is String -> putExtra(k, v)
|
||||
null -> { /* skip */ }
|
||||
else -> putExtra(k, v.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
} else {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
(action.hashCode() xor requestCodeSeed),
|
||||
intent,
|
||||
flags
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Intent.longTaskId(): Long? {
|
||||
if (!hasExtra(NotificationActions.EXTRA_TASK_ID)) return null
|
||||
val asLong = getLongExtra(NotificationActions.EXTRA_TASK_ID, Long.MIN_VALUE)
|
||||
if (asLong != Long.MIN_VALUE) return asLong
|
||||
val asInt = getIntExtra(NotificationActions.EXTRA_TASK_ID, Int.MIN_VALUE)
|
||||
return if (asInt == Int.MIN_VALUE) null else asInt.toLong()
|
||||
}
|
||||
|
||||
private fun Intent.longResidenceId(): Long? {
|
||||
if (!hasExtra(NotificationActions.EXTRA_RESIDENCE_ID)) return null
|
||||
val asLong = getLongExtra(NotificationActions.EXTRA_RESIDENCE_ID, Long.MIN_VALUE)
|
||||
if (asLong != Long.MIN_VALUE) return asLong
|
||||
val asInt = getIntExtra(NotificationActions.EXTRA_RESIDENCE_ID, Int.MIN_VALUE)
|
||||
return if (asInt == Int.MIN_VALUE) null else asInt.toLong()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,11 @@
|
||||
|
||||
<string name="widget_large_name">honeyDue Dashboard</string>
|
||||
<string name="widget_large_description">Full task dashboard with stats and interactive actions (Pro feature)</string>
|
||||
|
||||
<!-- Notification action buttons (P4 Stream O — iOS parity) -->
|
||||
<string name="notif_action_complete">Complete</string>
|
||||
<string name="notif_action_snooze">Snooze</string>
|
||||
<string name="notif_action_open">Open</string>
|
||||
<string name="notif_action_accept">Accept</string>
|
||||
<string name="notif_action_decline">Decline</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,363 @@
|
||||
package com.tt.honeyDue.notifications
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.Application
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.tt.honeyDue.MainActivity
|
||||
import com.tt.honeyDue.models.TaskCompletionCreateRequest
|
||||
import com.tt.honeyDue.models.TaskCompletionResponse
|
||||
import com.tt.honeyDue.network.APILayer
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.widget.WidgetUpdateManager
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.runs
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Unit tests for the iOS-parity [NotificationActionReceiver] (P4 Stream O).
|
||||
*
|
||||
* Covers the action dispatch table: Complete, Snooze, Open, Accept, Decline,
|
||||
* plus defensive handling of missing extras.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
|
||||
class NotificationActionReceiverTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var app: Application
|
||||
private lateinit var notificationManager: NotificationManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
app = context.applicationContext as Application
|
||||
notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.cancelAll()
|
||||
mockkObject(APILayer)
|
||||
mockkObject(WidgetUpdateManager)
|
||||
every { WidgetUpdateManager.forceRefresh(any()) } just runs
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
notificationManager.cancelAll()
|
||||
}
|
||||
|
||||
// Build a receiver whose async work runs synchronously on the test scheduler.
|
||||
private fun receiverFor(scope: CoroutineScope): NotificationActionReceiver =
|
||||
NotificationActionReceiver().apply { coroutineScopeOverride = scope }
|
||||
|
||||
private fun successCompletion(taskId: Int) = TaskCompletionResponse(
|
||||
id = 1,
|
||||
taskId = taskId,
|
||||
completedBy = null,
|
||||
completedAt = "2026-04-16T00:00:00Z",
|
||||
notes = "Completed from notification",
|
||||
actualCost = null,
|
||||
rating = null,
|
||||
images = emptyList(),
|
||||
createdAt = "2026-04-16T00:00:00Z",
|
||||
updatedTask = null
|
||||
)
|
||||
|
||||
private fun postDummyNotification(id: Int) {
|
||||
// Create channels so the notify() call below actually posts on O+.
|
||||
NotificationChannels.ensureChannels(context)
|
||||
val n = androidx.core.app.NotificationCompat.Builder(context, NotificationChannels.TASK_REMINDER)
|
||||
.setSmallIcon(com.tt.honeyDue.R.mipmap.ic_launcher)
|
||||
.setContentTitle("t")
|
||||
.setContentText("b")
|
||||
.build()
|
||||
notificationManager.notify(id, n)
|
||||
assertTrue(
|
||||
"precondition: dummy notification should be posted",
|
||||
notificationManager.activeNotifications.any { it.id == id }
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- 1. COMPLETE dispatches to APILayer + cancels notification ----------
|
||||
|
||||
@Test
|
||||
fun complete_callsCreateTaskCompletion_and_cancelsNotification() = runTest {
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
|
||||
coEvery { APILayer.createTaskCompletion(any()) } returns ApiResult.Success(successCompletion(42))
|
||||
|
||||
val notifId = 9001
|
||||
postDummyNotification(notifId)
|
||||
|
||||
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
|
||||
action = NotificationActions.COMPLETE
|
||||
putExtra(NotificationActions.EXTRA_TASK_ID, 42L)
|
||||
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
|
||||
}
|
||||
receiverFor(scope).onReceive(context, intent)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
APILayer.createTaskCompletion(match<TaskCompletionCreateRequest> {
|
||||
it.taskId == 42 && it.notes == "Completed from notification"
|
||||
})
|
||||
}
|
||||
verify(exactly = 1) { WidgetUpdateManager.forceRefresh(any()) }
|
||||
assertFalse(
|
||||
"notification should be canceled on success",
|
||||
notificationManager.activeNotifications.any { it.id == notifId }
|
||||
)
|
||||
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
// ---------- 2. COMPLETE failure: notification survives for retry ----------
|
||||
|
||||
@Test
|
||||
fun complete_apiFailure_keepsNotification_forRetry() = runTest {
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
|
||||
coEvery { APILayer.createTaskCompletion(any()) } returns ApiResult.Error("nope", 500)
|
||||
|
||||
val notifId = 9002
|
||||
postDummyNotification(notifId)
|
||||
|
||||
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
|
||||
action = NotificationActions.COMPLETE
|
||||
putExtra(NotificationActions.EXTRA_TASK_ID, 7L)
|
||||
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
|
||||
}
|
||||
receiverFor(scope).onReceive(context, intent)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { APILayer.createTaskCompletion(any()) }
|
||||
verify(exactly = 0) { WidgetUpdateManager.forceRefresh(any()) }
|
||||
assertTrue(
|
||||
"notification should remain posted so the user can retry",
|
||||
notificationManager.activeNotifications.any { it.id == notifId }
|
||||
)
|
||||
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
// ---------- 3. SNOOZE: schedules AlarmManager +30 min ----------
|
||||
|
||||
@Test
|
||||
fun snooze_schedulesAlarm_thirtyMinutesOut() = runTest {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
|
||||
|
||||
val notifId = 9003
|
||||
postDummyNotification(notifId)
|
||||
|
||||
val beforeMs = System.currentTimeMillis()
|
||||
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
|
||||
action = NotificationActions.SNOOZE
|
||||
putExtra(NotificationActions.EXTRA_TASK_ID, 55L)
|
||||
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
|
||||
putExtra(NotificationActions.EXTRA_TITLE, "Title")
|
||||
putExtra(NotificationActions.EXTRA_BODY, "Body")
|
||||
putExtra(NotificationActions.EXTRA_TYPE, NotificationChannels.TASK_REMINDER)
|
||||
}
|
||||
receiverFor(scope).onReceive(context, intent)
|
||||
|
||||
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val scheduled = shadowOf(am).scheduledAlarms
|
||||
assertEquals("exactly one snooze alarm scheduled", 1, scheduled.size)
|
||||
|
||||
val alarm = scheduled.first()
|
||||
val delta = alarm.triggerAtTime - beforeMs
|
||||
val expected = NotificationActions.SNOOZE_DELAY_MS
|
||||
// Allow ±2s jitter around the expected 30 minutes.
|
||||
assertTrue(
|
||||
"snooze alarm should fire ~30 min out (delta=$delta)",
|
||||
delta in (expected - 2_000)..(expected + 2_000)
|
||||
)
|
||||
assertFalse(
|
||||
"original notification should be cleared after snooze",
|
||||
notificationManager.activeNotifications.any { it.id == notifId }
|
||||
)
|
||||
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
// ---------- 4. OPEN: launches MainActivity with deep-link ----------
|
||||
|
||||
@Test
|
||||
fun open_launchesMainActivity_withDeepLinkAndExtras() = runTest {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
|
||||
|
||||
val notifId = 9004
|
||||
postDummyNotification(notifId)
|
||||
|
||||
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
|
||||
action = NotificationActions.OPEN
|
||||
putExtra(NotificationActions.EXTRA_TASK_ID, 77L)
|
||||
putExtra(NotificationActions.EXTRA_RESIDENCE_ID, 3L)
|
||||
putExtra(NotificationActions.EXTRA_DEEP_LINK, "honeydue://task/77")
|
||||
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
|
||||
}
|
||||
receiverFor(scope).onReceive(context, intent)
|
||||
|
||||
val started = shadowOf(app).nextStartedActivity
|
||||
assertNotNull("MainActivity should be launched", started)
|
||||
assertEquals(MainActivity::class.java.name, started.component?.className)
|
||||
assertEquals("honeydue", started.data?.scheme)
|
||||
assertEquals("77", started.data?.pathSegments?.last())
|
||||
assertEquals(77L, started.getLongExtra(FcmService.EXTRA_TASK_ID, -1))
|
||||
assertEquals(3L, started.getLongExtra(FcmService.EXTRA_RESIDENCE_ID, -1))
|
||||
assertFalse(
|
||||
"notification should be canceled after open",
|
||||
notificationManager.activeNotifications.any { it.id == notifId }
|
||||
)
|
||||
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
// ---------- 5. ACCEPT_INVITE: clears notification (TODO: API call) ----------
|
||||
|
||||
@Test
|
||||
fun acceptInvite_withResidenceId_cancelsNotification() = runTest {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
|
||||
|
||||
val notifId = 9005
|
||||
postDummyNotification(notifId)
|
||||
|
||||
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
|
||||
action = NotificationActions.ACCEPT_INVITE
|
||||
putExtra(NotificationActions.EXTRA_RESIDENCE_ID, 101L)
|
||||
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
|
||||
}
|
||||
receiverFor(scope).onReceive(context, intent)
|
||||
|
||||
// API method does not yet exist — see TODO in receiver. Expectation is
|
||||
// that the notification is still cleared (best-effort UX) and we did
|
||||
// NOT crash. APILayer.createTaskCompletion should NOT have been called.
|
||||
coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) }
|
||||
assertFalse(
|
||||
"invite notification should be cleared on accept",
|
||||
notificationManager.activeNotifications.any { it.id == notifId }
|
||||
)
|
||||
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
// ---------- 6. Missing extras: no crash, no-op ----------
|
||||
|
||||
@Test
|
||||
fun complete_withoutTaskId_isNoOp() = runTest {
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
|
||||
val notifId = 9006
|
||||
postDummyNotification(notifId)
|
||||
|
||||
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
|
||||
action = NotificationActions.COMPLETE
|
||||
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
|
||||
// no task_id
|
||||
}
|
||||
receiverFor(scope).onReceive(context, intent)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) }
|
||||
assertTrue(
|
||||
"notification must survive a malformed COMPLETE",
|
||||
notificationManager.activeNotifications.any { it.id == notifId }
|
||||
)
|
||||
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
// ---------- 7. Unknown action: no-op ----------
|
||||
|
||||
@Test
|
||||
fun unknownAction_isNoOp() = runTest {
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
|
||||
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
|
||||
action = "com.tt.honeyDue.action.NONSENSE"
|
||||
putExtra(NotificationActions.EXTRA_TASK_ID, 1L)
|
||||
}
|
||||
receiverFor(scope).onReceive(context, intent)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) }
|
||||
verify(exactly = 0) { WidgetUpdateManager.forceRefresh(any()) }
|
||||
// No started activity either.
|
||||
assertNull(shadowOf(app).nextStartedActivity)
|
||||
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
// ---------- 8. Null action: no crash ----------
|
||||
|
||||
@Test
|
||||
fun nullAction_doesNotCrash() = runTest {
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
|
||||
val intent = Intent() // action is null
|
||||
// Should not throw.
|
||||
receiverFor(scope).onReceive(context, intent)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) }
|
||||
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
// ---------- 9. Decline invite: clears notification ----------
|
||||
|
||||
@Test
|
||||
fun declineInvite_withResidenceId_cancelsNotification() = runTest {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
|
||||
|
||||
val notifId = 9009
|
||||
postDummyNotification(notifId)
|
||||
|
||||
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
|
||||
action = NotificationActions.DECLINE_INVITE
|
||||
putExtra(NotificationActions.EXTRA_RESIDENCE_ID, 77L)
|
||||
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
|
||||
}
|
||||
receiverFor(scope).onReceive(context, intent)
|
||||
|
||||
assertFalse(
|
||||
"invite notification should be cleared on decline",
|
||||
notificationManager.activeNotifications.any { it.id == notifId }
|
||||
)
|
||||
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.tt.honeyDue.ui.haptics
|
||||
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* Unit tests for the cross-platform [Haptics] API on Android.
|
||||
*
|
||||
* Uses a pluggable [HapticBackend] to verify the contract without
|
||||
* depending on real hardware (no-op in JVM unit tests otherwise).
|
||||
*
|
||||
* Mirrors iOS haptic taxonomy:
|
||||
* UIImpactFeedbackGenerator(.light) -> light
|
||||
* UIImpactFeedbackGenerator(.medium) -> medium
|
||||
* UIImpactFeedbackGenerator(.heavy) -> heavy
|
||||
* UINotificationFeedbackGenerator(.success|.warning|.error)
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class HapticsAndroidTest {
|
||||
|
||||
private lateinit var fake: RecordingHapticBackend
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
fake = RecordingHapticBackend()
|
||||
Haptics.setBackend(fake)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Haptics.resetBackend()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun light_delegatesToBackend_withLightEvent() {
|
||||
Haptics.light()
|
||||
assertEquals(listOf(HapticEvent.LIGHT), fake.events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun medium_delegatesToBackend_withMediumEvent() {
|
||||
Haptics.medium()
|
||||
assertEquals(listOf(HapticEvent.MEDIUM), fake.events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun heavy_delegatesToBackend_withHeavyEvent() {
|
||||
Haptics.heavy()
|
||||
assertEquals(listOf(HapticEvent.HEAVY), fake.events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun success_delegatesToBackend_withSuccessEvent() {
|
||||
Haptics.success()
|
||||
assertEquals(listOf(HapticEvent.SUCCESS), fake.events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun warning_delegatesToBackend_withWarningEvent() {
|
||||
Haptics.warning()
|
||||
assertEquals(listOf(HapticEvent.WARNING), fake.events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun error_delegatesToBackend_withErrorEvent() {
|
||||
Haptics.error()
|
||||
assertEquals(listOf(HapticEvent.ERROR), fake.events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleCalls_areRecordedInOrder() {
|
||||
Haptics.light()
|
||||
Haptics.success()
|
||||
Haptics.error()
|
||||
assertEquals(
|
||||
listOf(HapticEvent.LIGHT, HapticEvent.SUCCESS, HapticEvent.ERROR),
|
||||
fake.events
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun androidDefaultBackend_isResilientWithoutInstalledContext() {
|
||||
Haptics.resetBackend()
|
||||
// Default backend must not crash even when no context/view is installed.
|
||||
Haptics.light()
|
||||
Haptics.success()
|
||||
Haptics.error()
|
||||
assertTrue("platform default backend should be resilient", true)
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only backend that records events for assertion. */
|
||||
private class RecordingHapticBackend : HapticBackend {
|
||||
val events = mutableListOf<HapticEvent>()
|
||||
override fun perform(event: HapticEvent) {
|
||||
events += event
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package com.tt.honeyDue.ui.screens.task
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Checklist
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tt.honeyDue.models.TaskSuggestionResponse
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.ui.components.common.StandardCard
|
||||
import com.tt.honeyDue.ui.theme.AppRadius
|
||||
import com.tt.honeyDue.ui.theme.AppSpacing
|
||||
import com.tt.honeyDue.util.ErrorMessageParser
|
||||
|
||||
/**
|
||||
* Standalone screen that lets users pick personalized task suggestions
|
||||
* outside the onboarding flow. Android port of iOS TaskSuggestionsView as
|
||||
* a regular destination (not an inline dropdown).
|
||||
*
|
||||
* Flow:
|
||||
* 1. On entry, loads via APILayer.getTaskSuggestions(residenceId).
|
||||
* 2. Each row has an Accept button that fires APILayer.createTask with
|
||||
* template fields + templateId backlink.
|
||||
* 3. Non-onboarding analytics event task_suggestion_accepted fires on
|
||||
* each successful accept.
|
||||
* 4. Skip is a pop with no task created (handled by onNavigateBack).
|
||||
* 5. Supports pull-to-refresh.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TaskSuggestionsScreen(
|
||||
residenceId: Int,
|
||||
onNavigateBack: () -> Unit,
|
||||
onSuggestionAccepted: (templateId: Int) -> Unit = {},
|
||||
viewModel: TaskSuggestionsViewModel = viewModel {
|
||||
TaskSuggestionsViewModel(residenceId = residenceId)
|
||||
}
|
||||
) {
|
||||
val suggestionsState by viewModel.suggestionsState.collectAsState()
|
||||
val acceptState by viewModel.acceptState.collectAsState()
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (suggestionsState is ApiResult.Idle) {
|
||||
viewModel.load()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(suggestionsState) {
|
||||
if (suggestionsState !is ApiResult.Loading) {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(acceptState) {
|
||||
if (acceptState is ApiResult.Success) {
|
||||
val tid = viewModel.lastAcceptedTemplateId
|
||||
if (tid != null) onSuggestionAccepted(tid)
|
||||
viewModel.resetAcceptState()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(text = "Suggested Tasks", fontWeight = FontWeight.SemiBold)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
OutlinedButton(
|
||||
onClick = onNavigateBack,
|
||||
modifier = Modifier.padding(end = AppSpacing.md)
|
||||
) { Text("Skip") }
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
viewModel.load()
|
||||
},
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues)
|
||||
) {
|
||||
when (val state = suggestionsState) {
|
||||
is ApiResult.Loading, ApiResult.Idle -> {
|
||||
Box(Modifier.fillMaxSize(), Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
ErrorView(
|
||||
message = ErrorMessageParser.parse(state.message),
|
||||
onRetry = { viewModel.retry() }
|
||||
)
|
||||
}
|
||||
is ApiResult.Success -> {
|
||||
if (state.data.suggestions.isEmpty()) {
|
||||
EmptyView()
|
||||
} else {
|
||||
SuggestionsList(
|
||||
suggestions = state.data.suggestions,
|
||||
acceptState = acceptState,
|
||||
onAccept = viewModel::accept
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(acceptState as? ApiResult.Error)?.let { err ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(AppSpacing.lg),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = RoundedCornerShape(AppRadius.md),
|
||||
tonalElevation = 4.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(AppSpacing.md),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ErrorOutline,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(
|
||||
text = ErrorMessageParser.parse(err.message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuggestionsList(
|
||||
suggestions: List<TaskSuggestionResponse>,
|
||||
acceptState: ApiResult<*>,
|
||||
onAccept: (TaskSuggestionResponse) -> Unit
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = AppSpacing.lg,
|
||||
end = AppSpacing.lg,
|
||||
top = AppSpacing.md,
|
||||
bottom = AppSpacing.xl
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)
|
||||
) {
|
||||
items(suggestions, key = { it.template.id }) { suggestion ->
|
||||
SuggestionRow(
|
||||
suggestion = suggestion,
|
||||
isAccepting = acceptState is ApiResult.Loading,
|
||||
onAccept = { onAccept(suggestion) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuggestionRow(
|
||||
suggestion: TaskSuggestionResponse,
|
||||
isAccepting: Boolean,
|
||||
onAccept: () -> Unit
|
||||
) {
|
||||
val template = suggestion.template
|
||||
StandardCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = AppSpacing.md
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)) {
|
||||
Text(
|
||||
text = template.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
if (template.description.isNotBlank()) {
|
||||
Text(
|
||||
text = template.description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm)
|
||||
) {
|
||||
Text(
|
||||
text = template.categoryName,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "•",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = template.frequencyDisplay,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onAccept,
|
||||
enabled = !isAccepting,
|
||||
modifier = Modifier.fillMaxWidth().height(44.dp),
|
||||
shape = RoundedCornerShape(AppRadius.md)
|
||||
) {
|
||||
if (isAccepting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.Default.Check, contentDescription = null)
|
||||
Spacer(Modifier.width(AppSpacing.sm))
|
||||
Text("Accept", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorView(message: String, onRetry: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(AppSpacing.xl),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(AppSpacing.md, Alignment.CenterVertically)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ErrorOutline,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(text = "Couldn't load suggestions", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Button(onClick = onRetry) { Text("Retry") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyView() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(AppSpacing.xl),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(AppSpacing.md, Alignment.CenterVertically)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Checklist,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
Text(text = "No suggestions yet", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
text = "Complete your home profile to see personalized recommendations.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.tt.honeyDue.ui.screens.task
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tt.honeyDue.analytics.PostHogAnalytics
|
||||
import com.tt.honeyDue.models.TaskCreateRequest
|
||||
import com.tt.honeyDue.models.TaskResponse
|
||||
import com.tt.honeyDue.models.TaskSuggestionResponse
|
||||
import com.tt.honeyDue.models.TaskSuggestionsResponse
|
||||
import com.tt.honeyDue.network.APILayer
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* ViewModel for the standalone TaskSuggestionsScreen — the non-onboarding
|
||||
* path into personalized task suggestions. Event naming matches the
|
||||
* non-onboarding convention used by the templates browser.
|
||||
*/
|
||||
class TaskSuggestionsViewModel(
|
||||
private val residenceId: Int,
|
||||
private val loadSuggestions: suspend () -> ApiResult<TaskSuggestionsResponse> = {
|
||||
APILayer.getTaskSuggestions(residenceId)
|
||||
},
|
||||
private val createTask: suspend (TaskCreateRequest) -> ApiResult<TaskResponse> = { req ->
|
||||
APILayer.createTask(req)
|
||||
},
|
||||
private val analytics: (String, Map<String, Any>) -> Unit = { name, props ->
|
||||
PostHogAnalytics.capture(name, props)
|
||||
}
|
||||
) : ViewModel() {
|
||||
|
||||
private val _suggestionsState =
|
||||
MutableStateFlow<ApiResult<TaskSuggestionsResponse>>(ApiResult.Idle)
|
||||
val suggestionsState: StateFlow<ApiResult<TaskSuggestionsResponse>> =
|
||||
_suggestionsState.asStateFlow()
|
||||
|
||||
private val _acceptState = MutableStateFlow<ApiResult<TaskResponse>>(ApiResult.Idle)
|
||||
val acceptState: StateFlow<ApiResult<TaskResponse>> = _acceptState.asStateFlow()
|
||||
|
||||
var lastAcceptedTemplateId: Int? = null
|
||||
private set
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_suggestionsState.value = ApiResult.Loading
|
||||
_suggestionsState.value = loadSuggestions()
|
||||
}
|
||||
}
|
||||
|
||||
fun retry() = load()
|
||||
|
||||
fun accept(suggestion: TaskSuggestionResponse) {
|
||||
val template = suggestion.template
|
||||
val request = TaskCreateRequest(
|
||||
residenceId = residenceId,
|
||||
title = template.title,
|
||||
description = template.description.takeIf { it.isNotBlank() },
|
||||
categoryId = template.categoryId,
|
||||
frequencyId = template.frequencyId,
|
||||
templateId = template.id
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
_acceptState.value = ApiResult.Loading
|
||||
val result = createTask(request)
|
||||
_acceptState.value = when (result) {
|
||||
is ApiResult.Success -> {
|
||||
lastAcceptedTemplateId = template.id
|
||||
analytics(
|
||||
EVENT_TASK_SUGGESTION_ACCEPTED,
|
||||
buildMap {
|
||||
put("template_id", template.id)
|
||||
put("relevance_score", suggestion.relevanceScore)
|
||||
template.categoryId?.let { put("category_id", it) }
|
||||
}
|
||||
)
|
||||
ApiResult.Success(result.data)
|
||||
}
|
||||
is ApiResult.Error -> result
|
||||
ApiResult.Loading -> ApiResult.Loading
|
||||
ApiResult.Idle -> ApiResult.Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetAcceptState() {
|
||||
_acceptState.value = ApiResult.Idle
|
||||
lastAcceptedTemplateId = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Non-onboarding analytics event for a single accepted suggestion.
|
||||
*/
|
||||
const val EVENT_TASK_SUGGESTION_ACCEPTED = "task_suggestion_accepted"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package com.tt.honeyDue.ui.screens.task
|
||||
|
||||
import com.tt.honeyDue.models.TaskCategory
|
||||
import com.tt.honeyDue.models.TaskCreateRequest
|
||||
import com.tt.honeyDue.models.TaskResponse
|
||||
import com.tt.honeyDue.models.TaskSuggestionResponse
|
||||
import com.tt.honeyDue.models.TaskSuggestionsResponse
|
||||
import com.tt.honeyDue.models.TaskTemplate
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Unit tests for TaskSuggestionsViewModel covering:
|
||||
* 1. Initial state is Idle.
|
||||
* 2. load() transitions to Success with suggestions.
|
||||
* 3. Empty suggestions -> Success with empty list (empty state UI).
|
||||
* 4. Accept fires createTask with exact template fields + templateId backlink.
|
||||
* 5. Accept success fires non-onboarding analytics task_suggestion_accepted.
|
||||
* 6. Load error surfaces ApiResult.Error + retry() reloads endpoint.
|
||||
* 7. ViewModel is constructible with an explicit residenceId (standalone path).
|
||||
* 8. Accept error surfaces error and fires no analytics.
|
||||
* 9. resetAcceptState returns to Idle.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TaskSuggestionsViewModelTest {
|
||||
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() { Dispatchers.setMain(dispatcher) }
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() { Dispatchers.resetMain() }
|
||||
|
||||
private val plumbingCat = TaskCategory(id = 1, name = "Plumbing")
|
||||
|
||||
private val template1 = TaskTemplate(
|
||||
id = 100,
|
||||
title = "Change Water Filter",
|
||||
description = "Replace every 6 months.",
|
||||
categoryId = 1,
|
||||
category = plumbingCat,
|
||||
frequencyId = 7
|
||||
)
|
||||
private val template2 = TaskTemplate(
|
||||
id = 200,
|
||||
title = "Flush Water Heater",
|
||||
description = "Annual flush.",
|
||||
categoryId = 1,
|
||||
category = plumbingCat,
|
||||
frequencyId = 8
|
||||
)
|
||||
|
||||
private val suggestionsResponse = TaskSuggestionsResponse(
|
||||
suggestions = listOf(
|
||||
TaskSuggestionResponse(
|
||||
template = template1,
|
||||
relevanceScore = 0.92,
|
||||
matchReasons = listOf("Has water heater")
|
||||
),
|
||||
TaskSuggestionResponse(
|
||||
template = template2,
|
||||
relevanceScore = 0.75,
|
||||
matchReasons = listOf("Annual maintenance")
|
||||
)
|
||||
),
|
||||
totalCount = 2,
|
||||
profileCompleteness = 0.8
|
||||
)
|
||||
|
||||
private val emptyResponse = TaskSuggestionsResponse(
|
||||
suggestions = emptyList(),
|
||||
totalCount = 0,
|
||||
profileCompleteness = 0.5
|
||||
)
|
||||
|
||||
private fun fakeCreatedTask(templateId: Int?): TaskResponse = TaskResponse(
|
||||
id = 777,
|
||||
residenceId = 42,
|
||||
createdById = 1,
|
||||
title = "Created",
|
||||
description = "",
|
||||
categoryId = 1,
|
||||
frequencyId = 7,
|
||||
templateId = templateId,
|
||||
createdAt = "2024-01-01T00:00:00Z",
|
||||
updatedAt = "2024-01-01T00:00:00Z"
|
||||
)
|
||||
|
||||
private fun makeViewModel(
|
||||
loadResult: ApiResult<TaskSuggestionsResponse> = ApiResult.Success(suggestionsResponse),
|
||||
createResult: ApiResult<TaskResponse> = ApiResult.Success(fakeCreatedTask(100)),
|
||||
onCreateCall: (TaskCreateRequest) -> Unit = {},
|
||||
onAnalytics: (String, Map<String, Any>) -> Unit = { _, _ -> },
|
||||
residenceId: Int = 42
|
||||
) = TaskSuggestionsViewModel(
|
||||
residenceId = residenceId,
|
||||
loadSuggestions = { loadResult },
|
||||
createTask = { request ->
|
||||
onCreateCall(request)
|
||||
createResult
|
||||
},
|
||||
analytics = onAnalytics
|
||||
)
|
||||
|
||||
@Test
|
||||
fun initialStateIsIdle() {
|
||||
val vm = makeViewModel()
|
||||
assertIs<ApiResult.Idle>(vm.suggestionsState.value)
|
||||
assertIs<ApiResult.Idle>(vm.acceptState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadTransitionsToSuccessWithSuggestions() = runTest(dispatcher) {
|
||||
val vm = makeViewModel()
|
||||
vm.load()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val state = vm.suggestionsState.value
|
||||
assertIs<ApiResult.Success<TaskSuggestionsResponse>>(state)
|
||||
assertEquals(2, state.data.totalCount)
|
||||
assertEquals(100, state.data.suggestions.first().template.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptySuggestionsResolvesToSuccessWithEmptyList() = runTest(dispatcher) {
|
||||
val vm = makeViewModel(loadResult = ApiResult.Success(emptyResponse))
|
||||
vm.load()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val state = vm.suggestionsState.value
|
||||
assertIs<ApiResult.Success<TaskSuggestionsResponse>>(state)
|
||||
assertTrue(state.data.suggestions.isEmpty())
|
||||
assertEquals(0, state.data.totalCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptInvokesCreateTaskWithTemplateIdBacklinkAndFields() = runTest(dispatcher) {
|
||||
var captured: TaskCreateRequest? = null
|
||||
val vm = makeViewModel(onCreateCall = { captured = it })
|
||||
vm.load()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
vm.accept(suggestionsResponse.suggestions.first())
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val req = captured ?: error("createTask was not called")
|
||||
assertEquals(42, req.residenceId)
|
||||
assertEquals("Change Water Filter", req.title)
|
||||
assertEquals("Replace every 6 months.", req.description)
|
||||
assertEquals(1, req.categoryId)
|
||||
assertEquals(7, req.frequencyId)
|
||||
assertEquals(100, req.templateId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptSuccessFiresNonOnboardingAnalyticsEvent() = runTest(dispatcher) {
|
||||
val events = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
val vm = makeViewModel(onAnalytics = { name, props -> events += name to props })
|
||||
vm.load()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
vm.accept(suggestionsResponse.suggestions.first())
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val accepted = events.firstOrNull {
|
||||
it.first == TaskSuggestionsViewModel.EVENT_TASK_SUGGESTION_ACCEPTED
|
||||
}
|
||||
assertTrue(accepted != null, "expected task_suggestion_accepted event")
|
||||
assertEquals(100, accepted.second["template_id"])
|
||||
assertEquals(0.92, accepted.second["relevance_score"])
|
||||
|
||||
assertTrue(events.none { it.first == "onboarding_suggestion_accepted" })
|
||||
|
||||
val state = vm.acceptState.value
|
||||
assertIs<ApiResult.Success<TaskResponse>>(state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadErrorSurfacesErrorAndRetryReloads() = runTest(dispatcher) {
|
||||
var callCount = 0
|
||||
var nextResult: ApiResult<TaskSuggestionsResponse> = ApiResult.Error("Network down", 500)
|
||||
val vm = TaskSuggestionsViewModel(
|
||||
residenceId = 42,
|
||||
loadSuggestions = {
|
||||
callCount++
|
||||
nextResult
|
||||
},
|
||||
createTask = { ApiResult.Success(fakeCreatedTask(null)) },
|
||||
analytics = { _, _ -> }
|
||||
)
|
||||
|
||||
vm.load()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertIs<ApiResult.Error>(vm.suggestionsState.value)
|
||||
assertEquals(1, callCount)
|
||||
|
||||
nextResult = ApiResult.Success(suggestionsResponse)
|
||||
vm.retry()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertEquals(2, callCount)
|
||||
assertIs<ApiResult.Success<TaskSuggestionsResponse>>(vm.suggestionsState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun viewModelUsesProvidedResidenceIdOnStandalonePath() = runTest(dispatcher) {
|
||||
var captured: TaskCreateRequest? = null
|
||||
val vm = makeViewModel(
|
||||
residenceId = 999,
|
||||
onCreateCall = { captured = it }
|
||||
)
|
||||
vm.load()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
vm.accept(suggestionsResponse.suggestions.first())
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertEquals(999, captured?.residenceId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptErrorSurfacesErrorAndFiresNoAnalytics() = runTest(dispatcher) {
|
||||
val events = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
val vm = makeViewModel(
|
||||
createResult = ApiResult.Error("Server error", 500),
|
||||
onAnalytics = { name, props -> events += name to props }
|
||||
)
|
||||
vm.load()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
vm.accept(suggestionsResponse.suggestions.first())
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val state = vm.acceptState.value
|
||||
assertIs<ApiResult.Error>(state)
|
||||
assertEquals("Server error", state.message)
|
||||
assertTrue(events.isEmpty(), "analytics should not fire on accept error")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetAcceptStateReturnsToIdle() = runTest(dispatcher) {
|
||||
val vm = makeViewModel(createResult = ApiResult.Error("boom", 500))
|
||||
vm.load()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
vm.accept(suggestionsResponse.suggestions.first())
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
assertIs<ApiResult.Error>(vm.acceptState.value)
|
||||
|
||||
vm.resetAcceptState()
|
||||
assertIs<ApiResult.Idle>(vm.acceptState.value)
|
||||
assertNull(vm.lastAcceptedTemplateId)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user