Christopher Hotchkiss Christopher Hotchkiss
Login

Code

remindwall / RWPureSwift / Sources / EditSettingsNew / TopLevel / CalendarPicker.swift

Back to remindwall — Current RemindWall

Download this file

//
//  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<State, Action> {
        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<Action> {
        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<CalendarPickerFeature>

    public init(store: StoreOf<CalendarPickerFeature>) {
        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()
                }
            )
        }
    }
}