From 9723775cb6cbbd52f5aeabbee80b710d46b496b5 Mon Sep 17 00:00:00 2001 From: kutesir Date: Thu, 9 Jul 2026 13:33:32 +0300 Subject: [PATCH] onboarding+liveactivity+habits: 3 fixes from device feedback (KC-61) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Onboarding Habits caption now explicitly mentions the toggles are changeable in Settings, not just times/custom reminder. 2. TaskLiveActivity: removed the checkbox-look circle glyph (hourglass instead). iOS has no API for an app to block the system's swipe/dismiss gesture on a Live Activity -- not attempted. Added a real Stop control via a new StopCountdownIntent (LiveActivityIntent, iOS 17+) embedded directly in the card (lock screen '×' + Dynamic Island 'Stop Countdown' button) -- the actual iOS-supported equivalent of 'long press for options', since Live Activities don't support notification-style long-press menus. 3. Habit notifications gain 'Not Today' (dismiss only, intentional no-op -- the lifetime-tally philosophy has no concept of a logged miss) and 'Stop Tracking' (destructive-styled; flips the habit's master toggle off and cancels every pending notification for that type across all its slots). Co-Authored-By: Claude Opus 4.8 --- KisaniCal.xcodeproj/project.pbxproj | 4 ++ KisaniCal/ISSUES.md | 56 ++++++++++++++++++++ KisaniCal/Managers/NotificationManager.swift | 31 ++++++++++- KisaniCal/Views/OnboardingView.swift | 2 +- KisaniCalWidgets/StopCountdownIntent.swift | 26 +++++++++ KisaniCalWidgets/TaskLiveActivity.swift | 26 ++++++++- 6 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 KisaniCalWidgets/StopCountdownIntent.swift diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj index d9beb20..f64aa7f 100644 --- a/KisaniCal.xcodeproj/project.pbxproj +++ b/KisaniCal.xcodeproj/project.pbxproj @@ -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 = ""; }; 8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCalWidgets.entitlements; sourceTree = ""; }; 9100804DB1E61EA882CC54DA /* FloatingTabState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingTabState.swift; sourceTree = ""; }; + 91A354921717711EB3C0EE8E /* StopCountdownIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopCountdownIntent.swift; sourceTree = ""; }; 92824ED40ECD41EFD4F78BEC /* ISSUES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ISSUES.md; sourceTree = ""; }; 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; 9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NLRecurrenceParseTests.swift; sourceTree = ""; }; @@ -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 */, diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index eb5e0f4..08aee99 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -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...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). diff --git a/KisaniCal/Managers/NotificationManager.swift b/KisaniCal/Managers/NotificationManager.swift index 5a6a379..60f4099 100644 --- a/KisaniCal/Managers/NotificationManager.swift +++ b/KisaniCal/Managers/NotificationManager.swift @@ -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 diff --git a/KisaniCal/Views/OnboardingView.swift b/KisaniCal/Views/OnboardingView.swift index 762b05f..c1e9f8a 100644 --- a/KisaniCal/Views/OnboardingView.swift +++ b/KisaniCal/Views/OnboardingView.swift @@ -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) diff --git a/KisaniCalWidgets/StopCountdownIntent.swift b/KisaniCalWidgets/StopCountdownIntent.swift new file mode 100644 index 0000000..fc2fcf8 --- /dev/null +++ b/KisaniCalWidgets/StopCountdownIntent.swift @@ -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.activities + where activity.attributes.taskID == taskID { + await activity.end(dismissalPolicy: .immediate) + } + return .result() + } +} diff --git a/KisaniCalWidgets/TaskLiveActivity.swift b/KisaniCalWidgets/TaskLiveActivity.swift index 278ff30..bc54ff8 100644 --- a/KisaniCalWidgets/TaskLiveActivity.swift +++ b/KisaniCalWidgets/TaskLiveActivity.swift @@ -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: {