Merge develop -> main: full session (KC-51 through KC-71) #28

Merged
kutesir merged 64 commits from develop into main 2026-07-12 22:57:14 +00:00
6 changed files with 141 additions and 4 deletions
Showing only changes of commit 9723775cb6 - Show all commits

View File

@@ -30,6 +30,7 @@
550E9A86BE36228C705E91C1 /* AnalyticsEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */; };
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */; };
55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429806CE1021C8DE2EB770CE /* WidgetViews.swift */; };
5D0E254AB5D195AFDE09FABF /* StopCountdownIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91A354921717711EB3C0EE8E /* StopCountdownIntent.swift */; };
5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */; };
5F91DDE0B64C8AE1F142F434 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A22FCC3FA1A10F52415E5AF /* AnalyticsService.swift */; };
67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106EEF572C6F8990408329F0 /* OnboardingView.swift */; };
@@ -145,6 +146,7 @@
8DC8687EA2FBA9FB2EEE51C6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCalWidgets.entitlements; sourceTree = "<group>"; };
9100804DB1E61EA882CC54DA /* FloatingTabState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingTabState.swift; sourceTree = "<group>"; };
91A354921717711EB3C0EE8E /* StopCountdownIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopCountdownIntent.swift; sourceTree = "<group>"; };
92824ED40ECD41EFD4F78BEC /* ISSUES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ISSUES.md; sourceTree = "<group>"; };
93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; };
9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NLRecurrenceParseTests.swift; sourceTree = "<group>"; };
@@ -300,6 +302,7 @@
8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */,
42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */,
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */,
91A354921717711EB3C0EE8E /* StopCountdownIntent.swift */,
0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */,
61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */,
429806CE1021C8DE2EB770CE /* WidgetViews.swift */,
@@ -560,6 +563,7 @@
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */,
C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */,
3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */,
5D0E254AB5D195AFDE09FABF /* StopCountdownIntent.swift in Sources */,
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */,
205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */,
AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */,

View File

@@ -2062,3 +2062,59 @@ Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
parens confirmed; wiring mirrors `TodayView`/`WorkoutView`'s existing
`tm.xIsActive` + `InViewTutorialCard` pattern exactly, reusing the same shared
component rather than introducing a new one.
---
## KC-61 — Three fixes: onboarding note, Live Activity circle/stop control, habit notification actions
**Status:** Implemented (not build-verified)
**Reported by:** User (screenshot: a real device Live Activity + delivered
"A moment to pause" prayer notification — confirms KC-55/56 are actually
firing correctly on-device)
### 1. Onboarding: clearer "change in Settings" note
The Habits card's caption only mentioned times/custom-reminder, not the
toggles themselves. Reworded: "You can turn any of these on or off — or
fine-tune the exact times, or add a custom coffee reminder — anytime in
Settings → Habits."
### 2. Live Activity: removed the checkbox-look circle, added a Stop control
`TaskLiveActivity`'s lock-screen card used `circle`/`checkmark.circle.fill` —
read as an unchecked checkbox on a pure countdown display. Two honest
technical notes acted on:
- iOS gives apps **no API to block the system's own dismiss gesture** on a
Live Activity — not something any app can control, confirmed against
ActivityKit's public surface. Not attempted.
- True "long-press reveals a menu" isn't how Live Activities work (that's
specific to `UNNotificationCategory` actions). The correct iOS 17+
equivalent is a **button embedded directly in the card**.
Changes:
- Circle → `hourglass` (informational, not checkbox-shaped) when incomplete.
- New `StopCountdownIntent` (`LiveActivityIntent`, widget-extension target)
ends the activity via `Activity<TaskActivityAttributes>...end()` directly
from the card — no app launch needed.
- Wired as an "×" button on the lock-screen card and a labeled "Stop
Countdown" button in the Dynamic Island's expanded region, both iOS 17+
gated (graceful no-op below that, matching the file's existing
`@available(iOS 16.1, *)` pattern).
### 3. Habit notifications: two more actions beyond "Done"
Consolidated the requested "not done / not today / don't track for today /
mute for today / stop tracking" into two clear, non-redundant actions:
- **"Not Today"** — dismisses only; intentionally a no-op beyond that, since
the habit tally only ever counts "Done" taps (KC-40's lifetime-tally
philosophy has no concept of a logged miss). Gives an explicit, discoverable
"I saw it, skipping" action instead of relying on a swipe.
- **"Stop Tracking"** (`.destructive` style) — flips that habit's master
toggle off (so Settings reflects it) and immediately cancels every pending
notification for that type across all its slots, not just the one tapped.
Files: `OnboardingView.swift`, `TaskLiveActivity.swift`,
`StopCountdownIntent.swift` (new), `NotificationManager.swift`.
### Note
Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/
parens confirmed per-file. `StopCountdownIntent` needs to land in the
KisaniCalWidgets target (already covered by `project.yml`'s `sources: -path:
KisaniCalWidgets` glob — no project.yml change needed).

View File

@@ -22,6 +22,8 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
private static let snoozeWorkoutActId = "SNOOZE_WORKOUT"
private static let habitCatId = "HABIT_LOG"
private static let habitDoneActId = "HABIT_DONE"
private static let habitNotTodayActId = "HABIT_NOT_TODAY"
private static let habitStopTrackingActId = "HABIT_STOP_TRACKING"
override private init() {
super.init()
@@ -52,7 +54,11 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
intentIdentifiers: [], options: .customDismissAction)
let habitDoneAction = UNNotificationAction(identifier: Self.habitDoneActId, title: "Done", options: [])
let habitCat = UNNotificationCategory(identifier: Self.habitCatId, actions: [habitDoneAction],
let habitNotTodayAction = UNNotificationAction(identifier: Self.habitNotTodayActId, title: "Not Today", options: [])
let habitStopAction = UNNotificationAction(identifier: Self.habitStopTrackingActId, title: "Stop Tracking",
options: .destructive)
let habitCat = UNNotificationCategory(identifier: Self.habitCatId,
actions: [habitDoneAction, habitNotTodayAction, habitStopAction],
intentIdentifiers: [], options: .customDismissAction)
center.setNotificationCategories([taskCat, confirmCat, habitCat])
@@ -309,6 +315,19 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
ud.set(Array(dates), forKey: key)
}
/// Turn off future reminders for a habit type (from its notification's
/// "Stop Tracking" action) flips the master toggle so Settings reflects
/// it too, and immediately cancels every pending notification for that
/// type (all its slots), not just the one just tapped.
private func stopTrackingHabit(type: String) {
UserDefaults.kisani.set(false, forKey: "\(type)ReminderEnabled")
let prefix = "\(Self.habitPrefix).\(type)"
center.getPendingNotificationRequests { [weak self] requests in
let ids = requests.map(\.identifier).filter { $0.hasPrefix(prefix) }
self?.center.removePendingNotificationRequests(withIdentifiers: ids)
}
}
/// 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 {
@@ -759,6 +778,16 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
case Self.habitDoneActId:
if let habitType = info["habitType"] as? String { logHabitDone(type: habitType) }
case Self.habitNotTodayActId:
// Intentionally a no-op beyond dismissing the habit tally only
// ever counts "Done" taps (KC-40's lifetime-tally philosophy), so
// there's nothing to log for a skip. This just gives an explicit,
// discoverable "I saw it, skipping" action instead of only a swipe.
break
case Self.habitStopTrackingActId:
if let habitType = info["habitType"] as? String { stopTrackingHabit(type: habitType) }
default:
if let taskId { markTaskComplete(taskId: taskId, userId: userId) }
Task { @MainActor in

View File

@@ -540,7 +540,7 @@ struct OnboardingView: View {
.padding(.horizontal, 20)
.padding(.bottom, 8)
Text("Times default to typical hours — fine-tune each one, or add a custom coffee reminder, in Settings.")
Text("You can turn any of these on or off — or fine-tune the exact times, or add a custom coffee reminder — anytime in Settings → Habits.")
.font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs))
.multilineTextAlignment(.center)

View File

@@ -0,0 +1,26 @@
import AppIntents
import ActivityKit
/// Ends a running task Live Activity directly from its Lock Screen / Dynamic
/// Island card no need to open the app. Runs in the widget extension
/// process (that's how LiveActivityIntent works), so it talks to ActivityKit
/// directly rather than going through LiveActivityManager (app target only).
@available(iOS 17.0, *)
struct StopCountdownIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Stop Countdown"
static var description = IntentDescription("Ends this task's live countdown.")
@Parameter(title: "Task ID")
var taskID: String
init() {}
init(taskID: String) { self.taskID = taskID }
func perform() async throws -> some IntentResult {
for activity in Activity<TaskActivityAttributes>.activities
where activity.attributes.taskID == taskID {
await activity.end(dismissalPolicy: .immediate)
}
return .result()
}
}

View File

@@ -10,8 +10,8 @@ struct TaskLiveActivity: Widget {
ActivityConfiguration(for: TaskActivityAttributes.self) { context in
// Lock screen / banner
HStack(spacing: 12) {
Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "circle")
.font(.system(size: 22))
Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "hourglass")
.font(.system(size: 20))
.foregroundColor(context.state.isComplete ? .green : accent)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
@@ -40,6 +40,18 @@ struct TaskLiveActivity: Widget {
.frame(width: 92, alignment: .trailing)
.fixedSize(horizontal: false, vertical: true)
}
// Explicit stop control a Live Activity can't be prevented
// from being swiped away by the system, so this gives an
// intentional, in-card way to end tracking instead.
if #available(iOS 17.0, *), !context.state.isComplete {
Button(intent: StopCountdownIntent(taskID: context.attributes.taskID)) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 18))
.foregroundColor(.white.opacity(0.5))
}
.buttonStyle(.plain)
}
}
.padding(.leading, 14)
.padding(.trailing, 10)
@@ -69,6 +81,16 @@ struct TaskLiveActivity: Widget {
.font(.system(.callout, design: .monospaced))
.lineLimit(1)
}
DynamicIslandExpandedRegion(.bottom) {
if #available(iOS 17.0, *), !context.state.isComplete {
Button(intent: StopCountdownIntent(taskID: context.attributes.taskID)) {
Label("Stop Countdown", systemImage: "xmark.circle")
.font(.system(.caption, design: .monospaced))
}
.buttonStyle(.plain)
.tint(accent)
}
}
} compactLeading: {
Image(systemName: "checklist").foregroundColor(accent)
} compactTrailing: {