Files
honeyDueKMP/iosApp/iosApp/PushNotifications/PushNotificationManager.swift
Trey t 70d46da14a Add smart device token caching for push notification registration
Cache device token in UserDefaults and only register with backend when
token changes. Also registers when app returns from background if token
differs from cached value, reducing unnecessary API calls.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 20:18:08 -06:00

268 lines
9.0 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
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)
}
}
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 {
print("Task notification for task ID: \(taskId)")
// TODO: Navigate to task detail
}
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)")
}
}
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)
}
}