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" : {
|
||||
"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
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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 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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
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`
|
||||
- [ ] **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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user