Files
honeyDueKMP/iosApp/iosApp/PushNotifications/PushNotificationManager.swift
Trey t cbe073aa21 Add push notification deep linking and sharing subscription checks
- Add deep link navigation from push notifications to specific task column on kanban board
- Fix subscription check in push notification handler to allow navigation when limitations disabled
- Add pendingNavigationTaskId to handle notifications when app isn't ready
- Add ScrollViewReader to AllTasksView for programmatic scrolling to task column
- Add canShareResidence() and canShareContractor() subscription checks (iOS & Android)
- Add test APNS file for simulator push notification testing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 23:17:28 -06:00

494 lines
18 KiB
Swift

import Foundation
import UIKit
import UserNotifications
import ComposeApp
class PushNotificationManager: NSObject, ObservableObject {
@MainActor static let shared = PushNotificationManager()
@Published var deviceToken: String?
@Published var notificationPermissionGranted = false
/// Pending task ID to navigate to (set when notification is tapped before app is ready)
@Published var pendingNavigationTaskId: Int?
private let registeredTokenKey = "com.casera.registeredDeviceToken"
/// The last token that was successfully registered with the backend
private var lastRegisteredToken: String? {
get { UserDefaults.standard.string(forKey: registeredTokenKey) }
set { UserDefaults.standard.set(newValue, forKey: registeredTokenKey) }
}
override init() {
super.init()
}
// MARK: - Permission Request
func requestNotificationPermission() async -> Bool {
let center = UNUserNotificationCenter.current()
do {
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
notificationPermissionGranted = granted
if granted {
print("✅ Notification permission granted")
// Register for remote notifications on main thread
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
} else {
print("❌ Notification permission denied")
}
return granted
} catch {
print("❌ Error requesting notification permission: \(error)")
return false
}
}
// MARK: - Token Management
func didRegisterForRemoteNotifications(withDeviceToken deviceToken: Data) {
let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
self.deviceToken = tokenString
print("📱 APNs device token: \(tokenString)")
// Register with backend
Task {
await registerDeviceWithBackend(token: tokenString)
}
}
func didFailToRegisterForRemoteNotifications(withError error: Error) {
print("❌ Failed to register for remote notifications: \(error)")
}
// MARK: - Backend Registration
/// Call this after login to register any pending device token
func registerDeviceAfterLogin() {
guard let token = deviceToken else {
print("⚠️ No device token available for registration")
return
}
Task {
await registerDeviceWithBackend(token: token, force: false)
}
}
/// Call this when app returns from background to check and register if needed
func checkAndRegisterDeviceIfNeeded() {
guard let token = deviceToken else {
print("⚠️ No device token available for registration check")
return
}
// Skip if token hasn't changed
if token == lastRegisteredToken {
print("📱 Device token unchanged, skipping registration")
return
}
Task {
await registerDeviceWithBackend(token: token, force: false)
}
}
private func registerDeviceWithBackend(token: String, force: Bool = false) async {
guard TokenStorage.shared.getToken() != nil else {
print("⚠️ No auth token available, will register device after login")
return
}
// Skip if token hasn't changed (unless forced)
if !force && token == lastRegisteredToken {
print("📱 Device token unchanged, skipping registration")
return
}
// Get unique device identifier
let deviceId = await MainActor.run {
UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
}
let deviceName = await MainActor.run {
UIDevice.current.name
}
let request = DeviceRegistrationRequest(
deviceId: deviceId,
registrationId: token,
platform: "ios",
name: deviceName
)
do {
let result = try await APILayer.shared.registerDevice(request: request)
if let success = result as? ApiResultSuccess<DeviceRegistrationResponse> {
print("✅ Device registered successfully: \(success.data)")
// Cache the token on successful registration
await MainActor.run {
self.lastRegisteredToken = token
}
} else if let error = result as? ApiResultError {
print("❌ Failed to register device: \(error.message)")
} else {
print("⚠️ Unexpected result type from device registration")
}
} catch {
print("❌ Error registering device: \(error.localizedDescription)")
}
}
// MARK: - Handle Notifications
func handleNotification(userInfo: [AnyHashable: Any]) {
print("📬 Received notification: \(userInfo)")
// Extract notification data
if let notificationId = userInfo["notification_id"] as? String {
print("Notification ID: \(notificationId)")
// Mark as read when user taps notification
Task {
await markNotificationAsRead(notificationId: notificationId)
}
}
if let type = userInfo["type"] as? String {
print("Notification type: \(type)")
handleNotificationType(type: type, userInfo: userInfo)
}
}
/// 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
// Allow navigation if: Pro user, OR limitations are disabled, OR subscription hasn't loaded yet
let subscription = SubscriptionCacheWrapper.shared.currentSubscription
let isPremium = SubscriptionCacheWrapper.shared.currentTier == "pro"
let limitationsEnabled = subscription?.limitationsEnabled ?? false // Default to false (allow) if not loaded
let canNavigateToTask = isPremium || !limitationsEnabled
print("📬 Push nav check: isPremium=\(isPremium), limitationsEnabled=\(limitationsEnabled), canNavigate=\(canNavigateToTask), subscription=\(subscription != nil ? "loaded" : "nil")")
if canNavigateToTask {
// 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 with limitations enabled - 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
// Allow actions if: Pro user, OR limitations are disabled, OR subscription hasn't loaded yet
let subscription = SubscriptionCacheWrapper.shared.currentSubscription
let isPremium = SubscriptionCacheWrapper.shared.currentTier == "pro"
let limitationsEnabled = subscription?.limitationsEnabled ?? false // Default to false (allow) if not loaded
let canPerformAction = isPremium || !limitationsEnabled
guard canPerformAction else {
// Free user with limitations enabled 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 taskIdStr = userInfo["task_id"] as? String, let taskId = Int(taskIdStr) {
print("Task notification for task ID: \(taskId)")
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":
if let residenceId = userInfo["residence_id"] as? String {
print("Residence shared notification for residence ID: \(residenceId)")
// TODO: Navigate to residence detail
}
case "warranty_expiring":
if let documentId = userInfo["document_id"] as? String {
print("Warranty expiring notification for document ID: \(documentId)")
// TODO: Navigate to document detail
}
default:
print("Unknown notification type: \(type)")
}
}
// 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)")
// Store pending navigation in case MainTabView isn't ready yet
pendingNavigationTaskId = taskId
NotificationCenter.default.post(
name: .navigateToTask,
object: nil,
userInfo: ["taskId": taskId]
)
}
/// Clear pending navigation after it has been handled
func clearPendingNavigation() {
pendingNavigationTaskId = nil
}
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 {
return
}
do {
let result = try await APILayer.shared.markNotificationAsRead(notificationId: notificationIdInt)
if result is ApiResultSuccess<ComposeApp.Notification> {
print("✅ Notification marked as read")
} else if let error = result as? ApiResultError {
print("❌ Failed to mark notification as read: \(error.message)")
}
} catch {
print("❌ Error marking notification as read: \(error.localizedDescription)")
}
}
// MARK: - Notification Preferences
func updateNotificationPreferences(_ preferences: UpdateNotificationPreferencesRequest) async -> Bool {
guard TokenStorage.shared.getToken() != nil else {
print("⚠️ No auth token available")
return false
}
do {
let result = try await APILayer.shared.updateNotificationPreferences(request: preferences)
if result is ApiResultSuccess<NotificationPreference> {
print("✅ Notification preferences updated")
return true
} else if let error = result as? ApiResultError {
print("❌ Failed to update preferences: \(error.message)")
return false
}
return false
} catch {
print("❌ Error updating notification preferences: \(error.localizedDescription)")
return false
}
}
func getNotificationPreferences() async -> NotificationPreference? {
guard TokenStorage.shared.getToken() != nil else {
print("⚠️ No auth token available")
return nil
}
do {
let result = try await APILayer.shared.getNotificationPreferences()
if let success = result as? ApiResultSuccess<NotificationPreference> {
return success.data
} else if let error = result as? ApiResultError {
print("❌ Failed to get preferences: \(error.message)")
return nil
}
return nil
} catch {
print("❌ Error getting notification preferences: \(error.localizedDescription)")
return nil
}
}
// MARK: - Badge Management
func clearBadge() {
UNUserNotificationCenter.current().setBadgeCount(0)
}
func setBadge(count: Int) {
UNUserNotificationCenter.current().setBadgeCount(count)
}
}