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 */; };
|
550E9A86BE36228C705E91C1 /* AnalyticsEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */; };
|
||||||
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */; };
|
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */; };
|
||||||
55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429806CE1021C8DE2EB770CE /* WidgetViews.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 */; };
|
5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */; };
|
||||||
5F91DDE0B64C8AE1F142F434 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A22FCC3FA1A10F52415E5AF /* AnalyticsService.swift */; };
|
5F91DDE0B64C8AE1F142F434 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A22FCC3FA1A10F52415E5AF /* AnalyticsService.swift */; };
|
||||||
67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106EEF572C6F8990408329F0 /* OnboardingView.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NLRecurrenceParseTests.swift; sourceTree = "<group>"; };
|
||||||
@@ -300,6 +302,7 @@
|
|||||||
8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */,
|
8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */,
|
||||||
42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */,
|
42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */,
|
||||||
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */,
|
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */,
|
||||||
|
91A354921717711EB3C0EE8E /* StopCountdownIntent.swift */,
|
||||||
0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */,
|
0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */,
|
||||||
61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */,
|
61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */,
|
||||||
429806CE1021C8DE2EB770CE /* WidgetViews.swift */,
|
429806CE1021C8DE2EB770CE /* WidgetViews.swift */,
|
||||||
@@ -560,6 +563,7 @@
|
|||||||
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */,
|
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */,
|
||||||
C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */,
|
C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */,
|
||||||
3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */,
|
3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */,
|
||||||
|
5D0E254AB5D195AFDE09FABF /* StopCountdownIntent.swift in Sources */,
|
||||||
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */,
|
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */,
|
||||||
205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */,
|
205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */,
|
||||||
AC0D814DA54D5EF5E25CEB99 /* WidgetData.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
|
parens confirmed; wiring mirrors `TodayView`/`WorkoutView`'s existing
|
||||||
`tm.xIsActive` + `InViewTutorialCard` pattern exactly, reusing the same shared
|
`tm.xIsActive` + `InViewTutorialCard` pattern exactly, reusing the same shared
|
||||||
component rather than introducing a new one.
|
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 snoozeWorkoutActId = "SNOOZE_WORKOUT"
|
||||||
private static let habitCatId = "HABIT_LOG"
|
private static let habitCatId = "HABIT_LOG"
|
||||||
private static let habitDoneActId = "HABIT_DONE"
|
private static let habitDoneActId = "HABIT_DONE"
|
||||||
|
private static let habitNotTodayActId = "HABIT_NOT_TODAY"
|
||||||
|
private static let habitStopTrackingActId = "HABIT_STOP_TRACKING"
|
||||||
|
|
||||||
override private init() {
|
override private init() {
|
||||||
super.init()
|
super.init()
|
||||||
@@ -52,7 +54,11 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
|||||||
intentIdentifiers: [], options: .customDismissAction)
|
intentIdentifiers: [], options: .customDismissAction)
|
||||||
|
|
||||||
let habitDoneAction = UNNotificationAction(identifier: Self.habitDoneActId, title: "Done", options: [])
|
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)
|
intentIdentifiers: [], options: .customDismissAction)
|
||||||
|
|
||||||
center.setNotificationCategories([taskCat, confirmCat, habitCat])
|
center.setNotificationCategories([taskCat, confirmCat, habitCat])
|
||||||
@@ -309,6 +315,19 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
|||||||
ud.set(Array(dates), forKey: key)
|
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"
|
/// Total distinct days logged done for a habit type — for a small "X days"
|
||||||
/// badge in Settings. Never surfaced in Today/Matrix/Activity History.
|
/// badge in Settings. Never surfaced in Today/Matrix/Activity History.
|
||||||
func habitDoneCount(type: String) -> Int {
|
func habitDoneCount(type: String) -> Int {
|
||||||
@@ -759,6 +778,16 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
|
|||||||
case Self.habitDoneActId:
|
case Self.habitDoneActId:
|
||||||
if let habitType = info["habitType"] as? String { logHabitDone(type: habitType) }
|
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:
|
default:
|
||||||
if let taskId { markTaskComplete(taskId: taskId, userId: userId) }
|
if let taskId { markTaskComplete(taskId: taskId, userId: userId) }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
|||||||
@@ -540,7 +540,7 @@ struct OnboardingView: View {
|
|||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 8)
|
.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))
|
.font(AppFonts.sans(11))
|
||||||
.foregroundColor(AppColors.text3(cs))
|
.foregroundColor(AppColors.text3(cs))
|
||||||
.multilineTextAlignment(.center)
|
.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
|
ActivityConfiguration(for: TaskActivityAttributes.self) { context in
|
||||||
// ── Lock screen / banner ──
|
// ── Lock screen / banner ──
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "circle")
|
Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "hourglass")
|
||||||
.font(.system(size: 22))
|
.font(.system(size: 20))
|
||||||
.foregroundColor(context.state.isComplete ? .green : accent)
|
.foregroundColor(context.state.isComplete ? .green : accent)
|
||||||
.frame(width: 24)
|
.frame(width: 24)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
@@ -40,6 +40,18 @@ struct TaskLiveActivity: Widget {
|
|||||||
.frame(width: 92, alignment: .trailing)
|
.frame(width: 92, alignment: .trailing)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.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(.leading, 14)
|
||||||
.padding(.trailing, 10)
|
.padding(.trailing, 10)
|
||||||
@@ -69,6 +81,16 @@ struct TaskLiveActivity: Widget {
|
|||||||
.font(.system(.callout, design: .monospaced))
|
.font(.system(.callout, design: .monospaced))
|
||||||
.lineLimit(1)
|
.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: {
|
} compactLeading: {
|
||||||
Image(systemName: "checklist").foregroundColor(accent)
|
Image(systemName: "checklist").foregroundColor(accent)
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
|
|||||||
Reference in New Issue
Block a user