feat(ios): Kalender des Geraets anzeigen und bespielen

Die Kalenderveroeffentlichung in Outlook ist oft von der IT gesperrt, und
iCloud laesst sich nicht ueber einen ICS-Link einbinden. Das iPhone kennt
beide Konten aber bereits, also liest die App sie per EventKit direkt.

Die Termine bleiben auf dem Geraet und gehen nie an den Server. Im Web und
auf anderen Geraeten erscheinen sie deshalb nicht — dafuer braucht es keine
fremden Zugangsdaten und keine Kopie fremder Termindaten in unserer
Datenbank.

Standardmaessig aus: der Schalter steht unter Konten, und erst beim
Einschalten fragt EventKit nach Zugriff. Wer die Funktion nicht will, sieht
keine Berechtigungsabfrage.

Kaum neuer Code noetig, weil CalEvent schon source/calendarId/readOnly hat
und die Sichtbarkeits-, Sortier- und Ausblendlogik an
calendarKey(source:calendarId:) haengt: die Quelle "device" rutscht ohne
Umbau hinein. WritableCalendar bekommt nur ein deviceId, weil EventKit
Zeichenketten statt Zeilen-IDs vergibt.

Lesen, Anlegen, Aendern und Loeschen gehen ueber dieselben Ansichten wie bei
allen anderen Quellen. Nur die Gruppenansicht bleibt aussen vor, die ist
serverseitig zusammengesetzt.

Statt den Store durch zwei Views zu reichen, damit der Kalender nach dem
Einschalten nachlaedt, zaehlt der Dienst eine Generation hoch; ein
abweichender Stand gilt im Cache als Treffer-Fehlschlag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-23 14:57:15 +02:00
co-authored by Claude Opus 5
parent 2db4b42fa1
commit c811486ea7
8 changed files with 254 additions and 3 deletions
+26 -2
View File
@@ -44,6 +44,12 @@ struct WritableCalendar: Identifiable {
let color: String
let source: String
let numericId: Int
/// EventKit calendar identifier; set only for `source == "device"`, whose
/// ids are strings rather than server row ids.
var deviceId: String? = nil
/// The id as it appears in a calendar key, whichever kind it is.
var calendarIdString: String { deviceId ?? String(numericId) }
}
@Observable
@@ -420,8 +426,13 @@ class CalendarStore {
/// Load events for a specific range. Skips the network if already cached,
/// unless `force` is set (used after create/edit to pull fresh server data
/// for the visible range, bypassing the cache).
/// Device-calendar generation the cache was filled at (see DeviceCalendarService).
private var deviceGeneration = DeviceCalendarService.generation
func loadEvents(api: CalendarrAPI, start: Date, end: Date, force: Bool = false) async {
if !force, isCached(start: start, end: end) {
let deviceChanged = deviceGeneration != DeviceCalendarService.generation
if deviceChanged { deviceGeneration = DeviceCalendarService.generation }
if !force, !deviceChanged, isCached(start: start, end: end) {
refreshFromCache(start: start, end: end)
return
}
@@ -453,7 +464,11 @@ class CalendarStore {
let combined = try await api.fetchGroupCombined(groupId: g.id, start: start, end: end)
return (combined.map { decorateGroupEvent($0) }, [])
}
return try await api.fetchEvents(start: start, end: end)
let (server, errors) = try await api.fetchEvents(start: start, end: end)
// Device calendars live on the phone, so they are merged in here rather
// than coming from the server. They are deliberately absent from the
// group overlay, which is a server-side combined view.
return (server + DeviceCalendarService.shared.events(start: start, end: end), errors)
}
/// Prefix a combined-view event with its owner (others) or 👥 + creator
@@ -685,6 +700,15 @@ class CalendarStore {
for (accName, id, name, color) in haCals {
result.append(WritableCalendar(id: "ha-\(id)", name: "\(accName) – \(name)", color: color, source: "homeassistant", numericId: id))
}
for cal in DeviceCalendarService.shared.calendars() where cal.allowsContentModifications {
result.append(WritableCalendar(
id: "device-\(cal.calendarIdentifier)",
name: cal.title,
color: "#8E8E93",
source: DeviceCalendarService.source,
numericId: 0,
deviceId: cal.calendarIdentifier))
}
writableCalendars = result
writableCalendarErrors = errors
}
@@ -297,6 +297,10 @@ private let strings: [String: [String: String]] = [
"login.totp_placeholder": "6-stelliger Code",
"login.remember": "Angemeldet bleiben",
"login.signin": "Anmelden",
"device.calendars.header": "Gerätekalender",
"device.calendars.enable": "Kalender des iPhones anzeigen",
"device.calendars.desc": "Zeigt iCloud-, Outlook- und andere Kalender, die auf diesem iPhone eingerichtet sind. Sie bleiben auf dem Gerät und werden nicht an den Server übertragen — im Web und auf anderen Geräten erscheinen sie deshalb nicht.",
"device.calendars.denied": "Kein Zugriff auf die Kalender. Erlaube ihn in den Systemeinstellungen unter Datenschutz → Kalender.",
"login.choose_server": "Anderen Server wählen",
// Single sign-on (OpenID Connect)
"sso.or": "oder",
@@ -694,6 +698,10 @@ private let strings: [String: [String: String]] = [
"login.totp_placeholder": "6-digit code",
"login.remember": "Stay signed in",
"login.signin": "Sign in",
"device.calendars.header": "Device calendars",
"device.calendars.enable": "Show this iPhone's calendars",
"device.calendars.desc": "Shows iCloud, Outlook and other calendars set up on this iPhone. They stay on the device and are never sent to the server, so they do not appear on the web or on your other devices.",
"device.calendars.denied": "No calendar access. Grant it in System Settings under Privacy → Calendars.",
"login.choose_server": "Choose another server",
// Single sign-on (OpenID Connect)
"sso.or": "or",