Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Bash(git:*)",
"Bash(gh:*)"
],
"ask": [
"Bash(git push --force:*)",
"Bash(git push -f:*)",
"Bash(gh repo delete:*)"
]
}
}
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: CI

on:
push:
branches: [master, modernize]
branches: [main, modernize]
pull_request:

env:
Expand Down Expand Up @@ -32,6 +32,7 @@ jobs:
run: |
set -o pipefail
xcodebuild docbuild -scheme KDCalendar -destination "$DESTINATION" -derivedDataPath DerivedData | xcbeautify --renderer github-actions || xcodebuild docbuild -scheme KDCalendar -destination "$DESTINATION" -derivedDataPath DerivedData
xcodebuild docbuild -scheme KDCalendarEventKit -destination "$DESTINATION" -derivedDataPath DerivedData | xcbeautify --renderer github-actions || xcodebuild docbuild -scheme KDCalendarEventKit -destination "$DESTINATION" -derivedDataPath DerivedData
- name: Build example app
run: |
set -o pipefail
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ All notable changes to KDCalendar are documented here. The format follows

## [Unreleased]

### Added

- `CalendarEventStore`, the part of `EKEventStore` the EventKit bridge uses, and
`EventsManager.store` to inject a test double. The bridge has tests now.

### Fixed

- `loadEvents()` stopped at the start of the last day of the range, so events
later on that day were missed.
- The header did not mirror when a calendar was right-to-left on its own
rather than through the app's layout direction.
- A `CalendarView` decoded from an archive crashed in the header's
`init(coder:)`; the view, header and cells now decode and rebuild themselves.

## [2.0.1] - 2026-09-08

### Fixed

- An event with a far-off end date, such as an open-ended system event, was
Expand Down
4 changes: 2 additions & 2 deletions KDCalendar.podspec
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
Pod::Spec.new do |s|

s.name = "KDCalendar"
s.version = "2.0.0"
s.version = "2.0.1"
s.summary = "A calendar component with native events support. Prefer Swift Package Manager; CocoaPods support ends after 2.0."

s.description = <<-DESC
A month calendar for iOS written in Swift. It features both vertical and horizontal layout (and scrolling) and, through the optional EventKit subspec, the display of native calendar events.
DESC

s.homepage = "https://github.com/mmick66/CalendarView"
s.screenshots = "https://raw.githubusercontent.com/mmick66/CalendarView/master/Assets/screenshots.png"
s.screenshots = "https://raw.githubusercontent.com/mmick66/CalendarView/main/Assets/screenshots.png"

s.license = { :type => "MIT", :file => "LICENSE" }

Expand Down
18 changes: 11 additions & 7 deletions Sources/KDCalendar/CalendarDayCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,22 @@ open class CalendarDayCell: UICollectionViewCell {
let bgView = UIView()

override init(frame: CGRect) {
super.init(frame: frame)
buildSubviews()
}

self.textLabel.textAlignment = NSTextAlignment.center
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
contentView.subviews.forEach { $0.removeFromSuperview() }
subviews.filter { $0 !== contentView }.forEach { $0.removeFromSuperview() }
buildSubviews()
}

super.init(frame: frame)
private func buildSubviews() {
self.textLabel.textAlignment = NSTextAlignment.center

self.addSubview(self.bgView)
self.addSubview(self.textLabel)

self.addSubview(self.dotsView)

self.textLabel.adjustsFontForContentSizeCategory = true
Expand All @@ -123,10 +131,6 @@ open class CalendarDayCell: UICollectionViewCell {
}
}

required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}

override open func layoutSubviews() {

super.layoutSubviews()
Expand Down
16 changes: 12 additions & 4 deletions Sources/KDCalendar/CalendarHeaderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,17 @@ open class CalendarHeaderView: UIView {

public override init(frame: CGRect) {
super.init(frame: frame)
buildLabels()
}

required public init?(coder: NSCoder) {
super.init(coder: coder)
// Labels decoded from an archive are replaced by fresh ones; the style recreates the text.
subviews.forEach { $0.removeFromSuperview() }
buildLabels()
}

private func buildLabels() {
self.translatesAutoresizingMaskIntoConstraints = false

monthLabel = UILabel()
Expand All @@ -49,6 +59,7 @@ open class CalendarHeaderView: UIView {
monthLabel.accessibilityTraits = .header
self.addSubview(monthLabel)

dayLabels.removeAll()
for _ in 0..<7 {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
Expand All @@ -58,6 +69,7 @@ open class CalendarHeaderView: UIView {
dayLabels.append(label)
self.addSubview(label)
}
updateStyle()
}

public func updateStyle() {
Expand Down Expand Up @@ -89,10 +101,6 @@ open class CalendarHeaderView: UIView {
self.backgroundColor = style.weekdaysBackgroundColor
}

required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override open func layoutSubviews() {
super.layoutSubviews()

Expand Down
13 changes: 12 additions & 1 deletion Sources/KDCalendar/CalendarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,11 @@ public class CalendarView: UIView {

required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
// A view decoded from an archive brings its old header and grid along; replace them.
for subview in subviews where subview is CalendarHeaderView || subview is UICollectionView {
subview.removeFromSuperview()
}
self.setup()
}

override open func awakeFromNib() {
Expand All @@ -286,7 +291,9 @@ public class CalendarView: UIView {
}

// MARK: Create Subviews
/// Builds the header and the grid once; safe to call again.
private func setup() {
guard collectionView == nil else { return }

self.clipsToBounds = true

Expand Down Expand Up @@ -411,14 +418,18 @@ public class CalendarView: UIView {

internal func updateLayoutDirections() {
self.collectionView?.semanticContentAttribute = .forceLeftToRight
self.headerView?.semanticContentAttribute = forceLtr ? .forceLeftToRight : .unspecified

var isRtl = false

if !forceLtr {
isRtl = self.effectiveUserInterfaceLayoutDirection == .rightToLeft
}

// The header mirrors with the calendar, whether the direction comes from the app or
// from this view alone.
self.headerView?.semanticContentAttribute = isRtl ? .forceRightToLeft : .forceLeftToRight
self.headerView?.setNeedsLayout()

if _isRtl != isRtl {
_isRtl = isRtl

Expand Down
76 changes: 52 additions & 24 deletions Sources/KDCalendarEventKit/EventsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,72 +33,100 @@ public enum EventsManagerError: Error, Sendable {
case authorization
}

/// The bridge between the system event store and ``KDCalendar/CalendarEvent`` values.
/// The part of an event store the bridge needs. `EKEventStore` conforms; a test double can
/// stand in through ``EventsManager/store``.
@MainActor
public protocol CalendarEventStore: AnyObject {
/// Whether the app holds full access to the user's calendars.
var hasFullAccess: Bool { get }
/// Asks the user for full access. Returns whether it was granted.
func requestFullAccess() async throws -> Bool
/// The events that overlap the interval, as `CalendarEvent` values.
func events(from start: Date, to end: Date) -> [CalendarEvent]
/// Saves a new event to the default calendar.
func save(_ event: CalendarEvent) throws
}

extension EKEventStore: CalendarEventStore {

public var hasFullAccess: Bool {
EKEventStore.authorizationStatus(for: .event) == .fullAccess
}

public func requestFullAccess() async throws -> Bool {
try await requestFullAccessToEvents()
}

public func events(from start: Date, to end: Date) -> [CalendarEvent] {
let predicate = predicateForEvents(withStart: start, end: end, calendars: nil)
return events(matching: predicate).map {
CalendarEvent(title: $0.title, startDate: $0.startDate, endDate: $0.endDate)
}
}

public func save(_ calendarEvent: CalendarEvent) throws {
let event = EKEvent(eventStore: self)
event.title = calendarEvent.title
event.startDate = calendarEvent.startDate
event.endDate = calendarEvent.endDate
event.calendar = defaultCalendarForNewEvents
try save(event, span: .thisEvent)
}
}

/// The bridge between the system event store and `CalendarEvent` values.
///
/// Every call runs on the main actor. Full calendar access is requested the first time
/// it is needed; the app must declare `NSCalendarsFullAccessUsageDescription`.
@MainActor
public enum EventsManager {

private static let store = EKEventStore()
/// The store every call goes through. `EKEventStore` by default; replace it with a
/// ``CalendarEventStore`` of your own to test without calendar access.
public static var store: any CalendarEventStore = EKEventStore()

/// Whether the app currently holds full access to the user's calendars.
public static var hasFullAccess: Bool {
EKEventStore.authorizationStatus(for: .event) == .fullAccess
store.hasFullAccess
}

/// Requests full access if needed and returns the events between the two dates.
/// - Throws: ``EventsManagerError/authorization`` when access is denied.
public static func load(from fromDate: Date, to toDate: Date) async throws -> [CalendarEvent] {
if !hasFullAccess {
let granted = (try? await store.requestFullAccessToEvents()) ?? false
let granted = (try? await store.requestFullAccess()) ?? false
guard granted else { throw EventsManagerError.authorization }
}
return fetch(from: fromDate, to: toDate)
return store.events(from: fromDate, to: toDate)
}

/// Saves an event to the user's default calendar. Returns `false` when access has not
/// been granted or the store refuses the event.
public static func add(event calendarEvent: CalendarEvent) -> Bool {

guard hasFullAccess else {
return false
}

let event = EKEvent(eventStore: store)
event.title = calendarEvent.title
event.startDate = calendarEvent.startDate
event.endDate = calendarEvent.endDate
event.calendar = store.defaultCalendarForNewEvents
do {
try store.save(event, span: .thisEvent)
try store.save(calendarEvent)
return true
} catch {
return false
}
}

private static func fetch(from fromDate: Date, to toDate: Date) -> [CalendarEvent] {

let predicate = store.predicateForEvents(withStart: fromDate, end: toDate, calendars: nil)

return store.events(matching: predicate).map {
CalendarEvent(title: $0.title, startDate: $0.startDate, endDate: $0.endDate)
}
}
}

// MARK: - CalendarView integration

extension CalendarView {

/// Loads the events from the system calendar that fall inside the data source's range and
/// assigns them to ``KDCalendar/CalendarView/events``. Asks for full calendar access if it
/// assigns them to `events`. Asks for full calendar access if it
/// has not been granted yet; the app must declare `NSCalendarsFullAccessUsageDescription`.
/// - Throws: ``EventsManagerError/authorization`` when access is denied.
public func loadEvents() async throws {
let range = self.dateRange
self.events = try await EventsManager.load(from: range.lowerBound, to: range.upperBound)
guard let end = calendar.date(byAdding: .day, value: 1, to: range.upperBound) else { return }
self.events = try await EventsManager.load(from: range.lowerBound, to: end)
}

/// Completion-handler form of ``loadEvents()``. The handler runs on the main actor with
Expand Down
Loading
Loading