// // CalendarPicker.swift // RWPureSwift // // Created by Christopher Hotchkiss on 2/14/26. // import AppTypes import CalendarAsync import ComposableArchitecture import Dao import Dependencies @preconcurrency import EventKit import SwiftUI @Reducer public struct CalendarPickerFeature { @Dependency(\.calendarAsync) var calendarAsync @ObservableState public struct State: Equatable { @Shared(.appStorage(CALENDAR_SETTING_KEY)) var selectedCalendar: CalendarId? @Shared(.syncedSetting(CALENDAR_DESCRIPTOR_SETTING_KEY)) var calendarDescriptor: CalendarDescriptor? var calendarStatus: EKAuthorizationStatus = .notDetermined var availableCalendars: [EKCalendar]? /// A synced pick exists but no local calendar matched it. public var needsRePick: Bool { selectedCalendar == nil && calendarDescriptor != nil && calendarDescriptor != .noSelection } public init() {} } public enum Action { case onAppear case selectCalendar(CalendarId?) case tapOpenSettings case tapAuthorizeAccess case authorizationComplete(Bool) case loadListComplete([EKCalendar]?) } public init() {} public var body: some Reducer { Reduce { state, action in switch action { case .onAppear: state.calendarStatus = calendarAsync.calendarAccess() return loadList(state: &state) case let .selectCalendar(calendar): state.$selectedCalendar.withLock { $0 = calendar } // Sync title+source so other devices can resolve the same // calendar; an explicit "None" pick syncs as .noSelection. // A failed lookup writes nothing - never clobber a synced // descriptor with a guess. if let calendar { if let matched = state.availableCalendars? .first(where: { $0.calendarIdentifier == calendar.rawValue }) { state.$calendarDescriptor.withLock { $0 = CalendarDescriptor( title: matched.title, sourceTitle: matched.source?.title ?? "" ) } } } else { state.$calendarDescriptor.withLock { $0 = .noSelection } } return .none case .tapOpenSettings: return .run { [calendarAsync] send in await calendarAsync.openCalendarSettings() } case .tapAuthorizeAccess: return .run { [calendarAsync] send in let granted = try await calendarAsync.requestAccess() await send(.authorizationComplete(granted)) } case let .authorizationComplete(granted): state.calendarStatus = calendarAsync.calendarAccess() if granted { return loadList(state: &state) } return .none case let .loadListComplete(list): state.availableCalendars = list return .none } } } func loadList(state: inout State) -> Effect { if state.calendarStatus != .fullAccess { state.availableCalendars = nil return .none } return .run { [calendarAsync] send in let calendars = calendarAsync.getCalendars() await send(.loadListComplete(calendars.isEmpty ? nil : calendars)) } } } public struct CalendarPickerView: View { let store: StoreOf public init(store: StoreOf) { self.store = store } public var body: some View { HStack { if store.calendarStatus == .denied { Text("In order to use calendar events you will need to allow calendar access in the Settings App.") Button("Open Settings Application") { store.send(.tapOpenSettings) } } else if store.calendarStatus == .restricted { Text("In order to use calendar events you will need to allow calendar access from Screen Time.") Button("Open Settings Application") { store.send(.tapOpenSettings) } } else if store.calendarStatus != .fullAccess { Button("Authorize Calendar Access") { store.send(.tapAuthorizeAccess) } } else if store.availableCalendars == nil { ContentUnavailableView("No Calendars Found", systemImage: "calendar") } else { VStack(alignment: .leading) { if store.needsRePick { Label( "Synced calendar \"\(store.calendarDescriptor?.title ?? "")\" isn't on this device — pick again.", systemImage: "exclamationmark.triangle" ) .foregroundStyle(.orange) .accessibilityIdentifier("CalendarNeedsRePick") } Picker("Calendars", selection: Binding( get: { store.selectedCalendar }, set: { store.send(.selectCalendar($0)) } )) { Text("None").tag(nil as CalendarId?) ForEach(store.availableCalendars!, id: \.calendarIdentifier) { calendar in Text(calendar.title).tag(CalendarId(calendar.calendarIdentifier) as CalendarId?) } } #if !os(macOS) .pickerStyle(.navigationLink) #endif } } }.onAppear { store.send(.onAppear) } } } #Preview { let _ = prepareDependencies { $0.defaultDatabase = try! $0.appDatabase() } NavigationStack { Form { CalendarPickerView( store: Store( initialState: CalendarPickerFeature.State() ) { CalendarPickerFeature() } ) } } }