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. `occurrenceCopy` already preserve `isRecurring` on the occurrence copy.
Files: `TaskItem.swift`. 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.scheduleTasks(snapshot: tasks, urgentIds: urgentIds)
self.scheduleCalendarEvents(snapshot: calEvents) self.scheduleCalendarEvents(snapshot: calEvents)
self.schedulePeriodMilestones() self.schedulePeriodMilestones()
self.scheduleHabitReminders()
self.updateBadge(snapshot: tasks) 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) 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) // MARK: - Analytics report notifications (deep-linked)
/// Fire one local notification for each newly-generated weekly / monthly /// 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("heightCm") private var heightCm: Double = 0
@AppStorage("weightUnit") private var weightUnit: Int = 0 @AppStorage("weightUnit") private var weightUnit: Int = 0
@AppStorage("preferredWorkoutTime") private var preferredWorkoutTime: 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 // Local (uncommitted) state
@State private var localAppearance: Int = 2 // dark by default @State private var localAppearance: Int = 2 // dark by default
@@ -507,6 +512,41 @@ struct OnboardingView: View {
.padding(.horizontal, 20) .padding(.horizontal, 20)
.animation(KisaniSpring.snappy, value: workoutEnabled) .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.") Text("All settings can be changed later in Settings.")
.font(AppFonts.sans(11)) .font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs)) .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 // MARK: - Section Header
private struct OnboardingSectionHeader: View { private struct OnboardingSectionHeader: View {

View File

@@ -33,6 +33,9 @@ struct SettingsView: View {
@AppStorage("streakGoal") private var streakGoal = 5 @AppStorage("streakGoal") private var streakGoal = 5
// App-Group store so NotificationManager (which reads UserDefaults.kisani) sees it. // App-Group store so NotificationManager (which reads UserDefaults.kisani) sees it.
@AppStorage("kisani.periodMilestones", store: UserDefaults.kisani) private var periodMilestones = true @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 // Sheet presentation
@State private var showProfile = false @State private var showProfile = false
@@ -45,6 +48,7 @@ struct SettingsView: View {
@State private var showCalendar = false @State private var showCalendar = false
@State private var showBackup = false @State private var showBackup = false
@State private var showWorkoutSettings = false @State private var showWorkoutSettings = false
@State private var showHabitReminders = false
@State private var showRestTimer = false @State private var showRestTimer = false
@State private var showHelp = false @State private var showHelp = false
@State private var showFollow = 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 restLabel: String { workoutVM.restTimerEnabled ? "\(workoutVM.restTimerSeconds)s" : "Off" }
private var weightLabel: String { weightUnit == 0 ? "kg" : "lbs" } private var weightLabel: String { weightUnit == 0 ? "kg" : "lbs" }
private var dateLabel: String { ["MMM d", "MM/DD", "DD/MM"][dateFormat] } 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 { var body: some View {
ZStack { ZStack {
@@ -137,6 +145,13 @@ struct SettingsView: View {
HealthToggleRow(isLast: true) 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 // ACCOUNT
SttSection(label: "Account", secondary: true) { SttSection(label: "Account", secondary: true) {
if let user = AuthManager.shared.currentUser { 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: $showCalendar) { CalendarConnectSheet(calStore: calStore).presentationDetents([.large]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showBackup) { BackupSheet(iCloudSync: $iCloudSync).presentationDetents([.height(280)]).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: $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: $showRestTimer) { RestTimerSheet(seconds: $workoutVM.restTimerSeconds, enabled: $workoutVM.restTimerEnabled).presentationDetents([.height(360)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showHelp) { HelpSheet().presentationDetents([.height(300)]).presentationDragIndicator(.visible) } .sheet(isPresented: $showHelp) { HelpSheet().presentationDetents([.height(300)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showFollow) { FollowSheet().presentationDetents([.height(280)]).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 { private struct StepperRow: View {
@Environment(\.colorScheme) private var cs @Environment(\.colorScheme) private var cs
let label: String; @Binding var value: Int; let range: ClosedRange<Int>; var isLast: Bool = false let label: String; @Binding var value: Int; let range: ClosedRange<Int>; var isLast: Bool = false