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

@@ -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>

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"
}
}

View File

@@ -65,7 +65,9 @@ import casera.composeapp.generated.resources.compose_multiplatform
@Preview
fun App(
deepLinkResetToken: String? = null,
onClearDeepLinkToken: () -> Unit = {}
onClearDeepLinkToken: () -> Unit = {},
navigateToTaskId: Int? = null,
onClearNavigateToTask: () -> Unit = {}
) {
var isLoggedIn by remember { mutableStateOf(DataManager.authToken.value != null) }
var isVerified by remember { mutableStateOf(false) }
@@ -73,6 +75,15 @@ fun App(
var hasCompletedOnboarding by remember { mutableStateOf(DataManager.hasCompletedOnboarding.value) }
val navController = rememberNavController()
// Handle navigation from notification tap
LaunchedEffect(navigateToTaskId) {
if (navigateToTaskId != null && isLoggedIn && isVerified) {
// Navigate to tasks screen (task detail view is handled within the screen)
navController.navigate(TasksRoute)
onClearNavigateToTask()
}
}
// Check for stored token and verification status on app start
LaunchedEffect(Unit) {
val hasToken = DataManager.authToken.value != null

View File

@@ -0,0 +1,11 @@
import Foundation
extension Notification.Name {
// Navigation notifications from push notification actions
static let navigateToTask = Notification.Name("navigateToTask")
static let navigateToEditTask = Notification.Name("navigateToEditTask")
static let navigateToHome = Notification.Name("navigateToHome")
// Task action completion notification (for UI refresh)
static let taskActionCompleted = Notification.Name("taskActionCompleted")
}

View File

@@ -62,6 +62,17 @@ struct MainTabView: View {
.onChange(of: authManager.isAuthenticated) { _ in
selectedTab = 0
}
// Handle push notification deep links - switch to appropriate tab
// The actual task navigation is handled by AllTasksView
.onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { _ in
selectedTab = 1 // Switch to Tasks tab
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { _ in
selectedTab = 1 // Switch to Tasks tab
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToHome)) { _ in
selectedTab = 0 // Switch to Residences tab
}
}
}

View File

@@ -11,6 +11,9 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
// Set notification delegate
UNUserNotificationCenter.current().delegate = self
// Register notification categories for actionable notifications
NotificationCategories.registerCategories()
// Request notification permission
Task { @MainActor in
await PushNotificationManager.shared.requestNotificationPermission()
@@ -85,17 +88,33 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
completionHandler([.banner, .sound, .badge])
}
// Called when user taps on notification
// Called when user taps on notification or selects an action
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
print("👆 User tapped notification: \(userInfo)")
let actionIdentifier = response.actionIdentifier
print("👆 User interacted with notification - Action: \(actionIdentifier)")
print(" UserInfo: \(userInfo)")
Task { @MainActor in
PushNotificationManager.shared.handleNotification(userInfo: userInfo)
// Handle action buttons or default tap
if actionIdentifier == UNNotificationDefaultActionIdentifier {
// User tapped the notification body - navigate to task
PushNotificationManager.shared.handleNotificationTap(userInfo: userInfo)
} else if actionIdentifier == UNNotificationDismissActionIdentifier {
// User dismissed the notification
print("📤 Notification dismissed")
} else {
// User selected an action button
PushNotificationManager.shared.handleNotificationAction(
actionIdentifier: actionIdentifier,
userInfo: userInfo
)
}
}
completionHandler()

View File

@@ -0,0 +1,140 @@
import UserNotifications
/// Notification Category Identifiers matching backend constants
enum NotificationCategoryID: String {
case taskActionable = "TASK_ACTIONABLE" // overdue, due_soon, upcoming
case taskInProgress = "TASK_IN_PROGRESS" // tasks in progress
case taskCancelled = "TASK_CANCELLED" // cancelled tasks
case taskCompleted = "TASK_COMPLETED" // completed tasks (read-only)
case taskGeneric = "TASK_NOTIFICATION_GENERIC" // non-premium users
}
/// Action Identifiers for notification buttons
enum NotificationActionID: String {
// Task actions
case viewTask = "VIEW_TASK"
case completeTask = "COMPLETE_TASK"
case markInProgress = "MARK_IN_PROGRESS"
case cancelTask = "CANCEL_TASK"
case uncancelTask = "UNCANCEL_TASK"
case editTask = "EDIT_TASK"
// Default action (tapped notification body)
case defaultAction = "com.apple.UNNotificationDefaultActionIdentifier"
}
/// Manages notification category registration
struct NotificationCategories {
/// Registers all notification categories with the system
/// Call this early in app launch (didFinishLaunching)
static func registerCategories() {
let categories = createAllCategories()
UNUserNotificationCenter.current().setNotificationCategories(categories)
print("Registered \(categories.count) notification categories")
}
/// Creates all notification categories for the app
private static func createAllCategories() -> Set<UNNotificationCategory> {
return [
createTaskActionableCategory(),
createTaskInProgressCategory(),
createTaskCancelledCategory(),
createTaskCompletedCategory(),
createTaskGenericCategory()
]
}
// MARK: - Category Definitions
/// TASK_ACTIONABLE: For overdue, due_soon, upcoming tasks
/// Actions: Complete, Mark In Progress, Cancel
private static func createTaskActionableCategory() -> UNNotificationCategory {
let completeAction = UNNotificationAction(
identifier: NotificationActionID.completeTask.rawValue,
title: "Complete",
options: []
)
let inProgressAction = UNNotificationAction(
identifier: NotificationActionID.markInProgress.rawValue,
title: "Start",
options: []
)
let cancelAction = UNNotificationAction(
identifier: NotificationActionID.cancelTask.rawValue,
title: "Cancel",
options: [.destructive]
)
return UNNotificationCategory(
identifier: NotificationCategoryID.taskActionable.rawValue,
actions: [completeAction, inProgressAction, cancelAction],
intentIdentifiers: [],
options: []
)
}
/// TASK_IN_PROGRESS: For tasks currently being worked on
/// Actions: Complete, Cancel
private static func createTaskInProgressCategory() -> UNNotificationCategory {
let completeAction = UNNotificationAction(
identifier: NotificationActionID.completeTask.rawValue,
title: "Complete",
options: []
)
let cancelAction = UNNotificationAction(
identifier: NotificationActionID.cancelTask.rawValue,
title: "Cancel",
options: [.destructive]
)
return UNNotificationCategory(
identifier: NotificationCategoryID.taskInProgress.rawValue,
actions: [completeAction, cancelAction],
intentIdentifiers: [],
options: []
)
}
/// TASK_CANCELLED: For cancelled tasks
/// Actions: Uncancel
private static func createTaskCancelledCategory() -> UNNotificationCategory {
let uncancelAction = UNNotificationAction(
identifier: NotificationActionID.uncancelTask.rawValue,
title: "Restore",
options: []
)
return UNNotificationCategory(
identifier: NotificationCategoryID.taskCancelled.rawValue,
actions: [uncancelAction],
intentIdentifiers: [],
options: []
)
}
/// TASK_COMPLETED: For completed tasks (read-only, tap to view)
/// No actions - just view on tap
private static func createTaskCompletedCategory() -> UNNotificationCategory {
return UNNotificationCategory(
identifier: NotificationCategoryID.taskCompleted.rawValue,
actions: [],
intentIdentifiers: [],
options: []
)
}
/// TASK_NOTIFICATION_GENERIC: For non-premium users
/// No actions - tap opens app home
private static func createTaskGenericCategory() -> UNNotificationCategory {
return UNNotificationCategory(
identifier: NotificationCategoryID.taskGeneric.rawValue,
actions: [],
intentIdentifiers: [],
options: []
)
}
}

View File

@@ -163,12 +163,103 @@ class PushNotificationManager: NSObject, ObservableObject {
}
}
/// Called when user taps the notification body (not an action button)
func handleNotificationTap(userInfo: [AnyHashable: Any]) {
print("📬 Handling notification tap")
// Mark as read
if let notificationId = userInfo["notification_id"] as? String {
Task {
await markNotificationAsRead(notificationId: notificationId)
}
}
// Check subscription status to determine navigation
let isPremium = SubscriptionCacheWrapper.shared.currentTier == "pro" ||
!(SubscriptionCacheWrapper.shared.currentSubscription?.limitationsEnabled ?? true)
if isPremium {
// Premium user - navigate to task detail
if let taskIdStr = userInfo["task_id"] as? String, let taskId = Int(taskIdStr) {
navigateToTask(taskId: taskId)
} else if let taskId = userInfo["task_id"] as? Int {
navigateToTask(taskId: taskId)
}
} else {
// Free user - navigate to home
navigateToHome()
}
}
/// Called when user selects an action button on the notification
func handleNotificationAction(actionIdentifier: String, userInfo: [AnyHashable: Any]) {
print("🔘 Handling notification action: \(actionIdentifier)")
// Mark as read
if let notificationId = userInfo["notification_id"] as? String {
Task {
await markNotificationAsRead(notificationId: notificationId)
}
}
// Check subscription status
let isPremium = SubscriptionCacheWrapper.shared.currentTier == "pro" ||
!(SubscriptionCacheWrapper.shared.currentSubscription?.limitationsEnabled ?? true)
guard isPremium else {
// Free user shouldn't see actions, but if they somehow do, go to home
navigateToHome()
return
}
// Extract task ID
var taskId: Int?
if let taskIdStr = userInfo["task_id"] as? String {
taskId = Int(taskIdStr)
} else if let id = userInfo["task_id"] as? Int {
taskId = id
}
guard let taskId = taskId else {
print("❌ No task_id found in notification")
return
}
// Handle action
switch actionIdentifier {
case NotificationActionID.viewTask.rawValue:
navigateToTask(taskId: taskId)
case NotificationActionID.completeTask.rawValue:
performCompleteTask(taskId: taskId)
case NotificationActionID.markInProgress.rawValue:
performMarkInProgress(taskId: taskId)
case NotificationActionID.cancelTask.rawValue:
performCancelTask(taskId: taskId)
case NotificationActionID.uncancelTask.rawValue:
performUncancelTask(taskId: taskId)
case NotificationActionID.editTask.rawValue:
navigateToEditTask(taskId: taskId)
default:
print("⚠️ Unknown action: \(actionIdentifier)")
navigateToTask(taskId: taskId)
}
}
private func handleNotificationType(type: String, userInfo: [AnyHashable: Any]) {
switch type {
case "task_due_soon", "task_overdue", "task_completed", "task_assigned":
if let taskId = userInfo["task_id"] as? String {
if let taskIdStr = userInfo["task_id"] as? String, let taskId = Int(taskIdStr) {
print("Task notification for task ID: \(taskId)")
// TODO: Navigate to task detail
navigateToTask(taskId: taskId)
} else if let taskId = userInfo["task_id"] as? Int {
print("Task notification for task ID: \(taskId)")
navigateToTask(taskId: taskId)
}
case "residence_shared":
@@ -188,6 +279,123 @@ class PushNotificationManager: NSObject, ObservableObject {
}
}
// MARK: - Task Actions
private func performCompleteTask(taskId: Int) {
print("✅ Completing task \(taskId) from notification action")
Task {
do {
// Quick complete without photos/notes
let request = TaskCompletionCreateRequest(
taskId: Int32(taskId),
completedAt: nil,
notes: nil,
actualCost: nil,
rating: nil,
imageUrls: nil
)
let result = try await APILayer.shared.createTaskCompletion(request: request)
if result is ApiResultSuccess<TaskCompletionResponse> {
print("✅ Task \(taskId) completed successfully")
// Post notification for UI refresh
await MainActor.run {
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
}
} else if let error = result as? ApiResultError {
print("❌ Failed to complete task: \(error.message)")
}
} catch {
print("❌ Error completing task: \(error.localizedDescription)")
}
}
}
private func performMarkInProgress(taskId: Int) {
print("🔄 Marking task \(taskId) as in progress from notification action")
Task {
do {
let result = try await APILayer.shared.markInProgress(taskId: Int32(taskId))
if result is ApiResultSuccess<TaskResponse> {
print("✅ Task \(taskId) marked as in progress")
await MainActor.run {
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
}
} else if let error = result as? ApiResultError {
print("❌ Failed to mark task in progress: \(error.message)")
}
} catch {
print("❌ Error marking task in progress: \(error.localizedDescription)")
}
}
}
private func performCancelTask(taskId: Int) {
print("🚫 Cancelling task \(taskId) from notification action")
Task {
do {
let result = try await APILayer.shared.cancelTask(taskId: Int32(taskId))
if result is ApiResultSuccess<TaskResponse> {
print("✅ Task \(taskId) cancelled")
await MainActor.run {
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
}
} else if let error = result as? ApiResultError {
print("❌ Failed to cancel task: \(error.message)")
}
} catch {
print("❌ Error cancelling task: \(error.localizedDescription)")
}
}
}
private func performUncancelTask(taskId: Int) {
print("↩️ Uncancelling task \(taskId) from notification action")
Task {
do {
let result = try await APILayer.shared.uncancelTask(taskId: Int32(taskId))
if result is ApiResultSuccess<TaskResponse> {
print("✅ Task \(taskId) uncancelled")
await MainActor.run {
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
}
} else if let error = result as? ApiResultError {
print("❌ Failed to uncancel task: \(error.message)")
}
} catch {
print("❌ Error uncancelling task: \(error.localizedDescription)")
}
}
}
// MARK: - Navigation
private func navigateToTask(taskId: Int) {
print("📱 Navigating to task \(taskId)")
NotificationCenter.default.post(
name: .navigateToTask,
object: nil,
userInfo: ["taskId": taskId]
)
}
private func navigateToEditTask(taskId: Int) {
print("✏️ Navigating to edit task \(taskId)")
NotificationCenter.default.post(
name: .navigateToEditTask,
object: nil,
userInfo: ["taskId": taskId]
)
}
private func navigateToHome() {
print("🏠 Navigating to home")
NotificationCenter.default.post(name: .navigateToHome, object: nil)
}
private func markNotificationAsRead(notificationId: String) async {
guard TokenStorage.shared.getToken() != nil,
let notificationIdInt = Int32(notificationId) else {

View File

@@ -17,6 +17,9 @@ struct AllTasksView: View {
@State private var selectedTaskForCancel: TaskResponse?
@State private var showCancelConfirmation = false
// Deep link task ID to open (from push notification)
@State private var pendingTaskId: Int32?
// Use ViewModel's computed properties
private var totalTaskCount: Int { taskViewModel.totalTaskCount }
private var hasNoTasks: Bool { taskViewModel.hasNoTasks }
@@ -97,6 +100,31 @@ struct AllTasksView: View {
loadAllTasks()
residenceViewModel.loadMyResidences()
}
// Handle push notification deep links
.onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { notification in
if let userInfo = notification.userInfo,
let taskId = userInfo["taskId"] as? Int {
pendingTaskId = Int32(taskId)
}
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { notification in
if let userInfo = notification.userInfo,
let taskId = userInfo["taskId"] as? Int {
pendingTaskId = Int32(taskId)
}
}
// When tasks load and we have a pending task ID, open the edit sheet
.onChange(of: tasksResponse) { response in
if let taskId = pendingTaskId, let response = response {
// Find the task in all columns
let allTasks = response.columns.flatMap { $0.tasks }
if let task = allTasks.first(where: { $0.id == taskId }) {
selectedTaskForEdit = task
showEditTask = true
pendingTaskId = nil
}
}
}
}
@ViewBuilder

View File

@@ -0,0 +1,81 @@
# Push Notification Test Payloads
These `.apns` files can be dragged onto the iOS Simulator to test push notifications with action buttons.
## How to Use
1. Run the app on the iOS Simulator
2. Drag and drop any `.apns` file onto the Simulator window
3. The notification will appear with the appropriate action buttons
## Before Testing
Edit the `task_id` in each file to match a valid task ID from your database.
## Available Payloads
| File | Category | Actions | Description |
|------|----------|---------|-------------|
| `task_overdue.apns` | TASK_ACTIONABLE | edit, complete, cancel, mark_in_progress | Overdue task notification |
| `task_due_soon.apns` | TASK_ACTIONABLE | edit, complete, cancel, mark_in_progress | Task due within threshold |
| `task_in_progress.apns` | TASK_IN_PROGRESS | edit, complete, cancel | Task being worked on |
| `task_cancelled.apns` | TASK_CANCELLED | uncancel, delete | Cancelled task |
| `task_completed.apns` | TASK_COMPLETED | (none - read only) | Completed one-time task |
| `task_assigned.apns` | TASK_ACTIONABLE | edit, complete, cancel, mark_in_progress | Task assigned to user |
| `task_generic_free_user.apns` | TASK_NOTIFICATION_GENERIC | (none) | Free user sees generic message |
## iOS Notification Categories
The `category` field in `aps` maps to these iOS notification categories:
- `TASK_ACTIONABLE` - For overdue, due_soon, upcoming tasks (full action set)
- `TASK_IN_PROGRESS` - For tasks currently being worked on
- `TASK_CANCELLED` - For cancelled tasks (can uncancel or delete)
- `TASK_COMPLETED` - For completed one-time tasks (read-only, no actions)
- `TASK_NOTIFICATION_GENERIC` - For free users (no actions, generic message)
## Payload Structure
```json
{
"Simulator Target Bundle": "com.tt.casera.CaseraDev",
"aps": {
"alert": {
"title": "Notification Title",
"subtitle": "Residence Name",
"body": "Notification body text"
},
"sound": "default",
"badge": 1,
"category": "TASK_ACTIONABLE",
"mutable-content": 1,
"thread-id": "task-{id}"
},
"task_id": 123,
"task_name": "Task Name",
"residence_id": 1,
"residence_name": "Residence Name",
"notification_type": "task_overdue|task_due_soon|task_completed|task_assigned",
"button_types": ["edit", "complete", "cancel", "mark_in_progress"],
"is_premium": true
}
```
## Custom Data Fields
| Field | Type | Description |
|-------|------|-------------|
| `task_id` | int | The task ID to navigate to when tapped |
| `task_name` | string | Display name of the task |
| `residence_id` | int | The residence ID |
| `residence_name` | string | Display name of the residence |
| `notification_type` | string | Type of notification (for analytics/logging) |
| `button_types` | array | Available actions for this task state |
| `is_premium` | bool | Whether user is premium (affects display) |
## Notes
- The `Simulator Target Bundle` must match your app's bundle identifier
- `mutable-content: 1` allows the Notification Service Extension to modify content
- `thread-id` groups related notifications together
- The app must register notification categories on launch for actions to work

View File

@@ -0,0 +1,22 @@
{
"Simulator Target Bundle": "com.tt.casera.CaseraDev",
"aps": {
"alert": {
"title": "Task Assigned",
"subtitle": "Test Residence",
"body": "You have been assigned to: Winterize Sprinklers"
},
"sound": "default",
"badge": 1,
"category": "TASK_ACTIONABLE",
"mutable-content": 1,
"thread-id": "task-39"
},
"task_id": 39,
"task_name": "Winterize Sprinklers",
"residence_id": 1,
"residence_name": "Test Residence",
"notification_type": "task_assigned",
"button_types": ["edit", "complete", "cancel", "mark_in_progress"],
"is_premium": true
}

View File

@@ -0,0 +1,22 @@
{
"Simulator Target Bundle": "com.tt.casera.CaseraDev",
"aps": {
"alert": {
"title": "Task Cancelled",
"subtitle": "Test Residence",
"body": "Paint Fence has been cancelled"
},
"sound": "default",
"badge": 1,
"category": "TASK_CANCELLED",
"mutable-content": 1,
"thread-id": "task-101"
},
"task_id": 101,
"task_name": "Paint Fence",
"residence_id": 1,
"residence_name": "Test Residence",
"notification_type": "task_cancelled",
"button_types": ["uncancel", "delete"],
"is_premium": true
}

View File

@@ -0,0 +1,22 @@
{
"Simulator Target Bundle": "com.tt.casera.CaseraDev",
"aps": {
"alert": {
"title": "Task Completed",
"subtitle": "Test Residence",
"body": "Fix Leaky Faucet was completed by John"
},
"sound": "default",
"badge": 1,
"category": "TASK_COMPLETED",
"mutable-content": 1,
"thread-id": "task-202"
},
"task_id": 202,
"task_name": "Fix Leaky Faucet",
"residence_id": 1,
"residence_name": "Test Residence",
"notification_type": "task_completed",
"button_types": [],
"is_premium": true
}

View File

@@ -0,0 +1,22 @@
{
"Simulator Target Bundle": "com.tt.casera.CaseraDev",
"aps": {
"alert": {
"title": "Task Due Soon",
"subtitle": "Test Residence",
"body": "Clean Gutters is due in 5 days"
},
"sound": "default",
"badge": 1,
"category": "TASK_ACTIONABLE",
"mutable-content": 1,
"thread-id": "task-12"
},
"task_id": 12,
"task_name": "Clean Gutters",
"residence_id": 1,
"residence_name": "Test Residence",
"notification_type": "task_due_soon",
"button_types": ["edit", "complete", "cancel", "mark_in_progress"],
"is_premium": true
}

View File

@@ -0,0 +1,20 @@
{
"Simulator Target Bundle": "com.tt.casera.CaseraDev",
"aps": {
"alert": {
"title": "Casera",
"body": "Something in Casera needs your attention"
},
"sound": "default",
"badge": 1,
"category": "TASK_NOTIFICATION_GENERIC",
"mutable-content": 1
},
"task_id": 303,
"task_name": "Secret Task Name",
"residence_id": 1,
"residence_name": "Test Residence",
"notification_type": "task_overdue",
"button_types": ["edit", "complete", "cancel", "mark_in_progress"],
"is_premium": false
}

View File

@@ -0,0 +1,22 @@
{
"Simulator Target Bundle": "com.tt.casera.CaseraDev",
"aps": {
"alert": {
"title": "Task In Progress",
"subtitle": "Test Residence",
"body": "Repair Deck Railing is being worked on"
},
"sound": "default",
"badge": 1,
"category": "TASK_IN_PROGRESS",
"mutable-content": 1,
"thread-id": "task-789"
},
"task_id": 789,
"task_name": "Repair Deck Railing",
"residence_id": 1,
"residence_name": "Test Residence",
"notification_type": "task_assigned",
"button_types": ["edit", "complete", "cancel"],
"is_premium": true
}

View File

@@ -0,0 +1,22 @@
{
"Simulator Target Bundle": "com.tt.casera.CaseraDev",
"aps": {
"alert": {
"title": "Task Overdue",
"subtitle": "Test Residence",
"body": "Replace HVAC Filter is overdue"
},
"sound": "default",
"badge": 1,
"category": "TASK_ACTIONABLE",
"mutable-content": 1,
"thread-id": "task-38"
},
"task_id": 38,
"task_name": "Replace HVAC Filter",
"residence_id": 1,
"residence_name": "Test Residence",
"notification_type": "task_overdue",
"button_types": ["edit", "complete", "cancel", "mark_in_progress"],
"is_premium": true
}