From 39c95784db5f7ed126eb58b3b4bb97bc887d9b21 Mon Sep 17 00:00:00 2001 From: kutesir Date: Thu, 9 Jul 2026 02:04:48 +0300 Subject: [PATCH] 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 --- KisaniCal/ISSUES.md | 67 +++++ KisaniCal/Managers/NotificationManager.swift | 91 +++++++ KisaniCal/Views/OnboardingView.swift | 71 ++++++ KisaniCal/Views/SettingsView.swift | 245 +++++++++++++++++++ 4 files changed, 474 insertions(+) diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index c9af11d..9d8f68d 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -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). diff --git a/KisaniCal/Managers/NotificationManager.swift b/KisaniCal/Managers/NotificationManager.swift index 6958eb4..1bda8fa 100644 --- a/KisaniCal/Managers/NotificationManager.swift +++ b/KisaniCal/Managers/NotificationManager.swift @@ -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() + + 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 diff --git a/KisaniCal/Views/OnboardingView.swift b/KisaniCal/Views/OnboardingView.swift index ed55a13..cd91585 100644 --- a/KisaniCal/Views/OnboardingView.swift +++ b/KisaniCal/Views/OnboardingView.swift @@ -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 { diff --git a/KisaniCal/Views/SettingsView.swift b/KisaniCal/Views/SettingsView.swift index 63db2fe..c23add8 100644 --- a/KisaniCal/Views/SettingsView.swift +++ b/KisaniCal/Views/SettingsView.swift @@ -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 `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; var isLast: Bool = false