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