habits: add Prayer, Bed-making, and Coffee/Caffeine reminders (KC-55)

Three new opt-in daily local-notification habits, toggleable in both Settings
and Onboarding, backed by the same UserDefaults.kisani (App Group) keys:

- Prayer: up to 4 independently-toggleable times/day (Morning/Afternoon/
  Evening/Night), 'Have you had a chance to pray and give thanks?'
- Bed-making: one daily nudge, default 8am (research-backed first win of the
  day per the user's note).
- Coffee/Caffeine: up to 4 named times + one custom-labeled reminder.

NotificationManager.scheduleHabitReminders() schedules/cancels all slots
idempotently via fixed identifiers, wired into the existing reschedule()
orchestration. Onboarding gets 3 restrained master toggles with sensible
defaults (no per-slot editors, to avoid a long form); Settings gets the full
Habit Reminders sheet with per-slot times and the custom coffee reminder.

Found and worked around a pre-existing store mismatch: workoutCheckInEnabled/
workoutConfirmEnabled etc. write to UserDefaults.standard (no explicit store:)
but NotificationManager reads them from UserDefaults.kisani -- not fixed here
(out of scope), but all new keys explicitly use store: UserDefaults.kisani so
this feature actually fires.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-07-09 02:04:48 +03:00
committed by kutesir
parent dc5c403a36
commit 39c95784db
4 changed files with 474 additions and 0 deletions

View File

@@ -1827,3 +1827,70 @@ through `overdueTasks`). One-line, additive filter change; `activeRows()` /
`occurrenceCopy` already preserve `isRecurring` on the occurrence copy.
Files: `TaskItem.swift`.
---
## KC-55 — Habit reminders: Prayer, Bed-making, Coffee/Caffeine
**Status:** Implemented (not build-verified)
**Reported by:** User
**Area:** Settings / Onboarding / Notifications
### Description
Add three new opt-in daily local-notification habits, each toggleable in both
Settings and Onboarding:
1. **Prayer** — up to 4 independently-toggleable times a day (Morning 7:00,
Afternoon 13:00, Evening 18:00, Night 21:00 by default). Message: "Have you
had a chance to pray and give thanks?"
2. **Bed-making** — a single daily nudge, default 8:00 AM (per the user's note
that research shows it's an effective first win of the day).
3. **Coffee / Caffeine** — up to 4 named times (Morning 8:00 on by default,
Midday/Afternoon/Evening off by default) plus one custom-labeled reminder.
### Design decisions
- **Onboarding stays restrained**: 3 simple master toggles (Prayer/Bed/Coffee)
with sensible pre-filled defaults — no per-slot time editors there, per the
app's "nothing shouts" principle and to avoid a long onboarding form. Full
per-slot control (and the custom coffee reminder) lives in Settings →
**Habit Reminders**, which onboarding points to via existing "change later
in Settings" copy. The SAME `UserDefaults.kisani` keys back both surfaces,
so a toggle in onboarding is identical to the one in Settings.
- **Store correctness**: discovered the existing `workoutCheckInEnabled` /
`workoutConfirmEnabled` / etc. `@AppStorage` declarations have **no explicit
`store:`**, so they write to `UserDefaults.standard` — but
`NotificationManager` reads them via `UserDefaults.kisani` (the App Group
suite). That's a pre-existing mismatch (not fixed here — out of scope,
flagging for a future look). All NEW keys for this feature explicitly use
`store: UserDefaults.kisani` everywhere (Settings, Onboarding, and the
reusable `HabitSlotRow`), matching the one place in the codebase that
already does this correctly (`kisani.periodMilestones`) — so these
reminders are guaranteed to actually fire.
- **All daily, not weekday-scoped**: unlike workout notifications (tied to a
weekly schedule), these use a plain `UNCalendarNotificationTrigger` with
only hour/minute set (no weekday) — fires every day, same pattern as
`schedulePeriodMilestones`'s "Every midnight" trigger.
### Implementation
- `NotificationManager.scheduleHabitReminders()`: reads all keys from
`UserDefaults.kisani`, schedules/cancels 9 possible daily notifications
(bed ×1, prayer ×4, coffee ×4 + custom ×1) with fixed identifiers so
re-scheduling is idempotent (no duplicates); explicitly cancels any
previously-scheduled slot that's no longer wanted. Wired into the existing
`reschedule(workoutVM:taskVM:)` orchestration (same place as
`schedulePeriodMilestones()`), so it refreshes on every app foreground.
- Settings: new "Habits" section → **Habit Reminders** sheet
(`HabitRemindersSheet`), one card per habit, following the exact visual
pattern of the existing Workout Settings sheet. `HabitSlotRow` (reusable,
used 9×) is a named toggle + time-picker row that owns its own
`@AppStorage` backing via a manual `init` (dynamic key per slot).
- Onboarding: a new "Habits" card with 3 `OnboardingHabitToggle` rows
(reusable, used 3×) right after the Workout section.
Files: `NotificationManager.swift`, `SettingsView.swift`, `OnboardingView.swift`.
### Note
Not build-verified — no iOS runtime in this environment. Self-reviewed for
compile correctness (balanced braces/parens checked programmatically; the
manual `@AppStorage` backing-storage init in `HabitSlotRow` is the highest-risk
spot syntactically — standard, documented pattern, but worth a close look on
first build).

View File

@@ -138,6 +138,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
self.scheduleTasks(snapshot: tasks, urgentIds: urgentIds)
self.scheduleCalendarEvents(snapshot: calEvents)
self.schedulePeriodMilestones()
self.scheduleHabitReminders()
self.updateBadge(snapshot: tasks)
}
}
@@ -180,6 +181,96 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
add(Self.periodIds[3], "This Year", "One more year passed.", year)
}
// MARK: - Habit reminders (prayer, bed-making, coffee/caffeine)
private static let habitPrefix = "kisani.habit"
/// Named slot definitions shared by Prayer and Coffee (storage key
/// fragment, default hour, default minute, default enabled-when-master-on).
private static let prayerSlots: [(key: String, hour: Int, minute: Int, defaultOn: Bool)] = [
("Morning", 7, 0, true), ("Afternoon", 13, 0, true), ("Evening", 18, 0, true), ("Night", 21, 0, true),
]
private static let coffeeSlots: [(key: String, hour: Int, minute: Int, defaultOn: Bool)] = [
("Morning", 8, 0, true), ("Midday", 12, 0, false), ("Afternoon", 15, 0, false), ("Evening", 18, 0, false),
]
/// Daily (every day, not weekday-scoped) habit nudges. Reads from
/// `UserDefaults.kisani` the App Group suite so it must match wherever
/// the Settings/Onboarding toggles actually write (see the `store:` param
/// on those `@AppStorage` declarations).
private func scheduleHabitReminders() {
let ud = UserDefaults.kisani
var wanted = Set<String>()
func addDaily(id: String, enabled: Bool, hour: Int, minute: Int, title: String, body: String) {
guard enabled else { return }
wanted.insert(id)
let content = UNMutableNotificationContent()
content.title = title; content.body = body; content.sound = .default
var comps = DateComponents(); comps.hour = hour; comps.minute = minute
center.add(UNNotificationRequest(
identifier: id, content: content,
trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
))
}
// Bed-making a single daily nudge, the simplest possible "start the
// day with a win."
addDaily(
id: "\(Self.habitPrefix).bed",
enabled: ud.object(forKey: "bedReminderEnabled") as? Bool ?? false,
hour: ud.object(forKey: "bedReminderHour") as? Int ?? 8,
minute: ud.object(forKey: "bedReminderMinute") as? Int ?? 0,
title: "Rise and shine",
body: "Have you made your bed? Small win, great start to the day."
)
// Prayer up to 4 independently-toggleable times a day.
let prayerOn = ud.object(forKey: "prayerReminderEnabled") as? Bool ?? false
for slot in Self.prayerSlots {
addDaily(
id: "\(Self.habitPrefix).prayer.\(slot.key)",
enabled: prayerOn && (ud.object(forKey: "prayer\(slot.key)Enabled") as? Bool ?? slot.defaultOn),
hour: ud.object(forKey: "prayer\(slot.key)Hour") as? Int ?? slot.hour,
minute: ud.object(forKey: "prayer\(slot.key)Minute") as? Int ?? slot.minute,
title: "A moment to pause",
body: "Have you had a chance to pray and give thanks?"
)
}
// Coffee / caffeine up to 4 named times plus one custom reminder.
let coffeeOn = ud.object(forKey: "coffeeReminderEnabled") as? Bool ?? false
for slot in Self.coffeeSlots {
addDaily(
id: "\(Self.habitPrefix).coffee.\(slot.key)",
enabled: coffeeOn && (ud.object(forKey: "coffee\(slot.key)Enabled") as? Bool ?? slot.defaultOn),
hour: ud.object(forKey: "coffee\(slot.key)Hour") as? Int ?? slot.hour,
minute: ud.object(forKey: "coffee\(slot.key)Minute") as? Int ?? slot.minute,
title: "\(slot.key) coffee",
body: "Time for your \(slot.key.lowercased()) coffee?"
)
}
let customLabel = ud.string(forKey: "coffeeCustomLabel") ?? "Coffee"
addDaily(
id: "\(Self.habitPrefix).coffee.custom",
enabled: coffeeOn && (ud.object(forKey: "coffeeCustomEnabled") as? Bool ?? false),
hour: ud.object(forKey: "coffeeCustomHour") as? Int ?? 16,
minute: ud.object(forKey: "coffeeCustomMinute") as? Int ?? 30,
title: customLabel,
body: "Time for \(customLabel.lowercased())?"
)
// Cancel anything previously scheduled that isn't wanted this pass
// (toggled off, or a slot that's no longer enabled). `add` with an
// existing identifier already replaces in place for the ones we kept.
let allPossibleIds = Set(
["\(Self.habitPrefix).bed", "\(Self.habitPrefix).coffee.custom"]
+ Self.prayerSlots.map { "\(Self.habitPrefix).prayer.\($0.key)" }
+ Self.coffeeSlots.map { "\(Self.habitPrefix).coffee.\($0.key)" }
)
center.removePendingNotificationRequests(withIdentifiers: Array(allPossibleIds.subtracting(wanted)))
}
// MARK: - Analytics report notifications (deep-linked)
/// Fire one local notification for each newly-generated weekly / monthly

View File

@@ -16,6 +16,11 @@ struct OnboardingView: View {
@AppStorage("heightCm") private var heightCm: Double = 0
@AppStorage("weightUnit") private var weightUnit: Int = 0
@AppStorage("preferredWorkoutTime") private var preferredWorkoutTime: Int = 0
// Habit reminders same App Group keys HabitRemindersSheet and
// NotificationManager use, so a toggle here is identical to Settings.
@AppStorage("bedReminderEnabled", store: UserDefaults.kisani) private var bedReminderEnabled = false
@AppStorage("prayerReminderEnabled", store: UserDefaults.kisani) private var prayerReminderEnabled = false
@AppStorage("coffeeReminderEnabled", store: UserDefaults.kisani) private var coffeeReminderEnabled = false
// Local (uncommitted) state
@State private var localAppearance: Int = 2 // dark by default
@@ -507,6 +512,41 @@ struct OnboardingView: View {
.padding(.horizontal, 20)
.animation(KisaniSpring.snappy, value: workoutEnabled)
//
// MARK: Habits
//
OnboardingSectionHeader(title: "Habits")
VStack(spacing: 0) {
OnboardingHabitToggle(
icon: "hands.sparkles.fill", iconColor: AppColors.yellow, iconBg: AppColors.yellowSoft,
title: "Prayer reminders",
subtitle: "\"Have you had a chance to pray and give thanks?\"",
isOn: $prayerReminderEnabled)
Divider().background(AppColors.border(cs)).padding(.leading, 53)
OnboardingHabitToggle(
icon: "bed.double.fill", iconColor: AppColors.blue, iconBg: AppColors.blueSoft,
title: "Make your bed",
subtitle: "The simplest task that starts the day with a win",
isOn: $bedReminderEnabled)
Divider().background(AppColors.border(cs)).padding(.leading, 53)
OnboardingHabitToggle(
icon: "cup.and.saucer.fill", iconColor: .white, iconBg: Color(r: 122, g: 84, b: 52),
title: "Coffee reminders",
subtitle: "Track when you usually reach for a cup",
isOn: $coffeeReminderEnabled)
}
.cardStyle()
.padding(.horizontal, 20)
.padding(.bottom, 8)
Text("Times default to typical hours — fine-tune each one, or add a custom coffee reminder, in Settings.")
.font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs))
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
.padding(.bottom, 8)
Text("All settings can be changed later in Settings.")
.font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs))
@@ -577,6 +617,37 @@ struct OnboardingView: View {
}
}
// MARK: - Habit Toggle Row
/// A single habit reminder's opt-in row (icon, title, one-line description,
/// toggle) used for Prayer / Bed-making / Coffee in onboarding. Per-slot
/// times and the custom coffee reminder are Settings-only, kept out of
/// onboarding to stay restrained.
private struct OnboardingHabitToggle: View {
@Environment(\.colorScheme) private var cs
let icon: String; let iconColor: Color; let iconBg: Color
let title: String; let subtitle: String
@Binding var isOn: Bool
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 14))
.foregroundColor(iconColor)
.frame(width: 32, height: 32)
.background(iconBg)
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 2) {
Text(title).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
Text(subtitle).font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)).lineLimit(2)
}
Spacer()
Toggle("", isOn: $isOn).labelsHidden().tint(AppColors.accent)
}
.padding(.horizontal, 14).padding(.vertical, 12)
}
}
// MARK: - Section Header
private struct OnboardingSectionHeader: View {

View File

@@ -33,6 +33,9 @@ struct SettingsView: View {
@AppStorage("streakGoal") private var streakGoal = 5
// App-Group store so NotificationManager (which reads UserDefaults.kisani) sees it.
@AppStorage("kisani.periodMilestones", store: UserDefaults.kisani) private var periodMilestones = true
@AppStorage("bedReminderEnabled", store: UserDefaults.kisani) private var bedReminderEnabled = false
@AppStorage("prayerReminderEnabled", store: UserDefaults.kisani) private var prayerReminderEnabled = false
@AppStorage("coffeeReminderEnabled", store: UserDefaults.kisani) private var coffeeReminderEnabled = false
// Sheet presentation
@State private var showProfile = false
@@ -45,6 +48,7 @@ struct SettingsView: View {
@State private var showCalendar = false
@State private var showBackup = false
@State private var showWorkoutSettings = false
@State private var showHabitReminders = false
@State private var showRestTimer = false
@State private var showHelp = false
@State private var showFollow = false
@@ -54,6 +58,10 @@ struct SettingsView: View {
private var restLabel: String { workoutVM.restTimerEnabled ? "\(workoutVM.restTimerSeconds)s" : "Off" }
private var weightLabel: String { weightUnit == 0 ? "kg" : "lbs" }
private var dateLabel: String { ["MMM d", "MM/DD", "DD/MM"][dateFormat] }
private var habitsLabel: String? {
let on = [bedReminderEnabled, prayerReminderEnabled, coffeeReminderEnabled].filter { $0 }.count
return on == 0 ? nil : "\(on) on"
}
var body: some View {
ZStack {
@@ -137,6 +145,13 @@ struct SettingsView: View {
HealthToggleRow(isLast: true)
}
// HABITS
SttSection(label: "Habits") {
SttNavRow(icon: "hands.sparkles", bg: AppColors.yellowSoft, fg: AppColors.yellow,
label: "Habit Reminders", value: habitsLabel,
valueColor: habitsLabel != nil ? AppColors.green : nil, isLast: true) { showHabitReminders = true }
}
// ACCOUNT
SttSection(label: "Account", secondary: true) {
if let user = AuthManager.shared.currentUser {
@@ -185,6 +200,7 @@ struct SettingsView: View {
.sheet(isPresented: $showCalendar) { CalendarConnectSheet(calStore: calStore).presentationDetents([.large]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showBackup) { BackupSheet(iCloudSync: $iCloudSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showWorkoutSettings) { WorkoutSettingsSheet(unit: $weightUnit, sets: $defaultSets, reps: $defaultReps).environmentObject(workoutVM).presentationDetents([.large]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showHabitReminders) { HabitRemindersSheet().environmentObject(workoutVM).environmentObject(taskVM).presentationDetents([.large]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showRestTimer) { RestTimerSheet(seconds: $workoutVM.restTimerSeconds, enabled: $workoutVM.restTimerEnabled).presentationDetents([.height(360)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showHelp) { HelpSheet().presentationDetents([.height(300)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showFollow) { FollowSheet().presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
@@ -880,6 +896,235 @@ private struct WorkoutSettingsSheet: View {
}
}
// MARK: - Habit Reminders Sheet
//
// Three independent daily reminders: prayer (up to 4 named times a day),
// bed-making (one nudge research says it's the easiest first win of the
// day), and coffee/caffeine (up to 4 named times plus one custom reminder).
// All keys use the App Group store (`UserDefaults.kisani`) the same store
// NotificationManager reads from so toggling here actually schedules.
private struct HabitRemindersSheet: View {
@Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs
@EnvironmentObject var workoutVM: WorkoutViewModel
@EnvironmentObject var taskVM: TaskViewModel
@AppStorage("bedReminderEnabled", store: UserDefaults.kisani) private var bedReminderEnabled = false
@AppStorage("bedReminderHour", store: UserDefaults.kisani) private var bedReminderHour = 8
@AppStorage("bedReminderMinute", store: UserDefaults.kisani) private var bedReminderMinute = 0
@AppStorage("prayerReminderEnabled", store: UserDefaults.kisani) private var prayerReminderEnabled = false
@AppStorage("coffeeReminderEnabled", store: UserDefaults.kisani) private var coffeeReminderEnabled = false
@AppStorage("coffeeCustomEnabled", store: UserDefaults.kisani) private var coffeeCustomEnabled = false
@AppStorage("coffeeCustomLabel", store: UserDefaults.kisani) private var coffeeCustomLabel = "Coffee"
@AppStorage("coffeeCustomHour", store: UserDefaults.kisani) private var coffeeCustomHour = 16
@AppStorage("coffeeCustomMinute", store: UserDefaults.kisani) private var coffeeCustomMinute = 30
private let prayerSlots = ["Morning", "Afternoon", "Evening", "Night"]
private let coffeeSlots = ["Morning", "Midday", "Afternoon", "Evening"]
var body: some View {
VStack(spacing: 0) {
SheetHeader(title: "Habit Reminders") {
dismiss()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
}
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 20) {
// Prayer
VStack(alignment: .leading, spacing: 8) {
Text("PRAYER").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
VStack(spacing: 0) {
HStack(spacing: 11) {
Image(systemName: "hands.sparkles.fill")
.font(.system(size: 13)).foregroundColor(AppColors.yellow)
.frame(width: 28, height: 28).background(AppColors.yellowSoft).cornerRadius(8)
VStack(alignment: .leading, spacing: 1) {
Text("Prayer reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
Text("\"Have you had a chance to pray and give thanks?\"")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
}
Spacer()
Toggle("", isOn: $prayerReminderEnabled).labelsHidden().tint(AppColors.accent)
}
.padding(.horizontal, 14).padding(.vertical, 12)
if prayerReminderEnabled {
ForEach(prayerSlots, id: \.self) { slot in
HabitSlotRow(keyPrefix: "prayer\(slot)", label: slot,
defaultHour: Self.defaultHour(for: slot, prayer: true),
defaultMinute: 0, defaultEnabled: true)
}
}
}
.cardStyle()
.animation(KisaniSpring.snappy, value: prayerReminderEnabled)
}
// Bed-making
VStack(alignment: .leading, spacing: 8) {
Text("MORNING").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
VStack(spacing: 0) {
HStack(spacing: 11) {
Image(systemName: "bed.double.fill")
.font(.system(size: 13)).foregroundColor(AppColors.blue)
.frame(width: 28, height: 28).background(AppColors.blueSoft).cornerRadius(8)
VStack(alignment: .leading, spacing: 1) {
Text("Make your bed").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
Text("The simplest task that starts the day with a win.")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
}
Spacer()
Toggle("", isOn: $bedReminderEnabled).labelsHidden().tint(AppColors.accent)
}
.padding(.horizontal, 14).padding(.vertical, 12)
if bedReminderEnabled {
Divider().background(AppColors.border(cs)).padding(.leading, 53)
HabitTimeRow(label: "Remind me at", hour: $bedReminderHour, minute: $bedReminderMinute)
}
}
.cardStyle()
.animation(KisaniSpring.snappy, value: bedReminderEnabled)
}
// Coffee / Caffeine
VStack(alignment: .leading, spacing: 8) {
Text("CAFFEINE").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
VStack(spacing: 0) {
HStack(spacing: 11) {
Image(systemName: "cup.and.saucer.fill")
.font(.system(size: 13)).foregroundColor(.white)
.frame(width: 28, height: 28).background(Color(r: 122, g: 84, b: 52)).cornerRadius(8)
VStack(alignment: .leading, spacing: 1) {
Text("Coffee reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
Text("Track when you usually reach for a cup.")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
}
Spacer()
Toggle("", isOn: $coffeeReminderEnabled).labelsHidden().tint(AppColors.accent)
}
.padding(.horizontal, 14).padding(.vertical, 12)
if coffeeReminderEnabled {
ForEach(coffeeSlots, id: \.self) { slot in
HabitSlotRow(keyPrefix: "coffee\(slot)", label: slot,
defaultHour: Self.defaultHour(for: slot, prayer: false),
defaultMinute: 0, defaultEnabled: slot == "Morning")
}
Divider().background(AppColors.border(cs)).padding(.leading, 14)
HStack(spacing: 11) {
TextField("Custom label", text: $coffeeCustomLabel)
.font(AppFonts.sans(12.5)).foregroundColor(AppColors.text(cs))
Spacer()
if coffeeCustomEnabled {
DatePicker("", selection: Binding(
get: { Calendar.current.date(bySettingHour: coffeeCustomHour, minute: coffeeCustomMinute, second: 0, of: Date()) ?? Date() },
set: { v in
let c = Calendar.current.dateComponents([.hour, .minute], from: v)
coffeeCustomHour = c.hour ?? coffeeCustomHour
coffeeCustomMinute = c.minute ?? coffeeCustomMinute
}
), displayedComponents: .hourAndMinute)
.labelsHidden().tint(AppColors.accent)
}
Toggle("", isOn: $coffeeCustomEnabled).labelsHidden().tint(AppColors.accent).scaleEffect(0.85)
}
.padding(.horizontal, 14).padding(.vertical, 9)
}
}
.cardStyle()
.animation(KisaniSpring.snappy, value: coffeeReminderEnabled)
}
Text("All reminders are local to this device and never share your responses — they're just gentle nudges.")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
}
.background(AppColors.background(cs).ignoresSafeArea())
}
private static func defaultHour(for slot: String, prayer: Bool) -> Int {
let table: [String: Int] = prayer
? ["Morning": 7, "Afternoon": 13, "Evening": 18, "Night": 21]
: ["Morning": 8, "Midday": 12, "Afternoon": 15, "Evening": 18]
return table[slot] ?? 9
}
}
/// One named, independently-toggleable daily time slot (e.g. "Morning") used
/// for both Prayer and Coffee's dense multi-slot lists. Reads/writes
/// `UserDefaults.kisani` directly under `<keyPrefix>Enabled/Hour/Minute` so it
/// stays in lockstep with what `NotificationManager` schedules from.
private struct HabitSlotRow: View {
@Environment(\.colorScheme) private var cs
let keyPrefix: String
let label: String
let defaultHour: Int
let defaultMinute: Int
let defaultEnabled: Bool
@AppStorage private var enabled: Bool
@AppStorage private var hour: Int
@AppStorage private var minute: Int
init(keyPrefix: String, label: String, defaultHour: Int, defaultMinute: Int, defaultEnabled: Bool) {
self.keyPrefix = keyPrefix; self.label = label
self.defaultHour = defaultHour; self.defaultMinute = defaultMinute; self.defaultEnabled = defaultEnabled
_enabled = AppStorage(wrappedValue: defaultEnabled, "\(keyPrefix)Enabled", store: UserDefaults.kisani)
_hour = AppStorage(wrappedValue: defaultHour, "\(keyPrefix)Hour", store: UserDefaults.kisani)
_minute = AppStorage(wrappedValue: defaultMinute, "\(keyPrefix)Minute", store: UserDefaults.kisani)
}
var body: some View {
VStack(spacing: 0) {
Divider().background(AppColors.border(cs)).padding(.leading, 14)
HStack(spacing: 11) {
Text(label).font(AppFonts.sans(12.5)).foregroundColor(AppColors.text2(cs))
Spacer()
if enabled {
DatePicker("", selection: Binding(
get: { Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: Date()) ?? Date() },
set: { v in
let c = Calendar.current.dateComponents([.hour, .minute], from: v)
hour = c.hour ?? hour; minute = c.minute ?? minute
}
), displayedComponents: .hourAndMinute)
.labelsHidden().tint(AppColors.accent)
}
Toggle("", isOn: $enabled).labelsHidden().tint(AppColors.accent).scaleEffect(0.85)
}
.padding(.horizontal, 14).padding(.vertical, 9)
}
}
}
/// A plain "label + time picker" row (no toggle) used where the toggle
/// already lives on the parent row (e.g. bed-making's single time).
private struct HabitTimeRow: View {
@Environment(\.colorScheme) private var cs
let label: String
@Binding var hour: Int
@Binding var minute: Int
var body: some View {
HStack {
Text(label).font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs))
Spacer()
DatePicker("", selection: Binding(
get: { Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: Date()) ?? Date() },
set: { v in
let c = Calendar.current.dateComponents([.hour, .minute], from: v)
hour = c.hour ?? hour; minute = c.minute ?? minute
}
), displayedComponents: .hourAndMinute)
.labelsHidden().tint(AppColors.accent)
}
.padding(.horizontal, 14).padding(.vertical, 8)
}
}
private struct StepperRow: View {
@Environment(\.colorScheme) private var cs
let label: String; @Binding var value: Int; let range: ClosedRange<Int>; var isLast: Bool = false