From ed26a9d367729378d16d1589adc9f452cfd9a288 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sun, 25 Jan 2026 18:06:10 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Andromida/AndromidaApp.swift | 5 +- .../App/Localization/Localizable.xcstrings | 30 +++++ Andromida/App/Models/AppSettingsData.swift | 26 +++- .../App/Services/NotificationService.swift | 111 ++++++++++++++++++ Andromida/App/State/RitualStore+Preview.swift | 2 +- Andromida/App/State/RitualStore.swift | 17 ++- Andromida/App/State/SettingsStore.swift | 70 ++++++++++- .../App/Views/Settings/SettingsView.swift | 38 +++++- TODO.md | 72 ++++++++++-- 9 files changed, 353 insertions(+), 18 deletions(-) create mode 100644 Andromida/App/Services/NotificationService.swift diff --git a/Andromida/AndromidaApp.swift b/Andromida/AndromidaApp.swift index d0bc4a6..f8573e4 100644 --- a/Andromida/AndromidaApp.swift +++ b/Andromida/AndromidaApp.swift @@ -19,8 +19,9 @@ struct AndromidaApp: App { fatalError("Unable to create model container: \(error)") } modelContainer = container - _store = State(initialValue: RitualStore(modelContext: container.mainContext, seedService: RitualSeedService())) - _settingsStore = State(initialValue: SettingsStore()) + let settings = SettingsStore() + _settingsStore = State(initialValue: settings) + _store = State(initialValue: RitualStore(modelContext: container.mainContext, seedService: RitualSeedService(), settingsStore: settings)) } var body: some Scene { diff --git a/Andromida/App/Localization/Localizable.xcstrings b/Andromida/App/Localization/Localizable.xcstrings index 681d283..92ba7ed 100644 --- a/Andromida/App/Localization/Localizable.xcstrings +++ b/Andromida/App/Localization/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage" : "en", "strings" : { + "" : { + + }, "%lld of %lld habits complete" : { "localizations" : { "en" : { @@ -67,6 +70,10 @@ } } }, + "A relaxed pace for building habits slowly" : { + "comment" : "Description of what \"Gentle\" focus style means for the user.", + "isCommentAutoGenerated" : true + }, "About" : { "localizations" : { "en" : { @@ -159,6 +166,10 @@ "comment" : "Subtitle for a feature in the \"Rituals Pro\" upgrade screen, related to \"Advanced Insights\".", "isCommentAutoGenerated" : true }, + "Balanced daily check-ins" : { + "comment" : "Description of what the \"Steady\" focus style means for the user.", + "isCommentAutoGenerated" : true + }, "Begin a four-week arc" : { "localizations" : { "en" : { @@ -646,6 +657,10 @@ } } }, + "Focused approach with more accountability" : { + "comment" : "Description of the \"Intense\" focus style.", + "isCommentAutoGenerated" : true + }, "Four-week arc in progress" : { "localizations" : { "en" : { @@ -1156,6 +1171,10 @@ } } }, + "Notifications disabled in Settings" : { + "comment" : "Subtitle text for the reminder toggle when notifications are disabled in settings.", + "isCommentAutoGenerated" : true + }, "Play subtle completion sounds" : { "localizations" : { "en" : { @@ -1272,6 +1291,9 @@ } } } + }, + "Reminder time" : { + }, "Reset Onboarding" : { "comment" : "Title of a navigation row in the Settings view that resets the user's onboarding state.", @@ -1758,6 +1780,10 @@ } } }, + "Take a moment to check in on your daily habits." : { + "comment" : "Body text for a daily reminder notification.", + "isCommentAutoGenerated" : true + }, "Tap a habit to check in" : { "localizations" : { "en" : { @@ -1802,6 +1828,10 @@ } } }, + "Time for your rituals" : { + "comment" : "Title of a notification displayed at the start of the day.", + "isCommentAutoGenerated" : true + }, "Today" : { "localizations" : { "en" : { diff --git a/Andromida/App/Models/AppSettingsData.swift b/Andromida/App/Models/AppSettingsData.swift index a2a6190..285c9cd 100644 --- a/Andromida/App/Models/AppSettingsData.swift +++ b/Andromida/App/Models/AppSettingsData.swift @@ -5,7 +5,9 @@ struct AppSettingsData: PersistableData { static var dataIdentifier: String = "rituals.settings" static var empty = AppSettingsData() - var remindersEnabled: Bool = true + 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 focusStyle: FocusStyle = .gentle @@ -13,8 +15,21 @@ struct AppSettingsData: PersistableData { var lastModified: Date = .now var syncPriority: Int { ritualLengthDays } + + /// Returns the reminder time as DateComponents for scheduling. + var reminderTimeComponents: DateComponents { + var components = DateComponents() + components.hour = reminderHour + components.minute = reminderMinute + return components + } } +/// Defines the intensity of ritual pacing. +/// +/// - `gentle`: Relaxed approach with softer reminders. Good for building habits slowly. +/// - `steady`: Balanced default for most users. Consistent daily check-ins. +/// - `intense`: More focused approach with more frequent engagement. For users who want accountability. enum FocusStyle: String, CaseIterable, Codable, Identifiable { case gentle case steady @@ -29,4 +44,13 @@ enum FocusStyle: String, CaseIterable, Codable, Identifiable { case .intense: return String(localized: "Intense") } } + + /// Description of what this focus style means for the user. + var description: String { + switch self { + case .gentle: return String(localized: "A relaxed pace for building habits slowly") + case .steady: return String(localized: "Balanced daily check-ins") + case .intense: return String(localized: "Focused approach with more accountability") + } + } } diff --git a/Andromida/App/Services/NotificationService.swift b/Andromida/App/Services/NotificationService.swift new file mode 100644 index 0000000..a258d6a --- /dev/null +++ b/Andromida/App/Services/NotificationService.swift @@ -0,0 +1,111 @@ +// +// 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/State/RitualStore+Preview.swift b/Andromida/App/State/RitualStore+Preview.swift index 02f8474..6c72cd1 100644 --- a/Andromida/App/State/RitualStore+Preview.swift +++ b/Andromida/App/State/RitualStore+Preview.swift @@ -11,6 +11,6 @@ extension RitualStore { } catch { fatalError("Preview container failed: \(error)") } - return RitualStore(modelContext: container.mainContext, seedService: RitualSeedService()) + return RitualStore(modelContext: container.mainContext, seedService: RitualSeedService(), settingsStore: SettingsStore()) } } diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift index 3358848..fa3d057 100644 --- a/Andromida/App/State/RitualStore.swift +++ b/Andromida/App/State/RitualStore.swift @@ -1,12 +1,14 @@ import Foundation import Observation import SwiftData +import Bedrock @MainActor @Observable final class RitualStore: RitualStoreProviding { @ObservationIgnored private let modelContext: ModelContext @ObservationIgnored private let seedService: RitualSeedProviding + @ObservationIgnored private let settingsStore: SettingsStore @ObservationIgnored private let calendar: Calendar @ObservationIgnored private let dayFormatter: DateFormatter @ObservationIgnored private let displayFormatter: DateFormatter @@ -17,10 +19,12 @@ final class RitualStore: RitualStoreProviding { init( modelContext: ModelContext, seedService: RitualSeedProviding, + settingsStore: SettingsStore, calendar: Calendar = .current ) { self.modelContext = modelContext self.seedService = seedService + self.settingsStore = settingsStore self.calendar = calendar self.dayFormatter = DateFormatter() self.displayFormatter = DateFormatter() @@ -72,10 +76,19 @@ final class RitualStore: RitualStoreProviding { func toggleHabitCompletion(_ habit: Habit) { let dayID = dayIdentifier(for: Date()) - if habit.completedDayIDs.contains(dayID) { + let wasCompleted = habit.completedDayIDs.contains(dayID) + + if wasCompleted { habit.completedDayIDs.removeAll { $0 == dayID } } else { habit.completedDayIDs.append(dayID) + // Play feedback on check-in (not on uncheck) + if settingsStore.hapticsEnabled { + SoundManager.shared.playHaptic(.success) + } + if settingsStore.soundEnabled { + SoundManager.shared.playSystemSound(SystemSound.success) + } } saveContext() } @@ -150,7 +163,7 @@ final class RitualStore: RitualStoreProviding { title: String(localized: "Custom Ritual"), theme: String(localized: "Your next chapter"), startDate: Date(), - durationDays: 28, + durationDays: Int(settingsStore.ritualLengthDays), habits: habits, notes: String(localized: "A fresh ritual created from your focus today.") ) diff --git a/Andromida/App/State/SettingsStore.swift b/Andromida/App/State/SettingsStore.swift index ecbb428..b4a3e84 100644 --- a/Andromida/App/State/SettingsStore.swift +++ b/Andromida/App/State/SettingsStore.swift @@ -1,15 +1,57 @@ 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 } } + 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 { @@ -43,15 +85,41 @@ 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 187c130..fdfb7f0 100644 --- a/Andromida/App/Views/Settings/SettingsView.swift +++ b/Andromida/App/Views/Settings/SettingsView.swift @@ -1,5 +1,6 @@ import SwiftUI import Bedrock +import UserNotifications struct SettingsView: View { @Bindable var store: SettingsStore @@ -18,14 +19,30 @@ struct SettingsView: View { SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) { SettingsToggle( title: String(localized: "Daily reminders"), - subtitle: String(localized: "Get a gentle check-in each morning"), + subtitle: reminderSubtitle, isOn: $store.remindersEnabled, 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"), - subtitle: String(localized: "Feel a soft response on check-in"), + subtitle: String(localized: "Vibrate when completing habits"), isOn: $store.hapticsEnabled, accentColor: AppAccent.primary ) @@ -161,6 +178,23 @@ struct SettingsView: View { } } +// MARK: - Private Computed Properties + +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") + } + } +} + #Preview { NavigationStack { SettingsView(store: SettingsStore.preview) diff --git a/TODO.md b/TODO.md index b696b3f..cfa91aa 100644 --- a/TODO.md +++ b/TODO.md @@ -15,17 +15,71 @@ - [x] Re-add accessibility value/hint for habit rows once Sherpa-related ambiguity is resolved. - [x] Confirm focus ritual card and habit rows still match the intended visual hierarchy after refactors. -## 4) Settings & product readiness -- [x] Add a paid-app placeholder (e.g., "Pro unlock" copy) without backend requirements. -- [ ] Confirm default settings and theme in Settings match Bedrock branding. - -## 5) Data & defaults -- [ ] Confirm seed ritual creation and quick ritual creation behave as expected. -- [ ] Validate SwiftData sync (if enabled) doesn't require any external API. - -## 6) QA checklist +## 4) QA checklist - [x] First-launch walkthrough appears on a clean install. - [x] Onboarding can be manually reset from Settings. - [x] No build warnings or Swift compiler crashes. - [x] iPhone 17 Pro Max simulator layout verified on Today, Rituals, Insights, Settings. +--- + +## PRIORITY: Wire up existing settings + +### 5) Haptic feedback ⚡ +- [ ] Add haptic feedback on habit check-in using `UIImpactFeedbackGenerator`. +- [ ] Respect `hapticsEnabled` setting from SettingsStore. +- [ ] Add haptics to other interactions (ritual creation, onboarding completion). + +### 6) Sound effects ⚡ +- [ ] Add completion sound when habit is checked in. +- [ ] Respect `soundEnabled` setting from SettingsStore. +- [ ] Use Bedrock `SoundManager` if available, or create audio service. + +### 7) Daily reminders (notifications) ⚡ +- [ ] Request notification permission when "Daily reminders" is enabled. +- [ ] Schedule daily local notification at user-preferred time. +- [ ] Add time picker to Settings for reminder time. +- [ ] Cancel notifications when setting is disabled. +- [ ] Handle notification authorization denied state in UI. + +### 8) Ritual pacing settings ⚡ +- [ ] Use `ritualLengthDays` setting when creating new rituals via `createQuickRitual()`. +- [ ] Use `focusStyle` setting to affect ritual recommendations or insights. +- [ ] Consider adding visual indicator of current pacing in Today view. + +--- + +## Lower priority + +### 9) Settings & product readiness +- [x] Add a paid-app placeholder (e.g., "Pro unlock" copy) without backend requirements. +- [ ] Confirm default settings and theme in Settings match Bedrock branding. + +### 10) Data & defaults +- [ ] Confirm seed ritual creation and quick ritual creation behave as expected. +- [ ] Validate SwiftData sync (if enabled) doesn't require any external API. + +--- + +## Future features + +### 11) History view +- [ ] Add History tab or section to view completed/past rituals. +- [ ] Show completion percentage for each past ritual arc. +- [ ] Allow viewing habits and check-in history for past rituals. + +### 12) Ritual management +- [ ] Add ability to create custom rituals (not just quick ritual). +- [ ] Add ability to edit existing rituals (title, theme, habits). +- [ ] Add ability to delete rituals. +- [ ] Add ability to archive completed rituals. + +### 13) Insights enhancements +- [ ] Show weekly/monthly trends. +- [ ] Show streak data (consecutive days with all habits completed). +- [ ] Add charts or visualizations for progress over time. + +### 14) Future enhancements +- [ ] **HealthKit integration** – 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.