habits: add Prayer, Bed-making, and Coffee/Caffeine reminders (KC-55)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
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:
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user