Add push notification support for Android and iOS

- Integrated Firebase Cloud Messaging (FCM) for Android
- Integrated Apple Push Notification Service (APNs) for iOS
- Created shared notification models and API client
- Added device registration and token management
- Added notification permission handling for Android
- Created PushNotificationManager for iOS with AppDelegate
- Added placeholder google-services.json (needs to be replaced with actual config)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-11 22:39:39 -06:00
parent 1a4b5d07bf
commit ec7c01e92d
14 changed files with 919 additions and 1 deletions

View File

@@ -0,0 +1,76 @@
import UIKit
import UserNotifications
import ComposeApp
class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// Set notification delegate
UNUserNotificationCenter.current().delegate = self
// Request notification permission
Task { @MainActor in
await PushNotificationManager.shared.requestNotificationPermission()
}
return true
}
// MARK: - Remote Notifications
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
Task { @MainActor in
PushNotificationManager.shared.didRegisterForRemoteNotifications(withDeviceToken: deviceToken)
}
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
Task { @MainActor in
PushNotificationManager.shared.didFailToRegisterForRemoteNotifications(withError: error)
}
}
// MARK: - UNUserNotificationCenterDelegate
// Called when notification is received while app is in foreground
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
let userInfo = notification.request.content.userInfo
print("📬 Notification received in foreground: \(userInfo)")
Task { @MainActor in
PushNotificationManager.shared.handleNotification(userInfo: userInfo)
}
// Show notification even when app is in foreground
completionHandler([.banner, .sound, .badge])
}
// Called when user taps on notification
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
print("👆 User tapped notification: \(userInfo)")
Task { @MainActor in
PushNotificationManager.shared.handleNotification(userInfo: userInfo)
}
completionHandler()
}
}

View File

@@ -0,0 +1,206 @@
import Foundation
import UserNotifications
import ComposeApp
@MainActor
class PushNotificationManager: NSObject, ObservableObject {
static let shared = PushNotificationManager()
@Published var deviceToken: String?
@Published var notificationPermissionGranted = false
private let notificationApi = NotificationApi()
private 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
private func registerDeviceWithBackend(token: String) async {
guard let authToken = TokenStorage.shared.getToken() else {
print("⚠️ No auth token available, will register device after login")
return
}
let request = DeviceRegistrationRequest(
registrationId: token,
platform: "ios"
)
let result = await notificationApi.registerDevice(token: authToken, request: request)
switch result {
case let success as ApiResultSuccess<DeviceRegistrationResponse>:
print("✅ Device registered successfully: \(success.data)")
case let error as ApiResultError:
print("❌ Failed to register device: \(error.message)")
default:
print("⚠️ Unexpected result type from device registration")
}
}
// 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 let authToken = TokenStorage.shared.getToken(),
let notificationIdInt = Int32(notificationId) else {
return
}
let result = await notificationApi.markNotificationAsRead(
token: authToken,
notificationId: notificationIdInt
)
switch result {
case is ApiResultSuccess<Notification>:
print("✅ Notification marked as read")
case let error as ApiResultError:
print("❌ Failed to mark notification as read: \(error.message)")
default:
break
}
}
// MARK: - Notification Preferences
func updateNotificationPreferences(_ preferences: UpdateNotificationPreferencesRequest) async -> Bool {
guard let authToken = TokenStorage.shared.getToken() else {
print("⚠️ No auth token available")
return false
}
let result = await notificationApi.updateNotificationPreferences(
token: authToken,
request: preferences
)
switch result {
case is ApiResultSuccess<NotificationPreference>:
print("✅ Notification preferences updated")
return true
case let error as ApiResultError:
print("❌ Failed to update preferences: \(error.message)")
return false
default:
return false
}
}
func getNotificationPreferences() async -> NotificationPreference? {
guard let authToken = TokenStorage.shared.getToken() else {
print("⚠️ No auth token available")
return nil
}
let result = await notificationApi.getNotificationPreferences(token: authToken)
switch result {
case let success as ApiResultSuccess<NotificationPreference>:
return success.data
case let error as ApiResultError:
print("❌ Failed to get preferences: \(error.message)")
return nil
default:
return nil
}
}
// MARK: - Badge Management
func clearBadge() {
UIApplication.shared.applicationIconBadgeNumber = 0
}
func setBadge(count: Int) {
UIApplication.shared.applicationIconBadgeNumber = count
}
}

View File

@@ -3,6 +3,7 @@ import ComposeApp
@main
struct iOSApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@State private var deepLinkResetToken: String?
init() {