diff --git a/Andromida/Andromida.entitlements b/Andromida/.entitlements similarity index 100% rename from Andromida/Andromida.entitlements rename to Andromida/.entitlements diff --git a/Andromida/App/Localization/Localizable.xcstrings b/Andromida/App/Localization/Localizable.xcstrings index ab2c39a..c76491f 100644 --- a/Andromida/App/Localization/Localizable.xcstrings +++ b/Andromida/App/Localization/Localizable.xcstrings @@ -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 diff --git a/Andromida/App/Models/AppSettingsData.swift b/Andromida/App/Models/AppSettingsData.swift index 8843d29..158316c 100644 --- a/Andromida/App/Models/AppSettingsData.swift +++ b/Andromida/App/Models/AppSettingsData.swift @@ -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 } } diff --git a/Andromida/App/Services/NotificationService.swift b/Andromida/App/Services/NotificationService.swift deleted file mode 100644 index a258d6a..0000000 --- a/Andromida/App/Services/NotificationService.swift +++ /dev/null @@ -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 } - } -} diff --git a/Andromida/App/Services/ReminderScheduler.swift b/Andromida/App/Services/ReminderScheduler.swift new file mode 100644 index 0000000..8aad148 --- /dev/null +++ b/Andromida/App/Services/ReminderScheduler.swift @@ -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 = [] + + /// 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() + + 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)") + } + } +} diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift index 2bc45c4..47c6b47 100644 --- a/Andromida/App/State/RitualStore.swift +++ b/Andromida/App/State/RitualStore.swift @@ -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()) + // Update reminder scheduling when rituals change + Task { + await reminderScheduler.updateReminders(for: rituals) + } } catch { lastErrorMessage = error.localizedDescription } diff --git a/Andromida/App/State/SettingsStore.swift b/Andromida/App/State/SettingsStore.swift index 0fd4648..3da998a 100644 --- a/Andromida/App/State/SettingsStore.swift +++ b/Andromida/App/State/SettingsStore.swift @@ -1,58 +1,11 @@ import Foundation import Observation import Bedrock -import UserNotifications @MainActor @Observable final class SettingsStore: CloudSyncable { @ObservationIgnored private let cloudSync = CloudSyncManager() - - /// 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 { diff --git a/Andromida/App/Views/Settings/SettingsView.swift b/Andromida/App/Views/Settings/SettingsView.swift index 215f813..b8ebcf9 100644 --- a/Andromida/App/Views/Settings/SettingsView.swift +++ b/Andromida/App/Views/Settings/SettingsView.swift @@ -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 { + Binding( + get: { ritualStore?.reminderScheduler.remindersEnabled ?? false }, + set: { newValue in + ritualStore?.reminderScheduler.remindersEnabled = newValue + } + ) } } diff --git a/README.md b/README.md index 8f95c85..44abcb5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/TODO.md b/TODO.md index c87b584..d3b13d2 100644 --- a/TODO.md +++ b/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` - [ ] **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.