import Foundation import Combine import UserNotifications import UIKit class NotificationService: ObservableObject { static let shared = NotificationService() @Published var isAuthorized = false @Published var pendingCount = 0 private let center = UNUserNotificationCenter.current() private init() { checkAuthorizationStatus() } // MARK: - Authorization func checkAuthorizationStatus() { center.getNotificationSettings { [weak self] settings in DispatchQueue.main.async { self?.isAuthorized = settings.authorizationStatus == .authorized } } } func requestAuthorization() async -> Bool { do { let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound]) await MainActor.run { self.isAuthorized = granted } if granted { scheduleMonthlyPerformanceSummary() } return granted } catch { print("Notification authorization error: \(error)") return false } } // MARK: - Schedule Notifications func scheduleReminder(for source: InvestmentSource) { guard let nextDate = source.nextReminderDate else { return } guard source.frequency != .never else { return } // Remove existing notification for this source cancelReminder(for: source) // Get notification time from settings let settings = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext) let notificationTime = settings.defaultNotificationTime ?? defaultNotificationTime() // Combine date and time let calendar = Calendar.current var components = calendar.dateComponents([.year, .month, .day], from: nextDate) let timeComponents = calendar.dateComponents([.hour, .minute], from: notificationTime) components.hour = timeComponents.hour components.minute = timeComponents.minute guard let triggerDate = calendar.date(from: components) else { return } // Create notification content let content = UNMutableNotificationContent() content.title = "Investment Update Reminder" content.body = "Time to update \(source.name). Tap to add a new snapshot." content.sound = .default content.badge = NSNumber(value: pendingCount + 1) content.userInfo = [ "sourceId": source.id.uuidString, "sourceName": source.name ] // Create trigger let triggerComponents = calendar.dateComponents( [.year, .month, .day, .hour, .minute], from: triggerDate ) let trigger = UNCalendarNotificationTrigger(dateMatching: triggerComponents, repeats: false) // Create request let request = UNNotificationRequest( identifier: notificationIdentifier(for: source), content: content, trigger: trigger ) // Schedule center.add(request) { error in if let error = error { print("Failed to schedule notification: \(error)") } else { print("Scheduled reminder for \(source.name) on \(triggerDate)") FirebaseService.shared.logNotificationScheduled(frequency: source.notificationFrequency) } } } func scheduleAllReminders(for sources: [InvestmentSource]) { for source in sources { scheduleReminder(for: source) } } // MARK: - Cancel Notifications func cancelReminder(for source: InvestmentSource) { center.removePendingNotificationRequests( withIdentifiers: [notificationIdentifier(for: source)] ) } func cancelAllReminders() { center.removeAllPendingNotificationRequests() } // MARK: - Badge Management func updateBadgeCount() { let repository = InvestmentSourceRepository() let needsUpdate = repository.fetchSourcesNeedingUpdate() pendingCount = needsUpdate.count center.setBadgeCount(pendingCount) { _ in } } func clearBadge() { pendingCount = 0 center.setBadgeCount(0) { _ in } } // MARK: - Pending Notifications func getPendingNotifications() async -> [UNNotificationRequest] { await center.pendingNotificationRequests() } // MARK: - Helpers private func notificationIdentifier(for source: InvestmentSource) -> String { "investment_reminder_\(source.id.uuidString)" } private func defaultNotificationTime() -> Date { var components = DateComponents() components.hour = 9 components.minute = 0 return Calendar.current.date(from: components) ?? Date() } } // MARK: - Deep Link Handler extension NotificationService { func handleNotificationResponse(_ response: UNNotificationResponse) { let userInfo = response.notification.request.content.userInfo // Handle batch update deep link from monthly check-in if let action = userInfo["action"] as? String, action == "batchUpdate" { NotificationCenter.default.post(name: .openBatchUpdate, object: nil) return } // Handle openDashboard deep link from monthly summary notification if let action = userInfo["action"] as? String, action == "openDashboard" { NotificationCenter.default.post(name: .openDashboard, object: nil) return } guard let sourceIdString = userInfo["sourceId"] as? String, let sourceId = UUID(uuidString: sourceIdString) else { return } // Post notification for deep linking NotificationCenter.default.post( name: .openSourceDetail, object: nil, userInfo: ["sourceId": sourceId] ) } } // MARK: - Notification Names extension Notification.Name { static let openSourceDetail = Notification.Name("openSourceDetail") static let didResetData = Notification.Name("didResetData") static let openBatchUpdate = Notification.Name("openBatchUpdate") static let openDashboard = Notification.Name("openDashboard") static let openQuickUpdate = Notification.Name("openQuickUpdate") } // MARK: - Re-engagement Notifications extension NotificationService { /// Schedules a re-engagement notification 7 days from now. /// Call this every time the app becomes active to reset the timer. func scheduleReEngagementNotification() { guard isAuthorized else { return } center.removePendingNotificationRequests(withIdentifiers: ["re_engagement"]) let content = UNMutableNotificationContent() content.title = String(localized: "reengagement_title") content.body = String(localized: "reengagement_body") content.sound = .default let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 7 * 24 * 3600, repeats: false) let request = UNNotificationRequest(identifier: "re_engagement", content: content, trigger: trigger) center.add(request) { error in if let error = error { print("Re-engagement notification error: \(error)") } } } /// Schedules a non-repeating monthly check-in notification for the 1st of the next month /// that doesn't have a completed check-in. Safe to call on every app activation. func scheduleMonthlyCheckIn() { guard isAuthorized else { return } let identifier = "monthly_checkin" center.removePendingNotificationRequests(withIdentifiers: [identifier]) let calendar = Calendar.current let now = Date() guard let thisMonthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: now)) else { return } // Walk forward from next month to find the first month whose check-in is not done for offset in 1...13 { guard let targetStart = calendar.date(byAdding: .month, value: offset, to: thisMonthStart) else { break } let isDone = MonthlyCheckInStore.completionDate(for: targetStart.adding(days: 1)) != nil if isDone { continue } var components = calendar.dateComponents([.year, .month], from: targetStart) components.day = 1 components.hour = 9 components.minute = 0 let content = UNMutableNotificationContent() content.title = String(localized: "monthly_checkin_notification_title") content.body = String(localized: "monthly_checkin_notification_body") content.sound = .default content.userInfo = ["action": "batchUpdate"] let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) center.add(request) { error in if let error = error { print("Monthly check-in notification error: \(error)") } } return } } /// Schedules a monthly portfolio summary notification on the 5th of each month at 9am. func scheduleMonthlyPerformanceSummary() { guard isAuthorized else { return } let identifier = "monthly_summary" center.getPendingNotificationRequests { [weak self] requests in guard let self, !requests.contains(where: { $0.identifier == identifier }) else { return } let content = UNMutableNotificationContent() content.title = String(localized: "monthly_summary_notification_title") content.body = String(localized: "monthly_summary_notification_body") content.sound = .default content.userInfo = ["action": "openDashboard"] var components = DateComponents() components.day = 5 components.hour = 9 components.minute = 0 let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true) let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) self.center.add(request) { error in if let error = error { print("Monthly summary notification error: \(error)") } } } } /// Fires a one-time local notification celebrating goal achievement. func scheduleGoalAchievedNotification(goalName: String) { guard isAuthorized else { return } let content = UNMutableNotificationContent() content.title = String(localized: "goal_achieved_notification_title") content.body = String(format: String(localized: "goal_achieved_notification_body"), goalName) content.sound = .default let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) let id = "goal_achieved_\(UUID().uuidString)" let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger) center.add(request) { error in if let error = error { print("Goal achieved notification error: \(error)") } } } /// Fires a notification when the portfolio crosses a round milestone for the first time. func checkAndScheduleMilestoneNotification(portfolioValue: Decimal) { guard isAuthorized else { return } let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000, 100000, 250000, 500000, 1_000_000] let notifiedKey = "lastNotifiedMilestone" let lastNotified = UserDefaults.standard.double(forKey: notifiedKey) let current = NSDecimalNumber(decimal: portfolioValue).doubleValue for milestone in milestones.reversed() { let ms = NSDecimalNumber(decimal: milestone).doubleValue if current >= ms { if ms > lastNotified { UserDefaults.standard.set(ms, forKey: notifiedKey) let milestoneStr = CurrencyFormatter.format(milestone, style: .currency, maximumFractionDigits: 0) let content = UNMutableNotificationContent() content.title = String(localized: "notification_milestone_title") content.body = String(format: String(localized: "notification_milestone_body"), milestoneStr) content.sound = .default content.userInfo = ["action": "openDashboard"] let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) let request = UNNotificationRequest( identifier: "portfolio_milestone_\(Int(ms))", content: content, trigger: trigger ) center.add(request) { _ in } } break } } } } // MARK: - Background Refresh extension NotificationService { func performBackgroundRefresh() { updateBadgeCount() // Reschedule any missed notifications let repository = InvestmentSourceRepository() let sources = repository.fetchActiveSources() scheduleAllReminders(for: sources) } }