260 lines
8.8 KiB
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)")
|
|
}
|
|
}
|
|
}
|