P2 Stream H: standalone TaskSuggestionsScreen

Port iOS TaskSuggestionsView as a standalone route reachable outside
onboarding. Uses shared suggestions API + accept/skip analytics in
non-onboarding variant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 13:10:47 -05:00
parent 7d71408bcc
commit 19471d780d
19 changed files with 2161 additions and 3 deletions
@@ -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()
}