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:
@@ -65,6 +65,19 @@
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="@string/default_notification_channel_id" />
|
||||
|
||||
<!-- Notification Action Receiver -->
|
||||
<receiver
|
||||
android:name=".NotificationActionReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.example.casera.ACTION_VIEW_TASK" />
|
||||
<action android:name="com.example.casera.ACTION_COMPLETE_TASK" />
|
||||
<action android:name="com.example.casera.ACTION_MARK_IN_PROGRESS" />
|
||||
<action android:name="com.example.casera.ACTION_CANCEL_TASK" />
|
||||
<action android:name="com.example.casera.ACTION_UNCANCEL_TASK" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -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?) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user