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

This commit is contained in:
Matt Bruce 2026-01-25 18:06:10 -06:00
parent 2c5ee7490c
commit ed26a9d367
9 changed files with 353 additions and 18 deletions

View File

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

View File

@ -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" : {

View File

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

View File

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

View File

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

View File

@ -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.")
)

View File

@ -1,15 +1,57 @@
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 } }
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 {

View File

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

72
TODO.md
View File

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