Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-25 23:39:43 -06:00
parent e434c52e78
commit bc8a0d1b53
10 changed files with 253 additions and 284 deletions

View File

@ -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" : {
"comment" : "Time range description for the \"Night\" time of day.",
"isCommentAutoGenerated" : true
@ -623,10 +619,6 @@
"comment" : "Notes for a ritual preset focused on breaking up a day with midday activity.",
"isCommentAutoGenerated" : true
},
"Coming Soon" : {
"comment" : "A label indicating that a feature is coming soon.",
"isCommentAutoGenerated" : true
},
"Coming up later:" : {
"comment" : "A label for a section of a view that lists rituals scheduled for times after the current time of day.",
"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.",
"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" : {
"comment" : "A button label that triggers the creation of a custom ritual.",
"isCommentAutoGenerated" : true
@ -920,10 +908,6 @@
"comment" : "Title of a ritual preset focused on setting up for focused work.",
"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" : {
"comment" : "Title of a habit within a ritual preset focused on productivity.",
"isCommentAutoGenerated" : true
@ -1131,9 +1115,6 @@
"Excellent consistency! You're building strong habits." : {
"comment" : "Tip provided in the \"Completion\" insight card when the user has a high completion rate.",
"isCommentAutoGenerated" : true
},
"Faster iCloud synchronization" : {
},
"Feel a soft response on check-in" : {
"extractionState" : "stale",
@ -1455,9 +1436,6 @@
"Have a real conversation" : {
"comment" : "Habit title for a ritual preset that encourages having a real conversation with someone.",
"isCommentAutoGenerated" : true
},
"Help us build more features" : {
},
"Herbal tea" : {
"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" : {
"comment" : "Theme for a ritual preset that encourages reflection and processing of the day's events.",
"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." : {
"comment" : "A description below the label \"No Past Rituals\" in the \"Rituals\" view, explaining that users can restart their past rituals.",
"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.",
"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" : {
"localizations" : {
"en" : {
@ -2967,22 +2926,6 @@
"comment" : "A label for a breakdown item showing the number of unique days with activity.",
"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." : {
"comment" : "Notes for a \"Breathwork\" ritual preset.",
"isCommentAutoGenerated" : true

View File

@ -5,21 +5,11 @@ struct AppSettingsData: PersistableData {
static var dataIdentifier: String = "rituals.settings"
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 soundEnabled: Bool = true
var remindersEnabled: Bool = false
var lastModified: Date = .now
/// Sync priority based on reminder settings - higher values win conflicts
var syncPriority: Int { remindersEnabled ? reminderHour : 0 }
/// Returns the reminder time as DateComponents for scheduling.
var reminderTimeComponents: DateComponents {
var components = DateComponents()
components.hour = reminderHour
components.minute = reminderMinute
return components
}
/// Sync priority - uses haptics as a simple indicator of user activity
var syncPriority: Int { hapticsEnabled ? 1 : 0 }
}

View File

@ -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 }
}
}

View 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)")
}
}
}

View File

@ -16,6 +16,9 @@ final class RitualStore: RitualStoreProviding {
private(set) var rituals: [Ritual] = []
private(set) var lastErrorMessage: String?
/// Reminder scheduler for time-slot based notifications
let reminderScheduler = ReminderScheduler()
/// Ritual that needs renewal prompt (arc just completed)
var ritualNeedingRenewal: Ritual?
@ -622,6 +625,10 @@ final class RitualStore: RitualStoreProviding {
private func reloadRituals() {
do {
rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
// Update reminder scheduling when rituals change
Task {
await reminderScheduler.updateReminders(for: rituals)
}
} catch {
lastErrorMessage = error.localizedDescription
}

View File

@ -1,58 +1,11 @@
import Foundation
import Observation
import Bedrock
import UserNotifications
@MainActor
@Observable
final class SettingsStore: CloudSyncable {
@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 {
get { cloudSync.data.hapticsEnabled }
@ -75,41 +28,15 @@ final class SettingsStore: CloudSyncable {
var syncStatus: String { cloudSync.syncStatus }
var hasCompletedInitialSync: Bool { cloudSync.hasCompletedInitialSync }
init() {
Task { await refreshNotificationStatus() }
}
func forceSync() {
cloudSync.sync()
}
/// Refreshes the notification authorization status.
func refreshNotificationStatus() async {
notificationAuthStatus = await NotificationService.shared.authorizationStatus
}
private func update(_ transform: (inout AppSettingsData) -> Void) {
cloudSync.update { data in
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 {

View File

@ -1,6 +1,5 @@
import SwiftUI
import Bedrock
import UserNotifications
struct SettingsView: View {
@Bindable var store: SettingsStore
@ -17,27 +16,11 @@ struct SettingsView: View {
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
SettingsToggle(
title: String(localized: "Daily reminders"),
title: String(localized: "Reminders"),
subtitle: reminderSubtitle,
isOn: $store.remindersEnabled,
isOn: remindersBinding,
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(
title: String(localized: "Haptics"),
@ -163,16 +146,46 @@ struct SettingsView: View {
extension SettingsView {
private var reminderSubtitle: String {
switch store.notificationAuthStatus {
case .denied:
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")
guard let ritualStore else {
return String(localized: "Get reminded when it's time for your rituals")
}
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
}
)
}
}

View File

@ -65,7 +65,7 @@ Rituals is a paid, offline-first habit tracker built around customizable "ritual
- Contextual tips based on performance
### 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)
- iCloud settings sync
- Debug tools: reset onboarding, app icon generation, branding preview

View File

@ -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`
- [ ] **Widget** Home screen widget showing today's progress.
- [ ] **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.
- [ ] **Statistics** Monthly/yearly summary views.