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:
@@ -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