Merge develop -> main: full session (KC-51 through KC-71) #28
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user