Share a single CalendarStore for unified permission state

Introduce CalendarStore.shared so Today, Calendar, and Onboarding read
the same auth status and event cache instead of each spinning up its own
EKEventStore. Trust the system's authoritative authorization status over
the granted bool, debounce repeat permission requests, route denied users
to Settings, and prompt when enabling local calendars without access.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-06-07 03:27:24 +03:00
parent c0e80ab4ce
commit 0a4545f042
6 changed files with 62 additions and 56 deletions

View File

@@ -79,7 +79,7 @@
0506183945D16EC443A69651 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; }; 0506183945D16EC443A69651 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLiveActivity.swift; sourceTree = "<group>"; }; 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLiveActivity.swift; sourceTree = "<group>"; };
0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = "<group>"; }; 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = "<group>"; };
0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = KisaniCalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = KisaniCalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; }; 106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = "<group>"; }; 1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = "<group>"; };
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksWidget.swift; sourceTree = "<group>"; }; 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksWidget.swift; sourceTree = "<group>"; };
@@ -103,7 +103,7 @@
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>"; };
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>"; };
9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; }; 9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; };
ADF6CCD95A587E26E30F5712 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = "<group>"; }; ADF6CCD95A587E26E30F5712 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = "<group>"; };
AF905C574F34B4EE51A8D21E /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = "<group>"; }; AF905C574F34B4EE51A8D21E /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = "<group>"; };
BC5E179A9189B0A8C3F856F6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; BC5E179A9189B0A8C3F856F6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@@ -286,7 +286,7 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = YES; BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1500; LastUpgradeCheck = 2620;
TargetAttributes = { TargetAttributes = {
28B43516AD88946E21D9BFE0 = { 28B43516AD88946E21D9BFE0 = {
DevelopmentTeam = K8BLMMR883; DevelopmentTeam = K8BLMMR883;
@@ -465,6 +465,7 @@
DEVELOPMENT_TEAM = K8BLMMR883; DEVELOPMENT_TEAM = K8BLMMR883;
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -479,6 +480,7 @@
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -494,7 +496,11 @@
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = KisaniCalWidgets/Info.plist; INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets; PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_VERSION = 5.9; SWIFT_VERSION = 5.9;
@@ -541,6 +547,7 @@
DEVELOPMENT_TEAM = K8BLMMR883; DEVELOPMENT_TEAM = K8BLMMR883;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO; GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
@@ -562,6 +569,7 @@
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -577,7 +585,11 @@
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = KisaniCalWidgets/Info.plist; INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets; PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_VERSION = 5.9; SWIFT_VERSION = 5.9;

View File

@@ -1,11 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1500" LastUpgradeVersion = "2620"
version = "1.7"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES" buildImplicitDependencies = "YES">
runPostActionsOnFailure = "NO">
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
@@ -27,8 +26,7 @@
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES" shouldUseLaunchSchemeArgsEnv = "YES">
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion> <MacroExpansion>
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
@@ -40,8 +38,6 @@
</MacroExpansion> </MacroExpansion>
<Testables> <Testables>
</Testables> </Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Debug" buildConfiguration = "Debug"
@@ -63,8 +59,6 @@
ReferencedContainer = "container:KisaniCal.xcodeproj"> ReferencedContainer = "container:KisaniCal.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"
@@ -82,8 +76,6 @@
ReferencedContainer = "container:KisaniCal.xcodeproj"> ReferencedContainer = "container:KisaniCal.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction> </ProfileAction>
<AnalyzeAction <AnalyzeAction
buildConfiguration = "Debug"> buildConfiguration = "Debug">

View File

@@ -5,7 +5,12 @@ import EventKitUI
// MARK: - Calendar Store // MARK: - Calendar Store
@MainActor @MainActor
final class CalendarStore: ObservableObject { final class CalendarStore: ObservableObject {
/// Single shared source of truth so every screen (Today, Calendar, Onboarding,
/// Settings) sees the same permission state and event cache.
static let shared = CalendarStore()
@Published var authStatus: EKAuthorizationStatus = .notDetermined @Published var authStatus: EKAuthorizationStatus = .notDetermined
@Published private(set) var isRequesting = false
@Published var monthEvents: [EKEvent] = [] @Published var monthEvents: [EKEvent] = []
private let ekStore = EKEventStore() private let ekStore = EKEventStore()
@@ -63,20 +68,32 @@ final class CalendarStore: ObservableObject {
// MARK: - Permission // MARK: - Permission
func requestAccess() { func requestAccess() {
// Debounce rapid taps; only the system prompt can move us off .notDetermined.
guard !isRequesting else { return }
guard authStatus == .notDetermined else {
// Already decided (granted/denied/write-only) re-sync from the system.
refreshStatus()
return
}
isRequesting = true
// Trust the system's authoritative status, not just the `granted` bool.
let finish: () -> Void = { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.authStatus = EKEventStore.authorizationStatus(for: .event)
self.isRequesting = false
if self.isAuthorized {
self.fetchMonth(containing: self.fetchedMonth ?? Date())
self.loadLocalCalendars()
}
}
}
if #available(iOS 17.0, *) { if #available(iOS 17.0, *) {
ekStore.requestFullAccessToEvents { [weak self] granted, _ in ekStore.requestFullAccessToEvents { _, _ in finish() }
Task { @MainActor [weak self] in
self?.authStatus = granted ? .fullAccess : .denied
if granted { self?.fetchMonth(containing: Date()); self?.loadLocalCalendars() }
}
}
} else { } else {
ekStore.requestAccess(to: .event) { [weak self] granted, _ in ekStore.requestAccess(to: .event) { _, _ in finish() }
Task { @MainActor [weak self] in
self?.authStatus = granted ? .authorized : .denied
if granted { self?.fetchMonth(containing: Date()); self?.loadLocalCalendars() }
}
}
} }
} }
@@ -158,6 +175,8 @@ final class CalendarStore: ObservableObject {
func setLocalCalendarsEnabled(_ enabled: Bool) { func setLocalCalendarsEnabled(_ enabled: Bool) {
localCalendarsEnabled = enabled localCalendarsEnabled = enabled
saveCalPrefs() saveCalPrefs()
// Turning it on with no access yet should prompt, not silently no-op.
if enabled && !isAuthorized { requestAccess() }
if let m = fetchedMonth { fetchMonth(containing: m) } if let m = fetchedMonth { fetchMonth(containing: m) }
} }
@@ -231,7 +250,7 @@ struct CalendarView: View {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@EnvironmentObject var taskVM: TaskViewModel @EnvironmentObject var taskVM: TaskViewModel
@EnvironmentObject var workoutVM: WorkoutViewModel @EnvironmentObject var workoutVM: WorkoutViewModel
@StateObject private var calStore = CalendarStore() @StateObject private var calStore = CalendarStore.shared
@State private var selectedDate: Date = Date() @State private var selectedDate: Date = Date()
@State private var displayMonth: Date = { @State private var displayMonth: Date = {
let c = Calendar.current let c = Calendar.current

View File

@@ -26,12 +26,10 @@ struct OnboardingView: View {
@State private var weightStr = "" @State private var weightStr = ""
@State private var heightStr = "" @State private var heightStr = ""
@State private var selectedDays: Set<Int> = [] @State private var selectedDays: Set<Int> = []
@State private var calAuthStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .event) @ObservedObject private var calStore = CalendarStore.shared
@ObservedObject private var hk = HealthKitManager.shared @ObservedObject private var hk = HealthKitManager.shared
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
private let ekStore = EKEventStore()
private let timeSlots: [(Int, String, String)] = [ private let timeSlots: [(Int, String, String)] = [
(0, "Morning", "sunrise.fill"), (0, "Morning", "sunrise.fill"),
(1, "Midday", "sun.max.fill"), (1, "Midday", "sun.max.fill"),
@@ -41,10 +39,7 @@ struct OnboardingView: View {
fileprivate enum Field { case weight, height } fileprivate enum Field { case weight, height }
private let weekdays: [(Int, String)] = [(2,"M"),(3,"T"),(4,"W"),(5,"T"),(6,"F"),(7,"S"),(1,"S")] private let weekdays: [(Int, String)] = [(2,"M"),(3,"T"),(4,"W"),(5,"T"),(6,"F"),(7,"S"),(1,"S")]
private var calIsGranted: Bool { private var calIsGranted: Bool { calStore.isAuthorized }
if #available(iOS 17.0, *) { return calAuthStatus == .fullAccess }
return calAuthStatus == .authorized
}
private var liveScheme: ColorScheme? { private var liveScheme: ColorScheme? {
switch localAppearance { switch localAppearance {
@@ -118,7 +113,7 @@ struct OnboardingView: View {
.font(.system(size: 22)) .font(.system(size: 22))
.foregroundColor(AppColors.green) .foregroundColor(AppColors.green)
.transition(.scale.combined(with: .opacity)) .transition(.scale.combined(with: .opacity))
} else if calAuthStatus == .denied { } else if calStore.isDenied {
Text("Denied") Text("Denied")
.font(AppFonts.mono(10, weight: .bold)) .font(AppFonts.mono(10, weight: .bold))
.foregroundColor(AppColors.accent) .foregroundColor(AppColors.accent)
@@ -137,7 +132,7 @@ struct OnboardingView: View {
.buttonStyle(PressButtonStyle(scale: 0.92)) .buttonStyle(PressButtonStyle(scale: 0.92))
} }
} }
.animation(KisaniSpring.micro, value: calAuthStatus.rawValue) .animation(KisaniSpring.micro, value: calStore.authStatus.rawValue)
} }
.padding(.horizontal, 14).padding(.vertical, 14) .padding(.horizontal, 14).padding(.vertical, 14)
.cardStyle() .cardStyle()
@@ -551,29 +546,17 @@ struct OnboardingView: View {
} }
} }
.onAppear { .onAppear {
calAuthStatus = EKEventStore.authorizationStatus(for: .event) calStore.refreshStatus()
} }
} }
// MARK: - Actions // MARK: - Actions
private func requestCalendar() { private func requestCalendar() {
if #available(iOS 17.0, *) { if calStore.isDenied, let url = URL(string: UIApplication.openSettingsURLString) {
ekStore.requestFullAccessToEvents { granted, _ in UIApplication.shared.open(url)
Task { @MainActor in
withAnimation(KisaniSpring.snappy) {
self.calAuthStatus = granted ? .fullAccess : .denied
}
}
}
} else { } else {
ekStore.requestAccess(to: .event) { granted, _ in calStore.requestAccess()
Task { @MainActor in
withAnimation(KisaniSpring.snappy) {
self.calAuthStatus = granted ? .authorized : .denied
}
}
}
} }
} }

View File

@@ -6,7 +6,7 @@ struct TodayView: View {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@EnvironmentObject var taskVM: TaskViewModel @EnvironmentObject var taskVM: TaskViewModel
@EnvironmentObject var workoutVM: WorkoutViewModel @EnvironmentObject var workoutVM: WorkoutViewModel
@StateObject private var calStore = CalendarStore() @StateObject private var calStore = CalendarStore.shared
@State private var showAddTask = false @State private var showAddTask = false
@State private var showWorkoutSession = false @State private var showWorkoutSession = false
@State private var editingTask: TaskItem? = nil @State private var editingTask: TaskItem? = nil