Merge develop -> main: full session (KC-51 through KC-71) #28
@@ -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 */,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
26
KisaniCalWidgets/StopCountdownIntent.swift
Normal file
26
KisaniCalWidgets/StopCountdownIntent.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user