Calendar: shared store reliability + event notifications
KC-5: Consolidate calendar access into CalendarStore.shared so Today, Calendar, Onboarding, and Settings share one permission state and event cache. requestAccess() debounces and reads authoritative status; turning on "Show Calendar Events" while unauthorized now prompts. New Settings → Data → Calendar row shows Connected/Connect/Denied. KC-6: Add calendar-event notifications. CalendarStore.upcomingEventNotifs snapshots visible events (next 7 days); NotificationManager schedules at start time plus each event alarm (all-day at 9am), capped at ~20 to stay under iOS's 64-notification limit. Wired into reschedule(). Log KC-5 and KC-6 in ISSUES.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -140,3 +140,78 @@ Files: `TaskContextMenu.swift`, `TaskActivityAttributes.swift`,
|
||||
pass (orange accent + timer) and can be refined.
|
||||
- Uses the iOS 16.1 `Activity.request(...contentState:...)` API (deprecation
|
||||
warning on 16.2+, still functional).
|
||||
|
||||
---
|
||||
|
||||
## KC-5 — Calendar "Connect" toggle unreliable / inconsistent across screens
|
||||
|
||||
**Status:** In Progress (implemented & build-verified; full reliability win is device-only)
|
||||
**Reported by:** User
|
||||
**Area:** Calendar / Permissions
|
||||
|
||||
### Description
|
||||
The iPhone Calendar "Connect" sometimes needed repeated taps, onboarding
|
||||
sometimes didn't reflect a granted calendar, and there was no place to see/manage
|
||||
connection status from Settings.
|
||||
|
||||
### Root cause
|
||||
Three independent calendar-permission states that never synced: `TodayView`,
|
||||
`CalendarView` each had their own `CalendarStore` + `EKEventStore`, and
|
||||
`OnboardingView` had its own raw `EKEventStore`. Granting in one left the others
|
||||
showing stale cached status until they happened to re-read it.
|
||||
|
||||
### Fix
|
||||
- `CalendarStore.shared` singleton — one source of truth used by Today, Calendar,
|
||||
Onboarding, and Settings. Connect once, every screen reflects it.
|
||||
- `requestAccess()` now debounces in-flight requests and reads the **authoritative**
|
||||
`EKEventStore.authorizationStatus` instead of trusting the callback bool;
|
||||
already-decided states re-sync via `refreshStatus()`.
|
||||
- Turning on "Show Calendar Events" while unauthorized now triggers the prompt.
|
||||
- New **Settings → Data → Calendar** row showing ● Connected / Connect / Denied,
|
||||
opening the connect sheet.
|
||||
|
||||
Files: `CalendarView.swift`, `TodayView.swift`, `OnboardingView.swift`,
|
||||
`SettingsView.swift`.
|
||||
|
||||
### Verification
|
||||
Builds cleanly. Cross-screen consistency + the Settings status row are testable in
|
||||
the Simulator; the request-reliability fix is best confirmed on a device.
|
||||
|
||||
---
|
||||
|
||||
## KC-6 — No notifications for calendar events
|
||||
|
||||
**Status:** In Progress (implemented & build-verified; fires on a real device with calendar access)
|
||||
**Reported by:** User
|
||||
**Area:** Calendar / Notifications
|
||||
|
||||
### Description
|
||||
Task reminders fired, but calendar events never produced a KisaniCal
|
||||
notification — events with no iOS alert (e.g. subscribed F1/TV feeds) notified
|
||||
the user of nothing.
|
||||
|
||||
### Root cause
|
||||
`NotificationManager` only scheduled workout + per-task notifications. There was
|
||||
no calendar-event path at all (`EKEvent`/`CalendarStore` were never involved).
|
||||
|
||||
### Fix
|
||||
- `CalendarStore.upcomingEventNotifs(days:)` snapshots visible events for the next
|
||||
7 days (respects enabled flag + hidden calendars) with start time + alarm offsets.
|
||||
- `NotificationManager.scheduleCalendarEvents(...)` schedules a notification at the
|
||||
event start ("on time") plus at each alarm the user set; all-day events fire at
|
||||
9am morning-of. Capped at ~20 to stay under iOS's 64 pending-notification limit;
|
||||
recurring instances kept unique via start-time in the id.
|
||||
- Wired into `reschedule(...)` (runs on foreground / scene-active / task changes).
|
||||
|
||||
Files: `CalendarView.swift`, `NotificationManager.swift`.
|
||||
|
||||
### How to test (device, calendar access granted)
|
||||
1. Create an event a few minutes out with **no alert** on a visible calendar.
|
||||
2. Background the app (triggers a reschedule).
|
||||
3. Notification fires at the event's start time.
|
||||
|
||||
### Known limitations / decisions
|
||||
- "All visible events" was chosen, so personal events that already have an iOS
|
||||
alert may double (system + KisaniCal). Feed events (F1/TV) are the main win.
|
||||
- Tapping a calendar notification opens the Today view (no calendar deeplink).
|
||||
- The 7-day window re-scans on each foreground; no background refresh beyond that.
|
||||
|
||||
@@ -79,6 +79,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
let progress = workoutVM.progress
|
||||
let recorded = workoutVM.isTodayRecorded
|
||||
let tasks = taskVM.tasks
|
||||
let calEvents = CalendarStore.shared.upcomingEventNotifs(days: 7)
|
||||
|
||||
center.getNotificationSettings { [weak self] settings in
|
||||
guard let self else { return }
|
||||
@@ -88,10 +89,67 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
self.scheduleWorkout(schedule: schedule, programs: programs, progress: progress, recordedToday: recorded)
|
||||
self.scheduleWorkoutConfirm(schedule: schedule, programs: programs)
|
||||
self.scheduleTasks(snapshot: tasks)
|
||||
self.scheduleCalendarEvents(snapshot: calEvents)
|
||||
self.updateBadge(snapshot: tasks)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Calendar event notifications
|
||||
|
||||
/// Notifies for visible calendar events: at start time ("on time") plus each
|
||||
/// alarm the user set on the event. Capped to stay under iOS's 64-notification
|
||||
/// limit. Recurring instances stay unique via their start time in the id.
|
||||
private func scheduleCalendarEvents(snapshot: [CalEventNotif]) {
|
||||
center.getPendingNotificationRequests { [weak self] pending in
|
||||
guard let self else { return }
|
||||
let old = pending.filter { $0.identifier.hasPrefix("kisani.calevent.") }.map { $0.identifier }
|
||||
self.center.removePendingNotificationRequests(withIdentifiers: old)
|
||||
|
||||
let now = Date()
|
||||
let cal = Calendar.current
|
||||
var budget = 20 // leave headroom under the 64 pending-notification ceiling
|
||||
|
||||
for ev in snapshot where budget > 0 {
|
||||
var fireTimes: [Date] = []
|
||||
if ev.isAllDay {
|
||||
var c = cal.dateComponents([.year, .month, .day], from: ev.start)
|
||||
c.hour = 9; c.minute = 0
|
||||
if let d = cal.date(from: c) { fireTimes.append(d) } // 9am morning-of
|
||||
} else {
|
||||
fireTimes.append(ev.start) // on time
|
||||
for off in ev.alarmOffsets { fireTimes.append(ev.start.addingTimeInterval(off)) }
|
||||
}
|
||||
|
||||
let times = Array(Set(fireTimes)).filter { $0 > now }.sorted()
|
||||
for (i, t) in times.enumerated() where budget > 0 {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = ev.title
|
||||
content.body = self.calEventBody(fire: t, start: ev.start, isAllDay: ev.isAllDay)
|
||||
content.sound = .default
|
||||
content.userInfo = ["deeplink": "calendar"]
|
||||
let comps = cal.dateComponents([.year, .month, .day, .hour, .minute], from: t)
|
||||
let stamp = Int(ev.start.timeIntervalSince1970)
|
||||
self.center.add(UNNotificationRequest(
|
||||
identifier: "kisani.calevent.\(ev.id).\(stamp).\(i)",
|
||||
content: content,
|
||||
trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: false)
|
||||
))
|
||||
budget -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func calEventBody(fire: Date, start: Date, isAllDay: Bool) -> String {
|
||||
if isAllDay { return "Today" }
|
||||
let delta = start.timeIntervalSince(fire)
|
||||
if delta <= 60 { return "Starting now" }
|
||||
let mins = Int((delta / 60).rounded())
|
||||
if mins < 60 { return "Starts in \(mins) min" }
|
||||
let hrs = mins / 60
|
||||
return "Starts in \(hrs)h\(mins % 60 == 0 ? "" : " \(mins % 60)m")"
|
||||
}
|
||||
|
||||
// MARK: - Badge
|
||||
|
||||
private func updateBadge(snapshot: [TaskItem]) {
|
||||
|
||||
@@ -2,6 +2,15 @@ import SwiftUI
|
||||
import EventKit
|
||||
import EventKitUI
|
||||
|
||||
// Plain snapshot of a calendar event for scheduling notifications off the main actor.
|
||||
struct CalEventNotif {
|
||||
let id: String
|
||||
let title: String
|
||||
let start: Date
|
||||
let isAllDay: Bool
|
||||
let alarmOffsets: [TimeInterval] // seconds relative to start (negative = before)
|
||||
}
|
||||
|
||||
// MARK: - Calendar Store
|
||||
@MainActor
|
||||
final class CalendarStore: ObservableObject {
|
||||
@@ -133,6 +142,33 @@ final class CalendarStore: ObservableObject {
|
||||
return authStatus == .authorized
|
||||
}
|
||||
|
||||
/// Upcoming visible events over the next `days`, as plain snapshots for the
|
||||
/// notification scheduler. Honors the calendar-enabled flag and hidden calendars.
|
||||
func upcomingEventNotifs(days: Int) -> [CalEventNotif] {
|
||||
guard isAuthorized, localCalendarsEnabled else { return [] }
|
||||
let now = Date()
|
||||
guard let end = Calendar.current.date(byAdding: .day, value: days, to: now) else { return [] }
|
||||
let cals = ekStore.calendars(for: .event).filter { !hiddenCalendarIDs.contains($0.calendarIdentifier) }
|
||||
guard !cals.isEmpty else { return [] }
|
||||
let pred = ekStore.predicateForEvents(withStart: now, end: end, calendars: cals)
|
||||
return ekStore.events(matching: pred)
|
||||
.sorted { ($0.startDate ?? .distantFuture) < ($1.startDate ?? .distantFuture) }
|
||||
.compactMap { ev in
|
||||
guard let start = ev.startDate else { return nil }
|
||||
let offsets: [TimeInterval] = (ev.alarms ?? []).map { alarm in
|
||||
if let abs = alarm.absoluteDate { return abs.timeIntervalSince(start) }
|
||||
return alarm.relativeOffset
|
||||
}
|
||||
return CalEventNotif(
|
||||
id: ev.eventIdentifier ?? UUID().uuidString,
|
||||
title: ev.title ?? "Event",
|
||||
start: start,
|
||||
isAllDay: ev.isAllDay,
|
||||
alarmOffsets: offsets
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Exposed so the edit sheet can hand it to EKEventEditViewController.
|
||||
var eventStore: EKEventStore { ekStore }
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ struct SettingsView: View {
|
||||
@EnvironmentObject var workoutVM: WorkoutViewModel
|
||||
@EnvironmentObject var taskVM: TaskViewModel
|
||||
@ObservedObject private var auth = AuthManager.shared
|
||||
@ObservedObject private var calStore = CalendarStore.shared
|
||||
|
||||
// Persisted settings
|
||||
@AppStorage("appearanceMode") private var appearanceMode = 0
|
||||
@@ -30,6 +31,7 @@ struct SettingsView: View {
|
||||
@State private var showWidgets = false
|
||||
@State private var showGeneral = false
|
||||
@State private var showImport = false
|
||||
@State private var showCalendar = false
|
||||
@State private var showBackup = false
|
||||
@State private var showWorkoutSettings = false
|
||||
@State private var showRestTimer = false
|
||||
@@ -107,6 +109,7 @@ struct SettingsView: View {
|
||||
|
||||
// ── DATA ──
|
||||
SttSection(label: "Data") {
|
||||
SttNavRow(icon: "calendar", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Calendar", value: calStore.isAuthorized ? "● Connected" : (calStore.isDenied ? "Denied" : "Connect"), valueColor: calStore.isAuthorized ? AppColors.green : nil) { showCalendar = true }
|
||||
SttNavRow(icon: "arrow.up.right", bg: AppColors.greenSoft, fg: AppColors.green, label: "Connected Sources", value: googleCalSync || iCloudCalSync ? "Connected" : nil) { showImport = true }
|
||||
SttNavRow(icon: "cloud", bg: AppColors.blueSoft, fg: AppColors.blue, label: "Library Sync", value: iCloudSync ? "● Synced" : "Off", valueColor: iCloudSync ? AppColors.green : nil, isLast: true) { showBackup = true }
|
||||
}
|
||||
@@ -163,12 +166,14 @@ struct SettingsView: View {
|
||||
.sheet(isPresented: $showWidgets) { WidgetsSheet().presentationDetents([.height(320)]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showGeneral) { GeneralSheet(showCompleted: $showCompleted, mondayFirst: $firstDayMonday).presentationDetents([.height(260)]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showImport) { ImportSheet(googleCal: $googleCalSync, iCloudCal: $iCloudCalSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showCalendar) { CalendarConnectSheet(calStore: calStore).presentationDetents([.large]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showBackup) { BackupSheet(iCloudSync: $iCloudSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showWorkoutSettings) { WorkoutSettingsSheet(unit: $weightUnit, sets: $defaultSets, reps: $defaultReps).environmentObject(workoutVM).presentationDetents([.large]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showRestTimer) { RestTimerSheet(seconds: $workoutVM.restTimerSeconds, enabled: $workoutVM.restTimerEnabled).presentationDetents([.height(360)]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showHelp) { HelpSheet().presentationDetents([.height(300)]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showFollow) { FollowSheet().presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showAbout) { AboutSheet().presentationDetents([.height(340)]).presentationDragIndicator(.visible) }
|
||||
.onAppear { calStore.refreshStatus() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user