Add actionable push notifications for iOS and Android

iOS:
- Add notification categories with action buttons (complete, view, cancel, etc.)
- Handle notification actions in AppDelegate with API calls
- Add navigation to specific task from notification tap
- Register UNNotificationCategory for each task state

Android:
- Add NotificationActionReceiver BroadcastReceiver for handling actions
- Update MyFirebaseMessagingService to show action buttons
- Add deep link handling in MainActivity for task navigation
- Register receiver in AndroidManifest.xml

Shared:
- Add navigateToTaskId parameter to App for cross-platform navigation
- Add notification observers in MainTabView/AllTasksView for refresh

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-05 14:23:25 -06:00
parent 771f5d2bd3
commit 2965ec4031
19 changed files with 945 additions and 18 deletions

View File

@@ -35,6 +35,7 @@ import kotlinx.coroutines.launch
class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
private var deepLinkResetToken by mutableStateOf<String?>(null)
private var navigateToTaskId by mutableStateOf<Int?>(null)
private lateinit var billingManager: BillingManager
override fun onCreate(savedInstanceState: Bundle?) {
@@ -54,8 +55,9 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
// Initialize BillingManager for subscription management
billingManager = BillingManager.getInstance(applicationContext)
// Handle deep link from intent
// Handle deep link and notification navigation from intent
handleDeepLink(intent)
handleNotificationNavigation(intent)
// Request notification permission and setup FCM
setupFCM()
@@ -68,6 +70,10 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
deepLinkResetToken = deepLinkResetToken,
onClearDeepLinkToken = {
deepLinkResetToken = null
},
navigateToTaskId = navigateToTaskId,
onClearNavigateToTask = {
navigateToTaskId = null
}
)
}
@@ -180,6 +186,15 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleDeepLink(intent)
handleNotificationNavigation(intent)
}
private fun handleNotificationNavigation(intent: Intent?) {
val taskId = intent?.getIntExtra(NotificationActionReceiver.EXTRA_NAVIGATE_TO_TASK, -1)
if (taskId != null && taskId != -1) {
Log.d("MainActivity", "Navigating to task from notification: $taskId")
navigateToTaskId = taskId
}
}
private fun handleDeepLink(intent: Intent?) {

View File

@@ -8,6 +8,7 @@ import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import com.example.casera.cache.SubscriptionCache
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.CoroutineScope
@@ -92,25 +93,33 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
}
private fun sendNotification(title: String, body: String, data: Map<String, String>) {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
val taskIdStr = data["task_id"]
val taskId = taskIdStr?.toIntOrNull()
val buttonTypesStr = data["button_types"]
val notificationId = taskId ?: NOTIFICATION_ID
// Add data to intent for handling when notification is clicked
// Create main tap intent
val mainIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
data.forEach { (key, value) ->
putExtra(key, value)
}
// Add navigation extra if task notification
if (taskId != null) {
putExtra(NotificationActionReceiver.EXTRA_NAVIGATE_TO_TASK, taskId)
}
}
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_ONE_SHOT
PendingIntent.FLAG_UPDATE_CURRENT
}
val pendingIntent = PendingIntent.getActivity(
val mainPendingIntent = PendingIntent.getActivity(
this,
0,
intent,
notificationId,
mainIntent,
pendingIntentFlags
)
@@ -120,16 +129,21 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setContentIntent(mainPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
// Add action buttons for premium users with task notifications
if (taskId != null && isPremiumUser()) {
addActionButtons(notificationBuilder, taskId, notificationId, buttonTypesStr)
}
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Create notification channel for Android O and above
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId,
"MyCrib Notifications",
"Casera Notifications",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Notifications for tasks, residences, and warranties"
@@ -137,7 +151,60 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
notificationManager.createNotificationChannel(channel)
}
notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build())
notificationManager.notify(notificationId, notificationBuilder.build())
}
private fun isPremiumUser(): Boolean {
val subscription = SubscriptionCache.currentSubscription.value
// User is premium if limitations are disabled
return subscription?.limitationsEnabled == false
}
private fun addActionButtons(
builder: NotificationCompat.Builder,
taskId: Int,
notificationId: Int,
buttonTypesStr: String?
) {
val buttonTypes = buttonTypesStr?.split(",")?.map { it.trim() } ?: emptyList()
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
// Add up to 3 action buttons (Android notification limit)
var actionCount = 0
val maxActions = 3
for (buttonType in buttonTypes) {
if (actionCount >= maxActions) break
val (action, label) = when (buttonType) {
"complete" -> NotificationActionReceiver.ACTION_COMPLETE_TASK to "Complete"
"mark_in_progress" -> NotificationActionReceiver.ACTION_MARK_IN_PROGRESS to "Start"
"cancel" -> NotificationActionReceiver.ACTION_CANCEL_TASK to "Cancel"
"uncancel" -> NotificationActionReceiver.ACTION_UNCANCEL_TASK to "Restore"
else -> continue
}
val intent = Intent(this, NotificationActionReceiver::class.java).apply {
this.action = action
putExtra(NotificationActionReceiver.EXTRA_TASK_ID, taskId)
putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION_ID, notificationId)
}
val pendingIntent = PendingIntent.getBroadcast(
this,
notificationId * 10 + actionCount, // Unique request code per action
intent,
pendingIntentFlags
)
builder.addAction(0, label, pendingIntent)
actionCount++
}
}
companion object {

View File

@@ -0,0 +1,171 @@
package com.example.casera
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import com.example.casera.cache.SubscriptionCache
import com.example.casera.models.TaskCompletionCreateRequest
import com.example.casera.network.APILayer
import com.example.casera.network.ApiResult
import com.example.casera.storage.TokenStorage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* BroadcastReceiver for handling notification action button clicks.
* Performs task actions (complete, cancel, etc.) directly from notifications.
*/
class NotificationActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action ?: return
val taskId = intent.getIntExtra(EXTRA_TASK_ID, -1)
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0)
Log.d(TAG, "Action received: $action for task $taskId")
if (taskId == -1) {
Log.e(TAG, "No task ID provided")
return
}
// Dismiss the notification
NotificationManagerCompat.from(context).cancel(notificationId)
// Check subscription status
val isPremium = isPremiumUser()
if (!isPremium) {
Log.d(TAG, "Non-premium user, ignoring action")
launchMainActivity(context, null)
return
}
// Handle action
when (action) {
ACTION_VIEW_TASK -> {
launchMainActivity(context, taskId)
}
ACTION_COMPLETE_TASK -> {
performCompleteTask(context, taskId)
}
ACTION_MARK_IN_PROGRESS -> {
performMarkInProgress(context, taskId)
}
ACTION_CANCEL_TASK -> {
performCancelTask(context, taskId)
}
ACTION_UNCANCEL_TASK -> {
performUncancelTask(context, taskId)
}
else -> {
Log.w(TAG, "Unknown action: $action")
}
}
}
private fun isPremiumUser(): Boolean {
val subscription = SubscriptionCache.currentSubscription.value
// User is premium if limitations are disabled
return subscription?.limitationsEnabled == false
}
private fun launchMainActivity(context: Context, taskId: Int?) {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
if (taskId != null) {
putExtra(EXTRA_NAVIGATE_TO_TASK, taskId)
}
}
context.startActivity(intent)
}
private fun performCompleteTask(context: Context, taskId: Int) {
Log.d(TAG, "Completing task $taskId")
CoroutineScope(Dispatchers.IO).launch {
val request = TaskCompletionCreateRequest(
taskId = taskId,
completedAt = null,
notes = null,
actualCost = null,
rating = null,
imageUrls = null
)
when (val result = APILayer.createTaskCompletion(request)) {
is ApiResult.Success -> {
Log.d(TAG, "Task $taskId completed successfully")
// Launch app to show result
launchMainActivity(context, taskId)
}
is ApiResult.Error -> {
Log.e(TAG, "Failed to complete task: ${result.message}")
}
else -> {}
}
}
}
private fun performMarkInProgress(context: Context, taskId: Int) {
Log.d(TAG, "Marking task $taskId as in progress")
CoroutineScope(Dispatchers.IO).launch {
when (val result = APILayer.markInProgress(taskId)) {
is ApiResult.Success -> {
Log.d(TAG, "Task $taskId marked as in progress")
}
is ApiResult.Error -> {
Log.e(TAG, "Failed to mark task in progress: ${result.message}")
}
else -> {}
}
}
}
private fun performCancelTask(context: Context, taskId: Int) {
Log.d(TAG, "Cancelling task $taskId")
CoroutineScope(Dispatchers.IO).launch {
when (val result = APILayer.cancelTask(taskId)) {
is ApiResult.Success -> {
Log.d(TAG, "Task $taskId cancelled")
}
is ApiResult.Error -> {
Log.e(TAG, "Failed to cancel task: ${result.message}")
}
else -> {}
}
}
}
private fun performUncancelTask(context: Context, taskId: Int) {
Log.d(TAG, "Uncancelling task $taskId")
CoroutineScope(Dispatchers.IO).launch {
when (val result = APILayer.uncancelTask(taskId)) {
is ApiResult.Success -> {
Log.d(TAG, "Task $taskId uncancelled")
}
is ApiResult.Error -> {
Log.e(TAG, "Failed to uncancel task: ${result.message}")
}
else -> {}
}
}
}
companion object {
private const val TAG = "NotificationAction"
// Action constants
const val ACTION_VIEW_TASK = "com.example.casera.ACTION_VIEW_TASK"
const val ACTION_COMPLETE_TASK = "com.example.casera.ACTION_COMPLETE_TASK"
const val ACTION_MARK_IN_PROGRESS = "com.example.casera.ACTION_MARK_IN_PROGRESS"
const val ACTION_CANCEL_TASK = "com.example.casera.ACTION_CANCEL_TASK"
const val ACTION_UNCANCEL_TASK = "com.example.casera.ACTION_UNCANCEL_TASK"
// Extra constants
const val EXTRA_TASK_ID = "task_id"
const val EXTRA_NOTIFICATION_ID = "notification_id"
const val EXTRA_NAVIGATE_TO_TASK = "navigate_to_task"
}
}