Merge develop -> main: full session (KC-51 through KC-71) #28
@@ -1894,3 +1894,47 @@ 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).
|
||||
|
||||
---
|
||||
|
||||
## KC-56 — Habit reminders: "Done" notification action + private day count
|
||||
|
||||
**Status:** Implemented (not build-verified)
|
||||
**Reported by:** User (follow-up to KC-55: "should they appear in tasks, or
|
||||
stay reminders only?")
|
||||
|
||||
### Decision
|
||||
Discussed with the user: keep habit reminders (Prayer/Bed/Coffee, KC-55) OUT
|
||||
of Today/Matrix/Activity History — turning them into recurring TaskItems would
|
||||
add 3–9 extra daily checkboxes forever, against PRODUCT.md's "nothing shouts /
|
||||
data is primary" principle, and a prayer nudge isn't really a "deliverable."
|
||||
Middle ground: add a **"Done" action directly on the notification** (like the
|
||||
workout confirm flow's "Yes, log it") that logs a private timestamp for a
|
||||
lightweight day count — no task, no Activity History entry, nothing shown
|
||||
outside Settings.
|
||||
|
||||
### Implementation
|
||||
- New `HABIT_LOG` notification category + `HABIT_DONE` action ("Done"),
|
||||
registered alongside the existing task/workout categories.
|
||||
- Every habit notification (bed, all 4 prayer slots, all 4 coffee slots + the
|
||||
custom one) carries `userInfo["habitType"]` = "bed"/"prayer"/"coffee" (slot-
|
||||
level detail collapses to the 3 habit TYPES — tapping Done on any prayer
|
||||
slot counts as "prayed today," not per-slot).
|
||||
- `logHabitDone(type:)`: appends today's date to `UserDefaults.kisani["kisani.
|
||||
habit.doneDates.<type>"]`, deduped (idempotent — tapping Done twice same day
|
||||
is a no-op). Mirrors the "lifetime tally, never breaks" philosophy already
|
||||
established for the workout streak (KC-40), not a fragile consecutive
|
||||
streak.
|
||||
- `habitDoneCount(type:)`: reads the count back.
|
||||
- Settings → Habit Reminders now shows a quiet "Xd" badge next to each
|
||||
habit's title (Prayer/Bed/Coffee) once count > 0 — the only place this
|
||||
number appears.
|
||||
|
||||
Files: `NotificationManager.swift`, `SettingsView.swift`.
|
||||
|
||||
### Note
|
||||
Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
|
||||
parens checked programmatically; `logHabitDone`/`habitDoneCount` intentionally
|
||||
left without `@MainActor` to match the existing nonisolated handler functions
|
||||
(`markTaskComplete`, `snoozeTask`) they run alongside in the notification
|
||||
delegate callback.
|
||||
|
||||
@@ -20,6 +20,8 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
private static let logWorkoutActId = "LOG_WORKOUT"
|
||||
private static let missedWorkoutActId = "MISSED_WORKOUT"
|
||||
private static let snoozeWorkoutActId = "SNOOZE_WORKOUT"
|
||||
private static let habitCatId = "HABIT_LOG"
|
||||
private static let habitDoneActId = "HABIT_DONE"
|
||||
|
||||
override private init() {
|
||||
super.init()
|
||||
@@ -49,7 +51,11 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
actions: [logAction, missedAction, snoozeAction],
|
||||
intentIdentifiers: [], options: .customDismissAction)
|
||||
|
||||
center.setNotificationCategories([taskCat, confirmCat])
|
||||
let habitDoneAction = UNNotificationAction(identifier: Self.habitDoneActId, title: "Done", options: [])
|
||||
let habitCat = UNNotificationCategory(identifier: Self.habitCatId, actions: [habitDoneAction],
|
||||
intentIdentifiers: [], options: .customDismissAction)
|
||||
|
||||
center.setNotificationCategories([taskCat, confirmCat, habitCat])
|
||||
}
|
||||
|
||||
// MARK: - Complete task directly in UserDefaults (called from background)
|
||||
@@ -202,11 +208,17 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
let ud = UserDefaults.kisani
|
||||
var wanted = Set<String>()
|
||||
|
||||
func addDaily(id: String, enabled: Bool, hour: Int, minute: Int, title: String, body: String) {
|
||||
// `habitType` drives the "Done" action's log bucket ("bed"/"prayer"/
|
||||
// "coffee") — tapping Done on any slot of a type logs one entry for
|
||||
// that type today (deduped), feeding a simple lifetime "days done"
|
||||
// count. Never touches the task list or Activity History.
|
||||
func addDaily(id: String, enabled: Bool, hour: Int, minute: Int, title: String, body: String, habitType: String) {
|
||||
guard enabled else { return }
|
||||
wanted.insert(id)
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title; content.body = body; content.sound = .default
|
||||
content.categoryIdentifier = Self.habitCatId
|
||||
content.userInfo = ["habitType": habitType]
|
||||
var comps = DateComponents(); comps.hour = hour; comps.minute = minute
|
||||
center.add(UNNotificationRequest(
|
||||
identifier: id, content: content,
|
||||
@@ -222,7 +234,8 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
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."
|
||||
body: "Have you made your bed? Small win, great start to the day.",
|
||||
habitType: "bed"
|
||||
)
|
||||
|
||||
// Prayer — up to 4 independently-toggleable times a day.
|
||||
@@ -234,7 +247,8 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
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?"
|
||||
body: "Have you had a chance to pray and give thanks?",
|
||||
habitType: "prayer"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -247,7 +261,8 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
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?"
|
||||
body: "Time for your \(slot.key.lowercased()) coffee?",
|
||||
habitType: "coffee"
|
||||
)
|
||||
}
|
||||
let customLabel = ud.string(forKey: "coffeeCustomLabel") ?? "Coffee"
|
||||
@@ -257,7 +272,8 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
hour: ud.object(forKey: "coffeeCustomHour") as? Int ?? 16,
|
||||
minute: ud.object(forKey: "coffeeCustomMinute") as? Int ?? 30,
|
||||
title: customLabel,
|
||||
body: "Time for \(customLabel.lowercased())?"
|
||||
body: "Time for \(customLabel.lowercased())?",
|
||||
habitType: "coffee"
|
||||
)
|
||||
|
||||
// Cancel anything previously scheduled that isn't wanted this pass
|
||||
@@ -271,6 +287,31 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
center.removePendingNotificationRequests(withIdentifiers: Array(allPossibleIds.subtracting(wanted)))
|
||||
}
|
||||
|
||||
// MARK: - Habit "Done" logging (private, lightweight — no task-list footprint)
|
||||
|
||||
private static let habitLogKeyPrefix = "kisani.habit.doneDates."
|
||||
|
||||
/// Log today as done for a habit type ("bed"/"prayer"/"coffee"), once per
|
||||
/// day (idempotent — tapping Done twice doesn't double-count). Mirrors the
|
||||
/// app's "lifetime tally, never breaks" streak philosophy (see workout
|
||||
/// streakDays) rather than a fragile consecutive-day streak.
|
||||
private func logHabitDone(type: String) {
|
||||
let ud = UserDefaults.kisani
|
||||
let key = Self.habitLogKeyPrefix + type
|
||||
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
|
||||
let today = fmt.string(from: Date())
|
||||
var dates = Set(ud.stringArray(forKey: key) ?? [])
|
||||
guard !dates.contains(today) else { return }
|
||||
dates.insert(today)
|
||||
ud.set(Array(dates), forKey: key)
|
||||
}
|
||||
|
||||
/// Total distinct days logged done for a habit type — for a small "X days"
|
||||
/// badge in Settings. Never surfaced in Today/Matrix/Activity History.
|
||||
func habitDoneCount(type: String) -> Int {
|
||||
(UserDefaults.kisani.stringArray(forKey: Self.habitLogKeyPrefix + type) ?? []).count
|
||||
}
|
||||
|
||||
// MARK: - Analytics report notifications (deep-linked)
|
||||
|
||||
/// Fire one local notification for each newly-generated weekly / monthly
|
||||
@@ -712,6 +753,9 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
|
||||
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false)
|
||||
))
|
||||
|
||||
case Self.habitDoneActId:
|
||||
if let habitType = info["habitType"] as? String { logHabitDone(type: habitType) }
|
||||
|
||||
default:
|
||||
if let taskId { markTaskComplete(taskId: taskId, userId: userId) }
|
||||
Task { @MainActor in
|
||||
|
||||
@@ -941,7 +941,10 @@ private struct HabitRemindersSheet: View {
|
||||
.font(.system(size: 13)).foregroundColor(AppColors.yellow)
|
||||
.frame(width: 28, height: 28).background(AppColors.yellowSoft).cornerRadius(8)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 6) {
|
||||
Text("Prayer reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||||
habitBadge("prayer")
|
||||
}
|
||||
Text("\"Have you had a chance to pray and give thanks?\"")
|
||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
@@ -970,7 +973,10 @@ private struct HabitRemindersSheet: View {
|
||||
.font(.system(size: 13)).foregroundColor(AppColors.blue)
|
||||
.frame(width: 28, height: 28).background(AppColors.blueSoft).cornerRadius(8)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 6) {
|
||||
Text("Make your bed").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||||
habitBadge("bed")
|
||||
}
|
||||
Text("The simplest task that starts the day with a win.")
|
||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
@@ -996,7 +1002,10 @@ private struct HabitRemindersSheet: View {
|
||||
.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) {
|
||||
HStack(spacing: 6) {
|
||||
Text("Coffee reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||||
habitBadge("coffee")
|
||||
}
|
||||
Text("Track when you usually reach for a cup.")
|
||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
@@ -1051,6 +1060,16 @@ private struct HabitRemindersSheet: View {
|
||||
: ["Morning": 8, "Midday": 12, "Afternoon": 15, "Evening": 18]
|
||||
return table[slot] ?? 9
|
||||
}
|
||||
|
||||
/// A quiet "X days" badge from tapping "Done" on a habit's notifications —
|
||||
/// a private lifetime tally, never shown in Today/Matrix/Activity History.
|
||||
@ViewBuilder
|
||||
private func habitBadge(_ type: String) -> some View {
|
||||
let count = NotificationManager.shared.habitDoneCount(type: type)
|
||||
if count > 0 {
|
||||
AppBadge(text: "\(count)d", bg: AppColors.greenSoft, fg: AppColors.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One named, independently-toggleable daily time slot (e.g. "Morning") — used
|
||||
|
||||
Reference in New Issue
Block a user