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:
kutesir
2026-06-07 04:08:23 +03:00
parent 0a4545f042
commit 196dd7b78e
7 changed files with 283 additions and 5 deletions

View File

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

View File

@@ -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() }
}
}