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:
@@ -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()
|
||||
|
||||
140
iosApp/iosApp/PushNotifications/NotificationCategories.swift
Normal file
140
iosApp/iosApp/PushNotifications/NotificationCategories.swift
Normal 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: []
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user