import Foundation import Combine import CoreData 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") static let openAddFirstSource = Notification.Name("openAddFirstSource") } // 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. /// /// Instead of a fixed generic message, the body is built from the user's real /// KPIs (goal ETA, YoY, monthly gain, milestone proximity, update streak) to /// spark curiosity. Among the hooks that currently apply we rotate one per call /// so the copy stays fresh instead of repeating the same line every week. func scheduleReEngagementNotification() { guard isAuthorized else { return } center.removePendingNotificationRequests(withIdentifiers: ["re_engagement"]) let content = UNMutableNotificationContent() let hooks = buildReEngagementHooks() if let hook = hooks.isEmpty ? nil : hooks[nextReEngagementRotationIndex(count: hooks.count)] { content.title = hook.title content.body = hook.body } else { // No data yet (brand-new / empty portfolio) → generic fallback. content.title = String(localized: "reengagement_title") content.body = String(localized: "reengagement_body") } content.sound = .default content.userInfo = ["action": "openDashboard"] 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)") } } } /// Advances and returns the rotation index over the eligible hooks so a different /// one surfaces each time we (re)schedule. private func nextReEngagementRotationIndex(count: Int) -> Int { guard count > 0 else { return 0 } let key = "reengagementRotationIndex" let stored = UserDefaults.standard.integer(forKey: key) UserDefaults.standard.set(stored &+ 1, forKey: key) return ((stored % count) + count) % count } /// 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 } } /// Streak protection: reminds the user on the 25th of the CURRENT month if this /// month's check-in is still pending — loss aversion beats the day-1 nudge alone. /// Safe to call on every app activation (no-op if already done or date passed). func scheduleStreakProtectionReminder() { guard isAuthorized else { return } let identifier = "streak_protection" 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 } // Already checked in this month → nothing to protect guard MonthlyCheckInStore.completionDate(for: thisMonthStart.adding(days: 1)) == nil else { return } var components = calendar.dateComponents([.year, .month], from: thisMonthStart) components.day = 25 components.hour = 18 components.minute = 0 guard let fireDate = calendar.date(from: components), fireDate > now else { return } let content = UNMutableNotificationContent() content.title = String(localized: "streak_protection_notification_title") content.body = String(localized: "streak_protection_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("Streak protection notification error: \(error)") } } } /// 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: - Re-engagement KPI copy private extension NotificationService { struct ReEngagementHook { let title: String let body: String } static let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000, 100000, 250000, 500000, 1_000_000] static let monthYearFormatter: DateFormatter = { let f = DateFormatter() f.setLocalizedDateFormatFromTemplate("MMMM yyyy") return f }() static let percentFormatter: NumberFormatter = { let f = NumberFormatter() f.numberStyle = .decimal f.minimumFractionDigits = 0 f.maximumFractionDigits = 1 return f }() /// Builds the list of KPI-driven hooks that currently apply. A neutral /// curiosity hook is always appended (when the user has data) so the message /// never has to fall back to the bland generic copy and never states a loss. func buildReEngagementHooks() -> [ReEngagementHook] { let ctx = CoreDataStack.shared.viewContext let sourceReq: NSFetchRequest = InvestmentSource.fetchRequest() sourceReq.predicate = NSPredicate(format: "isActive == YES") let sources = (try? ctx.fetch(sourceReq)) ?? [] guard !sources.isEmpty else { return [] } let summary = CalculationService.shared.calculatePortfolioSummary(from: sources, snapshots: []) let evolution = monthlyEvolution(from: sources) var hooks: [ReEngagementHook] = [] // 1. Goal ETA — "at this pace you'll reach '' by " if let goalHook = goalEtaHook(ctx: ctx, sources: sources) { hooks.append(goalHook) } // 2. Year-over-year growth if summary.yearChangePercentage >= 5, let pct = Self.percentFormatter.string(from: NSNumber(value: summary.yearChangePercentage)) { hooks.append(ReEngagementHook( title: String(localized: "reengagement_yoy_title"), body: String(format: String(localized: "reengagement_yoy_body"), "\(pct)%"))) } // 3. Positive monthly change if summary.monthChange > 0 { let amount = CurrencyFormatter.format(summary.monthChange, style: .currency, maximumFractionDigits: 0) hooks.append(ReEngagementHook( title: String(localized: "reengagement_month_title"), body: String(format: String(localized: "reengagement_month_body"), amount))) } // 4. Close to the next round milestone (within 10% below it) if let msHook = milestoneProximityHook(totalValue: summary.totalValue) { hooks.append(msHook) } // 5. Update streak of 3+ consecutive months if let streak = monthlyStreak(evolution: evolution), streak >= 3 { hooks.append(ReEngagementHook( title: String(localized: "reengagement_streak_title"), body: String(format: String(localized: "reengagement_streak_body"), streak))) } // 6. Neutral curiosity — always available, never states a loss. hooks.append(ReEngagementHook( title: String(localized: "reengagement_neutral_title"), body: String(localized: "reengagement_neutral_body"))) return hooks } /// Portfolio total at the end of each month that has any snapshot, ascending. func monthlyEvolution(from sources: [InvestmentSource]) -> [(date: Date, value: Decimal)] { let cal = Calendar.current var monthStarts = Set() for source in sources { for snap in source.sortedSnapshotsByDateAscending { if let m = cal.date(from: cal.dateComponents([.year, .month], from: snap.date)) { monthStarts.insert(m) } } } return monthStarts.sorted().map { monthStart in let monthEnd = cal.date(byAdding: DateComponents(month: 1, day: -1), to: monthStart) ?? monthStart var total = Decimal.zero for source in sources { if let snap = source.sortedSnapshotsByDateAscending.last(where: { $0.date <= monthEnd }) { total += snap.decimalValue } } return (monthStart, total) } } /// Projects the first active goal reachable on the current 6-month trend and /// returns a hook naming its ETA. Mirrors GoalsViewModel.estimateCompletionDate. func goalEtaHook(ctx: NSManagedObjectContext, sources: [InvestmentSource]) -> ReEngagementHook? { let req: NSFetchRequest = Goal.fetchRequest() req.predicate = NSPredicate(format: "isActive == YES") let goals = (try? ctx.fetch(req)) ?? [] guard !goals.isEmpty else { return nil } let cal = Calendar.current for goal in goals { let goalSources = goal.relevantSources(from: sources) let evolution = monthlyEvolution(from: goalSources) guard evolution.count >= 3, let first = evolution.suffix(6).first, let last = evolution.suffix(6).last else { continue } let monthsBetween = max(1, first.date.monthsBetween(last.date)) let delta = last.value - first.value guard delta > 0 else { continue } let monthlyGain = delta / Decimal(monthsBetween) guard monthlyGain > 0 else { continue } let remaining = goal.targetDecimal - last.value guard remaining > 0 else { continue } // already reached → skip let monthsToGo = Int(ceil(NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue)) guard monthsToGo > 0, monthsToGo <= 600, let eta = cal.date(byAdding: .month, value: monthsToGo, to: last.date) else { continue } let etaStr = Self.monthYearFormatter.string(from: eta) return ReEngagementHook( title: String(localized: "reengagement_goal_title"), body: String(format: String(localized: "reengagement_goal_body"), goal.name, etaStr)) } return nil } /// Hook when the portfolio is within 10% below the next round milestone. func milestoneProximityHook(totalValue: Decimal) -> ReEngagementHook? { let current = NSDecimalNumber(decimal: totalValue).doubleValue guard current > 0 else { return nil } for milestone in Self.milestones { let target = NSDecimalNumber(decimal: milestone).doubleValue if current < target { guard current >= target * 0.9 else { return nil } // next one up is too far let msStr = CurrencyFormatter.format(milestone, style: .currency, maximumFractionDigits: 0) return ReEngagementHook( title: String(localized: "reengagement_milestone_title"), body: String(format: String(localized: "reengagement_milestone_body"), msStr)) } } return nil } /// Number of consecutive months (ending at the most recent one with data) that /// have a snapshot. `evolution` already holds one entry per month. func monthlyStreak(evolution: [(date: Date, value: Decimal)]) -> Int? { guard !evolution.isEmpty else { return nil } let cal = Calendar.current let months = Set(evolution.map { $0.date }) guard var cursor = months.max() else { return nil } var count = 0 while months.contains(cursor) { count += 1 guard let prev = cal.date(byAdding: .month, value: -1, to: cursor) else { break } cursor = prev } return count } } // MARK: - Background Refresh extension NotificationService { func performBackgroundRefresh() { updateBadgeCount() // Reschedule any missed notifications let repository = InvestmentSourceRepository() let sources = repository.fetchActiveSources() scheduleAllReminders(for: sources) } }