Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
e434c52e78
commit
bc8a0d1b53
@ -357,10 +357,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Advanced Insights" : {
|
|
||||||
"comment" : "Subtitle for a feature in the \"Rituals Pro\" upgrade screen, related to \"Advanced Insights\".",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"After 9pm" : {
|
"After 9pm" : {
|
||||||
"comment" : "Time range description for the \"Night\" time of day.",
|
"comment" : "Time range description for the \"Night\" time of day.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -623,10 +619,6 @@
|
|||||||
"comment" : "Notes for a ritual preset focused on breaking up a day with midday activity.",
|
"comment" : "Notes for a ritual preset focused on breaking up a day with midday activity.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Coming Soon" : {
|
|
||||||
"comment" : "A label indicating that a feature is coming soon.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Coming up later:" : {
|
"Coming up later:" : {
|
||||||
"comment" : "A label for a section of a view that lists rituals scheduled for times after the current time of day.",
|
"comment" : "A label for a section of a view that lists rituals scheduled for times after the current time of day.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -717,10 +709,6 @@
|
|||||||
"comment" : "A description below the buttons that allow users to create a custom ritual or view the preset library.",
|
"comment" : "A description below the buttons that allow users to create a custom ritual or view the preset library.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Create as many arcs as you need" : {
|
|
||||||
"comment" : "Subtitle for a feature row in the \"Rituals Pro\" upgrade view, describing the ability to create as many rituals as needed.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Create Custom Ritual" : {
|
"Create Custom Ritual" : {
|
||||||
"comment" : "A button label that triggers the creation of a custom ritual.",
|
"comment" : "A button label that triggers the creation of a custom ritual.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -920,10 +908,6 @@
|
|||||||
"comment" : "Title of a ritual preset focused on setting up for focused work.",
|
"comment" : "Title of a ritual preset focused on setting up for focused work.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Deeper analytics on your progress" : {
|
|
||||||
"comment" : "Subtitle for a feature row in the \"Pro\" upgrade view, describing advanced insights.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Define your top 3 priorities" : {
|
"Define your top 3 priorities" : {
|
||||||
"comment" : "Title of a habit within a ritual preset focused on productivity.",
|
"comment" : "Title of a habit within a ritual preset focused on productivity.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -1131,9 +1115,6 @@
|
|||||||
"Excellent consistency! You're building strong habits." : {
|
"Excellent consistency! You're building strong habits." : {
|
||||||
"comment" : "Tip provided in the \"Completion\" insight card when the user has a high completion rate.",
|
"comment" : "Tip provided in the \"Completion\" insight card when the user has a high completion rate.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
|
||||||
"Faster iCloud synchronization" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Feel a soft response on check-in" : {
|
"Feel a soft response on check-in" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@ -1455,9 +1436,6 @@
|
|||||||
"Have a real conversation" : {
|
"Have a real conversation" : {
|
||||||
"comment" : "Habit title for a ritual preset that encourages having a real conversation with someone.",
|
"comment" : "Habit title for a ritual preset that encourages having a real conversation with someone.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
|
||||||
"Help us build more features" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Herbal tea" : {
|
"Herbal tea" : {
|
||||||
"comment" : "Habit title for a ritual preset that includes herbal tea as a habit.",
|
"comment" : "Habit title for a ritual preset that includes herbal tea as a habit.",
|
||||||
@ -2027,13 +2005,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Priority Sync" : {
|
|
||||||
|
|
||||||
},
|
|
||||||
"Pro features are in development" : {
|
|
||||||
"comment" : "A description below the button that says that the Pro features are in development.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Process your day" : {
|
"Process your day" : {
|
||||||
"comment" : "Theme for a ritual preset that encourages reflection and processing of the day's events.",
|
"comment" : "Theme for a ritual preset that encourages reflection and processing of the day's events.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2298,14 +2269,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Rituals Pro" : {
|
|
||||||
"comment" : "The title of the \"Rituals Pro\" view.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Rituals Pro gives you everything you need to build lasting habits." : {
|
|
||||||
"comment" : "A description of what \"Rituals Pro\" offers.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Rituals that have ended will appear here. You can restart them anytime." : {
|
"Rituals that have ended will appear here. You can restart them anytime." : {
|
||||||
"comment" : "A description below the label \"No Past Rituals\" in the \"Rituals\" view, explaining that users can restart their past rituals.",
|
"comment" : "A description below the label \"No Past Rituals\" in the \"Rituals\" view, explaining that users can restart their past rituals.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2563,10 +2526,6 @@
|
|||||||
"comment" : "Description of a ritual preset that serves as a weekly reset to help users start their week on a positive note.",
|
"comment" : "Description of a ritual preset that serves as a weekly reset to help users start their week on a positive note.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Support Development" : {
|
|
||||||
"comment" : "Subtitle for a feature row in the \"Pro\" upgrade view, related to supporting the development of the app.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Switch tabs to explore rituals and insights" : {
|
"Switch tabs to explore rituals and insights" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -2967,22 +2926,6 @@
|
|||||||
"comment" : "A label for a breakdown item showing the number of unique days with activity.",
|
"comment" : "A label for a breakdown item showing the number of unique days with activity.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Unlimited Rituals" : {
|
|
||||||
"comment" : "Description of a feature in the \"Pro\" upgrade section, describing that users can create as many rituals as they need.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Unlock unlimited rituals and more" : {
|
|
||||||
"comment" : "Title of a navigation link in the \"Rituals Pro\" section of the settings view, leading to the ProUpgradeView.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Unlock Your Full Potential" : {
|
|
||||||
"comment" : "A heading describing the primary benefit of the Pro upgrade.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Upgrade to Pro" : {
|
|
||||||
"comment" : "Text for a settings card that allows users to upgrade to the Pro version of the app.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Use breath to reduce stress and increase focus." : {
|
"Use breath to reduce stress and increase focus." : {
|
||||||
"comment" : "Notes for a \"Breathwork\" ritual preset.",
|
"comment" : "Notes for a \"Breathwork\" ritual preset.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|||||||
@ -5,21 +5,11 @@ struct AppSettingsData: PersistableData {
|
|||||||
static var dataIdentifier: String = "rituals.settings"
|
static var dataIdentifier: String = "rituals.settings"
|
||||||
static var empty = AppSettingsData()
|
static var empty = AppSettingsData()
|
||||||
|
|
||||||
var remindersEnabled: Bool = false // Default off until user enables
|
|
||||||
var reminderHour: Int = 8 // Default 8:00 AM
|
|
||||||
var reminderMinute: Int = 0
|
|
||||||
var hapticsEnabled: Bool = true
|
var hapticsEnabled: Bool = true
|
||||||
var soundEnabled: Bool = true
|
var soundEnabled: Bool = true
|
||||||
|
var remindersEnabled: Bool = false
|
||||||
var lastModified: Date = .now
|
var lastModified: Date = .now
|
||||||
|
|
||||||
/// Sync priority based on reminder settings - higher values win conflicts
|
/// Sync priority - uses haptics as a simple indicator of user activity
|
||||||
var syncPriority: Int { remindersEnabled ? reminderHour : 0 }
|
var syncPriority: Int { hapticsEnabled ? 1 : 0 }
|
||||||
|
|
||||||
/// Returns the reminder time as DateComponents for scheduling.
|
|
||||||
var reminderTimeComponents: DateComponents {
|
|
||||||
var components = DateComponents()
|
|
||||||
components.hour = reminderHour
|
|
||||||
components.minute = reminderMinute
|
|
||||||
return components
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,111 +0,0 @@
|
|||||||
//
|
|
||||||
// NotificationService.swift
|
|
||||||
// Andromida
|
|
||||||
//
|
|
||||||
// Service for scheduling daily reminder notifications.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import UserNotifications
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class NotificationService {
|
|
||||||
|
|
||||||
// MARK: - Singleton
|
|
||||||
|
|
||||||
static let shared = NotificationService()
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
// MARK: - Authorization
|
|
||||||
|
|
||||||
/// Requests notification authorization from the user.
|
|
||||||
/// - Returns: `true` if authorization was granted.
|
|
||||||
func requestAuthorization() async -> Bool {
|
|
||||||
do {
|
|
||||||
let granted = try await UNUserNotificationCenter.current().requestAuthorization(
|
|
||||||
options: [.alert, .sound, .badge]
|
|
||||||
)
|
|
||||||
return granted
|
|
||||||
} catch {
|
|
||||||
print("Notification authorization error: \(error)")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the current authorization status.
|
|
||||||
var authorizationStatus: UNAuthorizationStatus {
|
|
||||||
get async {
|
|
||||||
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
|
||||||
return settings.authorizationStatus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether notifications are currently authorized.
|
|
||||||
var isAuthorized: Bool {
|
|
||||||
get async {
|
|
||||||
let status = await authorizationStatus
|
|
||||||
return status == .authorized || status == .provisional
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Scheduling
|
|
||||||
|
|
||||||
/// Schedules a daily reminder notification at the specified time.
|
|
||||||
/// - Parameter time: The time components (hour, minute) for the reminder.
|
|
||||||
func scheduleDailyReminder(at time: DateComponents) async {
|
|
||||||
// Request authorization if not already granted
|
|
||||||
var canSchedule = await isAuthorized
|
|
||||||
if !canSchedule {
|
|
||||||
canSchedule = await requestAuthorization()
|
|
||||||
}
|
|
||||||
guard canSchedule else { return }
|
|
||||||
|
|
||||||
// Cancel existing reminders before scheduling new one
|
|
||||||
cancelAllReminders()
|
|
||||||
|
|
||||||
// Create notification content
|
|
||||||
let content = UNMutableNotificationContent()
|
|
||||||
content.title = String(localized: "Time for your rituals")
|
|
||||||
content.body = String(localized: "Take a moment to check in on your daily habits.")
|
|
||||||
content.sound = .default
|
|
||||||
content.badge = 1
|
|
||||||
|
|
||||||
// Create daily trigger
|
|
||||||
var triggerComponents = DateComponents()
|
|
||||||
triggerComponents.hour = time.hour ?? 8
|
|
||||||
triggerComponents.minute = time.minute ?? 0
|
|
||||||
|
|
||||||
let trigger = UNCalendarNotificationTrigger(
|
|
||||||
dateMatching: triggerComponents,
|
|
||||||
repeats: true
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create and schedule the request
|
|
||||||
let request = UNNotificationRequest(
|
|
||||||
identifier: "rituals.daily.reminder",
|
|
||||||
content: content,
|
|
||||||
trigger: trigger
|
|
||||||
)
|
|
||||||
|
|
||||||
do {
|
|
||||||
try await UNUserNotificationCenter.current().add(request)
|
|
||||||
} catch {
|
|
||||||
print("Failed to schedule notification: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancels all scheduled reminder notifications.
|
|
||||||
func cancelAllReminders() {
|
|
||||||
UNUserNotificationCenter.current().removePendingNotificationRequests(
|
|
||||||
withIdentifiers: ["rituals.daily.reminder"]
|
|
||||||
)
|
|
||||||
// Also clear the badge
|
|
||||||
UNUserNotificationCenter.current().setBadgeCount(0) { _ in }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clears the app badge count.
|
|
||||||
func clearBadge() {
|
|
||||||
UNUserNotificationCenter.current().setBadgeCount(0) { _ in }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
200
Andromida/App/Services/ReminderScheduler.swift
Normal file
200
Andromida/App/Services/ReminderScheduler.swift
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
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] = []
|
||||||
|
|
||||||
|
init() {
|
||||||
|
Task { await refreshAuthorizationStatus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,6 +16,9 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
private(set) var rituals: [Ritual] = []
|
private(set) var rituals: [Ritual] = []
|
||||||
private(set) var lastErrorMessage: String?
|
private(set) var lastErrorMessage: String?
|
||||||
|
|
||||||
|
/// Reminder scheduler for time-slot based notifications
|
||||||
|
let reminderScheduler = ReminderScheduler()
|
||||||
|
|
||||||
/// Ritual that needs renewal prompt (arc just completed)
|
/// Ritual that needs renewal prompt (arc just completed)
|
||||||
var ritualNeedingRenewal: Ritual?
|
var ritualNeedingRenewal: Ritual?
|
||||||
|
|
||||||
@ -622,6 +625,10 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
private func reloadRituals() {
|
private func reloadRituals() {
|
||||||
do {
|
do {
|
||||||
rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
|
rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
|
||||||
|
// Update reminder scheduling when rituals change
|
||||||
|
Task {
|
||||||
|
await reminderScheduler.updateReminders(for: rituals)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
lastErrorMessage = error.localizedDescription
|
lastErrorMessage = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,58 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import Bedrock
|
import Bedrock
|
||||||
import UserNotifications
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class SettingsStore: CloudSyncable {
|
final class SettingsStore: CloudSyncable {
|
||||||
@ObservationIgnored private let cloudSync = CloudSyncManager<AppSettingsData>()
|
@ObservationIgnored private let cloudSync = CloudSyncManager<AppSettingsData>()
|
||||||
|
|
||||||
/// The current notification authorization status.
|
|
||||||
private(set) var notificationAuthStatus: UNAuthorizationStatus = .notDetermined
|
|
||||||
|
|
||||||
var remindersEnabled: Bool {
|
|
||||||
get { cloudSync.data.remindersEnabled }
|
|
||||||
set {
|
|
||||||
update { $0.remindersEnabled = newValue }
|
|
||||||
Task { await handleReminderToggle(enabled: newValue) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var reminderHour: Int {
|
|
||||||
get { cloudSync.data.reminderHour }
|
|
||||||
set {
|
|
||||||
update { $0.reminderHour = newValue }
|
|
||||||
if remindersEnabled {
|
|
||||||
Task { await scheduleReminder() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var reminderMinute: Int {
|
|
||||||
get { cloudSync.data.reminderMinute }
|
|
||||||
set {
|
|
||||||
update { $0.reminderMinute = newValue }
|
|
||||||
if remindersEnabled {
|
|
||||||
Task { await scheduleReminder() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a Date representing the reminder time for use with DatePicker.
|
|
||||||
var reminderTime: Date {
|
|
||||||
get {
|
|
||||||
var components = DateComponents()
|
|
||||||
components.hour = reminderHour
|
|
||||||
components.minute = reminderMinute
|
|
||||||
return Calendar.current.date(from: components) ?? Date()
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
let components = Calendar.current.dateComponents([.hour, .minute], from: newValue)
|
|
||||||
reminderHour = components.hour ?? 8
|
|
||||||
reminderMinute = components.minute ?? 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var hapticsEnabled: Bool {
|
var hapticsEnabled: Bool {
|
||||||
get { cloudSync.data.hapticsEnabled }
|
get { cloudSync.data.hapticsEnabled }
|
||||||
@ -75,41 +28,15 @@ final class SettingsStore: CloudSyncable {
|
|||||||
var syncStatus: String { cloudSync.syncStatus }
|
var syncStatus: String { cloudSync.syncStatus }
|
||||||
var hasCompletedInitialSync: Bool { cloudSync.hasCompletedInitialSync }
|
var hasCompletedInitialSync: Bool { cloudSync.hasCompletedInitialSync }
|
||||||
|
|
||||||
init() {
|
|
||||||
Task { await refreshNotificationStatus() }
|
|
||||||
}
|
|
||||||
|
|
||||||
func forceSync() {
|
func forceSync() {
|
||||||
cloudSync.sync()
|
cloudSync.sync()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refreshes the notification authorization status.
|
|
||||||
func refreshNotificationStatus() async {
|
|
||||||
notificationAuthStatus = await NotificationService.shared.authorizationStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
private func update(_ transform: (inout AppSettingsData) -> Void) {
|
private func update(_ transform: (inout AppSettingsData) -> Void) {
|
||||||
cloudSync.update { data in
|
cloudSync.update { data in
|
||||||
transform(&data)
|
transform(&data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Notification Handling
|
|
||||||
|
|
||||||
private func handleReminderToggle(enabled: Bool) async {
|
|
||||||
if enabled {
|
|
||||||
await scheduleReminder()
|
|
||||||
} else {
|
|
||||||
NotificationService.shared.cancelAllReminders()
|
|
||||||
}
|
|
||||||
await refreshNotificationStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scheduleReminder() async {
|
|
||||||
let components = cloudSync.data.reminderTimeComponents
|
|
||||||
await NotificationService.shared.scheduleDailyReminder(at: components)
|
|
||||||
await refreshNotificationStatus()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SettingsStore {
|
extension SettingsStore {
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Bedrock
|
import Bedrock
|
||||||
import UserNotifications
|
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Bindable var store: SettingsStore
|
@Bindable var store: SettingsStore
|
||||||
@ -17,27 +16,11 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||||
SettingsToggle(
|
SettingsToggle(
|
||||||
title: String(localized: "Daily reminders"),
|
title: String(localized: "Reminders"),
|
||||||
subtitle: reminderSubtitle,
|
subtitle: reminderSubtitle,
|
||||||
isOn: $store.remindersEnabled,
|
isOn: remindersBinding,
|
||||||
accentColor: AppAccent.primary
|
accentColor: AppAccent.primary
|
||||||
)
|
)
|
||||||
|
|
||||||
if store.remindersEnabled {
|
|
||||||
HStack {
|
|
||||||
Text(String(localized: "Reminder time"))
|
|
||||||
.foregroundStyle(AppTextColors.primary)
|
|
||||||
Spacer()
|
|
||||||
DatePicker(
|
|
||||||
"",
|
|
||||||
selection: $store.reminderTime,
|
|
||||||
displayedComponents: .hourAndMinute
|
|
||||||
)
|
|
||||||
.labelsHidden()
|
|
||||||
.tint(AppAccent.primary)
|
|
||||||
}
|
|
||||||
.padding(.vertical, Design.Spacing.small)
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsToggle(
|
SettingsToggle(
|
||||||
title: String(localized: "Haptics"),
|
title: String(localized: "Haptics"),
|
||||||
@ -163,16 +146,46 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
extension SettingsView {
|
extension SettingsView {
|
||||||
private var reminderSubtitle: String {
|
private var reminderSubtitle: String {
|
||||||
switch store.notificationAuthStatus {
|
guard let ritualStore else {
|
||||||
case .denied:
|
return String(localized: "Get reminded when it's time for your rituals")
|
||||||
return String(localized: "Notifications disabled in Settings")
|
|
||||||
case .notDetermined:
|
|
||||||
return String(localized: "Get a gentle check-in each morning")
|
|
||||||
case .authorized, .provisional, .ephemeral:
|
|
||||||
return String(localized: "Get a gentle check-in each morning")
|
|
||||||
@unknown default:
|
|
||||||
return String(localized: "Get a gentle check-in each morning")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let scheduler = ritualStore.reminderScheduler
|
||||||
|
|
||||||
|
// Check if notifications are denied at system level
|
||||||
|
if scheduler.authorizationStatus == .denied {
|
||||||
|
return String(localized: "Notifications disabled in Settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If reminders are enabled, show which time slots are scheduled
|
||||||
|
if scheduler.remindersEnabled {
|
||||||
|
let slots = scheduler.scheduledSlots
|
||||||
|
if slots.isEmpty {
|
||||||
|
return String(localized: "No active rituals to remind")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build time string like "7am, 6pm"
|
||||||
|
let times = slots.sorted { $0.hour < $1.hour }.map { slot in
|
||||||
|
switch slot {
|
||||||
|
case .morning: return String(localized: "7am")
|
||||||
|
case .midday: return String(localized: "12pm")
|
||||||
|
case .evening: return String(localized: "6pm")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let timeList = times.joined(separator: ", ")
|
||||||
|
return String(localized: "Daily at \(timeList)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(localized: "Get reminded when it's time for your rituals")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var remindersBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { ritualStore?.reminderScheduler.remindersEnabled ?? false },
|
||||||
|
set: { newValue in
|
||||||
|
ritualStore?.reminderScheduler.remindersEnabled = newValue
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -65,7 +65,7 @@ Rituals is a paid, offline-first habit tracker built around customizable "ritual
|
|||||||
- Contextual tips based on performance
|
- Contextual tips based on performance
|
||||||
|
|
||||||
### Settings Tab
|
### Settings Tab
|
||||||
- Daily reminder notifications with time picker
|
- Smart reminders based on ritual time-of-day (morning/midday/evening)
|
||||||
- Haptics and sound toggles (wired to habit check-ins)
|
- Haptics and sound toggles (wired to habit check-ins)
|
||||||
- iCloud settings sync
|
- iCloud settings sync
|
||||||
- Debug tools: reset onboarding, app icon generation, branding preview
|
- Debug tools: reset onboarding, app icon generation, branding preview
|
||||||
|
|||||||
2
TODO.md
2
TODO.md
@ -83,6 +83,6 @@
|
|||||||
- [ ] **HealthKit integration** – Sync habit completions (water, mindfulness, exercise) to Apple Health. See plan: `.cursor/plans/healthkit_integration_plan_ce4f933c.plan.md`
|
- [ ] **HealthKit integration** – Sync habit completions (water, mindfulness, exercise) to Apple Health. See plan: `.cursor/plans/healthkit_integration_plan_ce4f933c.plan.md`
|
||||||
- [ ] **Widget** – Home screen widget showing today's progress.
|
- [ ] **Widget** – Home screen widget showing today's progress.
|
||||||
- [ ] **Watch app** – Companion app for quick habit check-ins.
|
- [ ] **Watch app** – Companion app for quick habit check-ins.
|
||||||
- [ ] **Notifications** – Smart reminders based on habit completion patterns.
|
- [x] **Smart Reminders** – Time-slot based reminders (morning/midday/evening) scheduled automatically based on active rituals.
|
||||||
- [ ] **Export/Import** – Backup and restore ritual data.
|
- [ ] **Export/Import** – Backup and restore ritual data.
|
||||||
- [ ] **Statistics** – Monthly/yearly summary views.
|
- [ ] **Statistics** – Monthly/yearly summary views.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user