From 2965ec4031be7a69cd449981bc6c5d030fc28400 Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 5 Dec 2025 14:23:25 -0600 Subject: [PATCH] Add actionable push notifications for iOS and Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/androidMain/AndroidManifest.xml | 13 ++ .../kotlin/com/example/casera/MainActivity.kt | 17 +- .../casera/MyFirebaseMessagingService.kt | 89 +++++++- .../casera/NotificationActionReceiver.kt | 171 ++++++++++++++ .../kotlin/com/example/casera/App.kt | 13 +- .../Extensions/Notification+Names.swift | 11 + iosApp/iosApp/MainTabView.swift | 11 + .../PushNotifications/AppDelegate.swift | 25 ++- .../NotificationCategories.swift | 140 ++++++++++++ .../PushNotificationManager.swift | 212 +++++++++++++++++- iosApp/iosApp/Task/AllTasksView.swift | 28 +++ iosApp/push_test_payloads/README.md | 81 +++++++ iosApp/push_test_payloads/task_assigned.apns | 22 ++ iosApp/push_test_payloads/task_cancelled.apns | 22 ++ iosApp/push_test_payloads/task_completed.apns | 22 ++ iosApp/push_test_payloads/task_due_soon.apns | 22 ++ .../task_generic_free_user.apns | 20 ++ .../push_test_payloads/task_in_progress.apns | 22 ++ iosApp/push_test_payloads/task_overdue.apns | 22 ++ 19 files changed, 945 insertions(+), 18 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/example/casera/NotificationActionReceiver.kt create mode 100644 iosApp/iosApp/Extensions/Notification+Names.swift create mode 100644 iosApp/iosApp/PushNotifications/NotificationCategories.swift create mode 100644 iosApp/push_test_payloads/README.md create mode 100644 iosApp/push_test_payloads/task_assigned.apns create mode 100644 iosApp/push_test_payloads/task_cancelled.apns create mode 100644 iosApp/push_test_payloads/task_completed.apns create mode 100644 iosApp/push_test_payloads/task_due_soon.apns create mode 100644 iosApp/push_test_payloads/task_generic_free_user.apns create mode 100644 iosApp/push_test_payloads/task_in_progress.apns create mode 100644 iosApp/push_test_payloads/task_overdue.apns diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 64578fe..c85f996 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -65,6 +65,19 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt index 7329aab..63e52c5 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.launch class MainActivity : ComponentActivity(), SingletonImageLoader.Factory { private var deepLinkResetToken by mutableStateOf(null) + private var navigateToTaskId by mutableStateOf(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?) { diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/MyFirebaseMessagingService.kt b/composeApp/src/androidMain/kotlin/com/example/casera/MyFirebaseMessagingService.kt index e6fdd26..50a8660 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/MyFirebaseMessagingService.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/MyFirebaseMessagingService.kt @@ -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) { - 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 { diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/NotificationActionReceiver.kt b/composeApp/src/androidMain/kotlin/com/example/casera/NotificationActionReceiver.kt new file mode 100644 index 0000000..0e2aef3 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/NotificationActionReceiver.kt @@ -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" + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt index d8498bd..1b95d41 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt @@ -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 diff --git a/iosApp/iosApp/Extensions/Notification+Names.swift b/iosApp/iosApp/Extensions/Notification+Names.swift new file mode 100644 index 0000000..871229d --- /dev/null +++ b/iosApp/iosApp/Extensions/Notification+Names.swift @@ -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") +} diff --git a/iosApp/iosApp/MainTabView.swift b/iosApp/iosApp/MainTabView.swift index 1aca547..1b5c5bb 100644 --- a/iosApp/iosApp/MainTabView.swift +++ b/iosApp/iosApp/MainTabView.swift @@ -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 + } } } diff --git a/iosApp/iosApp/PushNotifications/AppDelegate.swift b/iosApp/iosApp/PushNotifications/AppDelegate.swift index 8cba1ad..6f9dea4 100644 --- a/iosApp/iosApp/PushNotifications/AppDelegate.swift +++ b/iosApp/iosApp/PushNotifications/AppDelegate.swift @@ -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() diff --git a/iosApp/iosApp/PushNotifications/NotificationCategories.swift b/iosApp/iosApp/PushNotifications/NotificationCategories.swift new file mode 100644 index 0000000..632e76b --- /dev/null +++ b/iosApp/iosApp/PushNotifications/NotificationCategories.swift @@ -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 { + 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: [] + ) + } +} diff --git a/iosApp/iosApp/PushNotifications/PushNotificationManager.swift b/iosApp/iosApp/PushNotifications/PushNotificationManager.swift index 1513af1..d168924 100644 --- a/iosApp/iosApp/PushNotifications/PushNotificationManager.swift +++ b/iosApp/iosApp/PushNotifications/PushNotificationManager.swift @@ -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 { + 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 { + 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 { + 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 { + 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 { diff --git a/iosApp/iosApp/Task/AllTasksView.swift b/iosApp/iosApp/Task/AllTasksView.swift index 04cc463..80ab70e 100644 --- a/iosApp/iosApp/Task/AllTasksView.swift +++ b/iosApp/iosApp/Task/AllTasksView.swift @@ -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 diff --git a/iosApp/push_test_payloads/README.md b/iosApp/push_test_payloads/README.md new file mode 100644 index 0000000..7a86b26 --- /dev/null +++ b/iosApp/push_test_payloads/README.md @@ -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 diff --git a/iosApp/push_test_payloads/task_assigned.apns b/iosApp/push_test_payloads/task_assigned.apns new file mode 100644 index 0000000..3a0e792 --- /dev/null +++ b/iosApp/push_test_payloads/task_assigned.apns @@ -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 +} diff --git a/iosApp/push_test_payloads/task_cancelled.apns b/iosApp/push_test_payloads/task_cancelled.apns new file mode 100644 index 0000000..802735e --- /dev/null +++ b/iosApp/push_test_payloads/task_cancelled.apns @@ -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 +} diff --git a/iosApp/push_test_payloads/task_completed.apns b/iosApp/push_test_payloads/task_completed.apns new file mode 100644 index 0000000..15e4ac6 --- /dev/null +++ b/iosApp/push_test_payloads/task_completed.apns @@ -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 +} diff --git a/iosApp/push_test_payloads/task_due_soon.apns b/iosApp/push_test_payloads/task_due_soon.apns new file mode 100644 index 0000000..030cba6 --- /dev/null +++ b/iosApp/push_test_payloads/task_due_soon.apns @@ -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 +} diff --git a/iosApp/push_test_payloads/task_generic_free_user.apns b/iosApp/push_test_payloads/task_generic_free_user.apns new file mode 100644 index 0000000..7362584 --- /dev/null +++ b/iosApp/push_test_payloads/task_generic_free_user.apns @@ -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 +} diff --git a/iosApp/push_test_payloads/task_in_progress.apns b/iosApp/push_test_payloads/task_in_progress.apns new file mode 100644 index 0000000..951be00 --- /dev/null +++ b/iosApp/push_test_payloads/task_in_progress.apns @@ -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 +} diff --git a/iosApp/push_test_payloads/task_overdue.apns b/iosApp/push_test_payloads/task_overdue.apns new file mode 100644 index 0000000..7da985b --- /dev/null +++ b/iosApp/push_test_payloads/task_overdue.apns @@ -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 +}