Andromida/Andromida/App/Services/ReminderScheduler.swift

260 lines
8.8 KiB
Swift

import Foundation
import UserNotifications
import Observation
/// Reminder time slots based on ritual TimeOfDay values.
/// Groups similar times to avoid excessive notifications.
enum ReminderSlot: String, CaseIterable {
case morning // 7:00 AM - covers morning rituals
case midday // 12:00 PM - covers midday and afternoon rituals
case evening // 6:00 PM - covers evening and night rituals
var hour: Int {
switch self {
case .morning: return 7
case .midday: return 12
case .evening: return 18
}
}
var notificationId: String {
"rituals.reminder.\(rawValue)"
}
var title: String {
switch self {
case .morning: return String(localized: "Good morning")
case .midday: return String(localized: "Midday check-in")
case .evening: return String(localized: "Evening rituals")
}
}
/// Maps TimeOfDay values to their corresponding reminder slot
static func slot(for timeOfDay: TimeOfDay) -> ReminderSlot? {
switch timeOfDay {
case .morning:
return .morning
case .midday, .afternoon:
return .midday
case .evening, .night:
return .evening
case .anytime:
return nil // Anytime rituals don't trigger specific reminders
}
}
}
/// Schedules smart reminders based on active rituals' time-of-day settings.
/// Only schedules reminders for time slots that have active rituals.
@MainActor
@Observable
final class ReminderScheduler: NSObject, UNUserNotificationCenterDelegate {
private(set) var authorizationStatus: UNAuthorizationStatus = .notDetermined
private(set) var scheduledSlots: Set<ReminderSlot> = []
/// Whether reminders are enabled by the user
var remindersEnabled: Bool {
get { UserDefaults.standard.bool(forKey: "remindersEnabled") }
set {
UserDefaults.standard.set(newValue, forKey: "remindersEnabled")
if newValue {
Task { await requestAuthorizationAndSchedule() }
} else {
cancelAllReminders()
}
}
}
/// The rituals to base reminders on - set this from RitualStore
private var activeRituals: [Ritual] = []
override init() {
super.init()
UNUserNotificationCenter.current().delegate = self
Task { await refreshAuthorizationStatus() }
}
// MARK: - UNUserNotificationCenterDelegate
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
print("🔔 Notification will present in foreground: \(notification.request.identifier)")
// Show the notification even when the app is in the foreground
completionHandler([.banner, .list, .sound, .badge])
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
print("🔔 Notification received/tapped: \(response.notification.request.identifier)")
// Clear badge when user interacts with notification
clearBadge()
completionHandler()
}
// MARK: - Public API
/// Updates the scheduled reminders based on the current active rituals.
/// Call this whenever rituals are created, updated, or deleted.
func updateReminders(for rituals: [Ritual]) async {
// Filter to rituals that have an active arc (currently in progress)
activeRituals = rituals.filter { $0.hasActiveArc }
guard remindersEnabled else {
cancelAllReminders()
return
}
await scheduleRemindersForActiveSlots()
}
/// Requests notification authorization from the user.
func requestAuthorization() async -> Bool {
do {
let granted = try await UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .sound, .badge]
)
await refreshAuthorizationStatus()
return granted
} catch {
print("Notification authorization error: \(error)")
return false
}
}
/// Cancels all scheduled ritual reminders.
func cancelAllReminders() {
let ids = ReminderSlot.allCases.map { $0.notificationId }
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids)
scheduledSlots = []
clearBadge()
}
/// Clears the app badge count.
func clearBadge() {
UNUserNotificationCenter.current().setBadgeCount(0) { _ in }
}
/// Schedules a test notification to appear in 5 seconds.
func scheduleTestNotification() {
print("🔔 Attempting to schedule test notification...")
let content = UNMutableNotificationContent()
content.title = String(localized: "Test Notification")
content.body = String(localized: "This is a test notification to verify delivery.")
content.sound = .default
content.badge = 1
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
let request = UNNotificationRequest(
identifier: "rituals.test.notification",
content: content,
trigger: trigger
)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("❌ Failed to schedule test notification: \(error)")
} else {
print("✅ Test notification scheduled successfully! It should appear in 5 seconds.")
}
}
}
/// Refreshes authorization status and reschedules if enabled.
func refreshStatus() async {
await refreshAuthorizationStatus()
if remindersEnabled {
await scheduleRemindersForActiveSlots()
}
}
// MARK: - Private
private func refreshAuthorizationStatus() async {
let settings = await UNUserNotificationCenter.current().notificationSettings()
authorizationStatus = settings.authorizationStatus
}
private func requestAuthorizationAndSchedule() async {
let authorized = await requestAuthorization()
if authorized {
await scheduleRemindersForActiveSlots()
}
}
private func scheduleRemindersForActiveSlots() async {
// Determine which slots have active rituals
var neededSlots = Set<ReminderSlot>()
for ritual in activeRituals {
if let slot = ReminderSlot.slot(for: ritual.timeOfDay) {
neededSlots.insert(slot)
}
}
// Cancel slots that are no longer needed
let slotsToCancel = scheduledSlots.subtracting(neededSlots)
for slot in slotsToCancel {
UNUserNotificationCenter.current().removePendingNotificationRequests(
withIdentifiers: [slot.notificationId]
)
}
// Schedule slots that are needed but not yet scheduled
let slotsToSchedule = neededSlots.subtracting(scheduledSlots)
for slot in slotsToSchedule {
await scheduleReminder(for: slot)
}
scheduledSlots = neededSlots
}
private func scheduleReminder(for slot: ReminderSlot) async {
// Count rituals for this slot
let ritualsForSlot = activeRituals.filter { ritual in
ReminderSlot.slot(for: ritual.timeOfDay) == slot
}
let content = UNMutableNotificationContent()
content.title = slot.title
if ritualsForSlot.count == 1, let ritual = ritualsForSlot.first {
content.body = String(localized: "Time for \(ritual.title)")
} else {
content.body = String(localized: "You have \(ritualsForSlot.count) rituals to complete")
}
content.sound = .default
content.badge = 1
// Create daily trigger at the slot's hour
var triggerComponents = DateComponents()
triggerComponents.hour = slot.hour
triggerComponents.minute = 0
let trigger = UNCalendarNotificationTrigger(
dateMatching: triggerComponents,
repeats: true
)
let request = UNNotificationRequest(
identifier: slot.notificationId,
content: content,
trigger: trigger
)
do {
try await UNUserNotificationCenter.current().add(request)
} catch {
print("Failed to schedule \(slot.rawValue) reminder: \(error)")
}
}
}