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:
+318
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user