Bring CalendarrKit into the repo and fix the iOS build

The monorepo's Xcode project referenced ../CalendarrKit, but the package was
never brought across: it is the one repo that never had a remote, so it existed
only on one machine. Added as a subtree at the root, where that existing
relative path resolves, so the project needs no change. History is preserved —
the commits explaining why the snapshot colours sit flat on the wire and why
the package must stay Foundation-only are the kind of thing that cannot be
reconstructed later.

The iOS target also did not compile on beta, independent of any of this. The
attachments section combined a string title with a footer, and SwiftUI has no
initializer for that pair: the title then binds as the first positional
argument of Section(content:footer:), which is what the three misleading
"generic parameter 'Content'" errors were. Rewritten as header:/footer:, the
way the reminders section directly above already does it.

Carries over the pending change from the archived iOS repo: each calendar
source now reports its own failure instead of being swallowed by `try?`, so an
absent Home Assistant calendar can finally be diagnosed from the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-19 05:38:06 +02:00
co-authored by Claude Opus 5
parent dccf8671b2
commit ab0c64a9e1
2 changed files with 54 additions and 10 deletions
+36 -8
View File
@@ -68,6 +68,10 @@ class CalendarStore {
var syncErrors: [SyncError] = [] var syncErrors: [SyncError] = []
var weekStartsOnMonday = true var weekStartsOnMonday = true
var writableCalendars: [WritableCalendar] = [] var writableCalendars: [WritableCalendar] = []
/// Why a source contributed nothing, keyed by source name. Empty when every
/// source answered. Surfaced in the event editor, because an empty picker
/// with no explanation is indistinguishable from "you own no calendars".
var writableCalendarErrors: [String: String] = [:]
// When set, the calendar shows the group's combined overlay instead of the // When set, the calendar shows the group's combined overlay instead of the
// user's own events. nil = personal view. // user's own events. nil = personal view.
var activeGroup: CalGroup? = nil var activeGroup: CalGroup? = nil
@@ -634,29 +638,53 @@ class CalendarStore {
// MARK: – Writable calendars // MARK: – Writable calendars
func loadWritableCalendars(api: CalendarrAPI) async { func loadWritableCalendars(api: CalendarrAPI) async {
async let localCals = (try? await api.getLocalCalendars()) ?? [] // Each source is fetched independently and reports its own failure.
async let caldavAccs = (try? await api.getCalDAVAccounts()) ?? [] // `try?` used to discard all four, so a source that errored looked
async let googleCals = (try? await api.getGoogleCalendars()) ?? [] // exactly like a source with nothing in it — which is how a missing
async let haCals = (try? await api.getHACalendars()) ?? [] // Home Assistant calendar can go unexplained for a long time.
var errors: [String: String] = [:]
async let localResult = Self.attempt("local") { try await api.getLocalCalendars() }
async let caldavResult = Self.attempt("caldav") { try await api.getCalDAVAccounts() }
async let googleResult = Self.attempt("google") { try await api.getGoogleCalendars() }
async let haResult = Self.attempt("homeassistant"){ try await api.getHACalendars() }
let (localCals, localErr) = await localResult
let (caldavAccs, caldavErr) = await caldavResult
let (googleCals, googleErr) = await googleResult
let (haCals, haErr) = await haResult
for (source, message) in [("local", localErr), ("caldav", caldavErr),
("google", googleErr), ("homeassistant", haErr)] {
if let message { errors[source] = message }
}
var result: [WritableCalendar] = [] var result: [WritableCalendar] = []
// Skip read-only shared calendars — offering them in the event editor // Skip read-only shared calendars — offering them in the event editor
// only leads to a 403 on save. Own + read_write (incl. group) stay. // only leads to a 403 on save. Own + read_write (incl. group) stay.
for cal in await localCals where cal.owned || cal.permission == "read_write" { for cal in localCals where cal.owned || cal.permission == "read_write" {
result.append(WritableCalendar(id: "local-\(cal.id)", name: cal.name, color: cal.color, source: "local", numericId: cal.id)) result.append(WritableCalendar(id: "local-\(cal.id)", name: cal.name, color: cal.color, source: "local", numericId: cal.id))
} }
for acc in await caldavAccs where acc.enabled { for acc in caldavAccs where acc.enabled {
for cal in acc.calendars ?? [] where cal.enabled { for cal in acc.calendars ?? [] where cal.enabled {
result.append(WritableCalendar(id: "caldav-\(cal.id)", name: "\(acc.name) – \(cal.name)", color: cal.color ?? acc.color, source: "caldav", numericId: cal.id)) result.append(WritableCalendar(id: "caldav-\(cal.id)", name: "\(acc.name) – \(cal.name)", color: cal.color ?? acc.color, source: "caldav", numericId: cal.id))
} }
} }
for (email, id, name, color) in await googleCals { for (email, id, name, color) in googleCals {
result.append(WritableCalendar(id: "google-\(id)", name: "\(email) – \(name)", color: color, source: "google", numericId: id)) result.append(WritableCalendar(id: "google-\(id)", name: "\(email) – \(name)", color: color, source: "google", numericId: id))
} }
for (accName, id, name, color) in await haCals { for (accName, id, name, color) in haCals {
result.append(WritableCalendar(id: "ha-\(id)", name: "\(accName) – \(name)", color: color, source: "homeassistant", numericId: id)) result.append(WritableCalendar(id: "ha-\(id)", name: "\(accName) – \(name)", color: color, source: "homeassistant", numericId: id))
} }
writableCalendars = result writableCalendars = result
writableCalendarErrors = errors
}
/// Run one source fetch, returning its result or the reason it produced
/// nothing. Never throws: one broken source must not empty the picker.
private static func attempt<T>(_ source: String,
_ work: () async throws -> [T]) async -> ([T], String?) {
do { return (try await work(), nil) }
catch { return ([], error.localizedDescription) }
} }
// MARK: – Query helpers // MARK: – Query helpers
@@ -35,6 +35,18 @@ struct EventEditorSheet: View {
private var isEditing: Bool { editingEvent != nil } private var isEditing: Bool { editingEvent != nil }
private var isCopying: Bool { copyFrom != nil && editingEvent == nil } private var isCopying: Bool { copyFrom != nil && editingEvent == nil }
/// Sources that failed to answer, named instead of silently leaving their
/// calendars out of the picker.
@ViewBuilder
private var sourceErrorRows: some View {
let failures = store.writableCalendarErrors.sorted { $0.key < $1.key }
ForEach(failures, id: \.key) { failure in
Label("\(failure.key): \(failure.value)", systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
.font(.caption)
}
}
private var selectedCal: WritableCalendar? { private var selectedCal: WritableCalendar? {
store.writableCalendars.first { $0.id == selectedCalendarId } store.writableCalendars.first { $0.id == selectedCalendarId }
} }
@@ -80,7 +92,9 @@ struct EventEditorSheet: View {
Text(L10n.t("event.no_writable", appLang)) Text(L10n.t("event.no_writable", appLang))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.font(.callout) .font(.callout)
} else { }
sourceErrorRows
if !store.writableCalendars.isEmpty {
Picker(L10n.t("event.calendar_picker", appLang), selection: $selectedCalendarId) { Picker(L10n.t("event.calendar_picker", appLang), selection: $selectedCalendarId) {
ForEach(store.writableCalendars) { cal in ForEach(store.writableCalendars) { cal in
HStack { HStack {
@@ -122,7 +136,7 @@ struct EventEditorSheet: View {
} }
.disabled(remindersDisabled) .disabled(remindersDisabled)
Section(L10n.t("event.attachments", appLang)) { Section {
ForEach(existingAttachments.filter { !removedAttachmentIds.contains($0.id) }) { att in ForEach(existingAttachments.filter { !removedAttachmentIds.contains($0.id) }) { att in
HStack { HStack {
Image(systemName: att.symbolName).foregroundStyle(.secondary) Image(systemName: att.symbolName).foregroundStyle(.secondary)
@@ -156,6 +170,8 @@ struct EventEditorSheet: View {
Label(L10n.t("event.attachment_add", appLang), systemImage: "paperclip") Label(L10n.t("event.attachment_add", appLang), systemImage: "paperclip")
} }
.disabled(attachmentSlotsUsed >= 10) .disabled(attachmentSlotsUsed >= 10)
} header: {
Text(L10n.t("event.attachments", appLang))
} footer: { } footer: {
Text(L10n.t("event.attachment_hint", appLang)) Text(L10n.t("event.attachment_hint", appLang))
} }