Add actionable push notifications for iOS and Android

iOS:
- Add notification categories with action buttons (complete, view, cancel, etc.)
- Handle notification actions in AppDelegate with API calls
- Add navigation to specific task from notification tap
- Register UNNotificationCategory for each task state

Android:
- Add NotificationActionReceiver BroadcastReceiver for handling actions
- Update MyFirebaseMessagingService to show action buttons
- Add deep link handling in MainActivity for task navigation
- Register receiver in AndroidManifest.xml

Shared:
- Add navigateToTaskId parameter to App for cross-platform navigation
- Add notification observers in MainTabView/AllTasksView for refresh

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-05 14:23:25 -06:00
parent 771f5d2bd3
commit 2965ec4031
19 changed files with 945 additions and 18 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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