// // KalenderTermin.swift // DataMetric360 // // Legt einen Termin über EventKit an — der Weg, den das Teilen-Blatt nicht // anbietet. // // WARUM ES DAS BRAUCHT // -------------------- // Bis zum 03.09.2026 ging der Werkstatt- und der Radwechseltermin als // .ics-Datei ins iOS-Teilen-Blatt. Dort erscheint nie ein Kalender, und das // liegt nicht an der Datei: Apples Kalender-App meldet sich beim System // überhaupt nicht als Teilen-Ziel an, für kein Format. Das Blatt kann sie // deshalb gar nicht zeigen, egal wie die Datei aussieht. // // Dass ein per Mail geschickter Anhang funktioniert, führt in die Irre: dort // öffnet nicht das Teilen-Blatt, sondern die Dokumentvorschau des Systems // (Quick Look). Die erkennt text/calendar und blendet „Alle hinzufügen" ein. // Ein anderer Mechanismus, den eine App nicht aus dem Teilen-Blatt heraus // erreicht. Gemeldet vom Eigentümer am 02.09.2026. // // EventKit umgeht beides: der Termin geht direkt an den Kalender. // // WARUM DER SYSTEM-EDITOR UND NICHT `EKEventStore.save` // ----------------------------------------------------- // `EKEventEditViewController` zeigt den fertig ausgefüllten Termin und lässt // ihn den Menschen bestätigen. Das passt zur Vorgabe des Eigentümers, dass // der Werkstatttermin keine eigene Verwaltung bekommt (siehe Abschnitt BQ in // AGENTS.md): die App liefert „was, wann, welche Werkstatt" und ist danach // raus — gespeichert wird im Kalender, nicht bei uns. // // Der zweite Grund ist die Berechtigung. Seit iOS 17 braucht der System- // Editor keine (WWDC23, „Discover Calendar and EventKit"): er läuft außerhalb // unseres Zugriffs, wir übergeben nur den Entwurf. Ein direktes `save()` // bräuchte dagegen eine Freigabe samt Rückfrage. Deshalb wird hier auch // `event.calendar` NICHT gesetzt — das würde `defaultCalendarForNewEvents` // lesen und damit doch eine Freigabe verlangen; der Editor sucht den // Standardkalender selbst. // // DIESE DATEI IST VERSIONIERT, ios/ NICHT. // `npx cap add ios` erzeugt den nativen Ordner neu und würde alles darin // verlieren. scripts/ios-teilen-einrichten.mjs kopiert sie bei jedem Bau // wieder hinein und hängt sie an die Sources-Phase des App-Ziels. // import Capacitor import EventKit import EventKitUI import Foundation import UIKit @objc(KalenderTerminPlugin) public class KalenderTerminPlugin: CAPPlugin, CAPBridgedPlugin, EKEventEditViewDelegate { public let identifier = "KalenderTerminPlugin" public let jsName = "KalenderTermin" public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "terminAnlegen", returnType: CAPPluginReturnPromise) ] /// Muss den Editor überleben — eine lokale Instanz wäre beim Erscheinen /// des Blatts schon wieder abgeräumt. private let speicher = EKEventStore() /// Der Aufruf wartet, bis der Mensch im Editor entschieden hat. private var offenerAufruf: CAPPluginCall? @objc func terminAnlegen(_ call: CAPPluginCall) { guard let titel = call.getString("titel"), !titel.isEmpty else { return call.reject("Ohne Titel kein Termin.") } guard let isoTag = call.getString("isoTag"), let tag = tagAus(isoTag) else { return call.reject("Kein gültiges Datum (erwartet JJJJ-MM-TT).") } let termin = EKEvent(eventStore: speicher) termin.title = titel termin.isAllDay = true // Ganztägig heißt für EventKit: Beginn und Ende am selben Tag. Deckt // sich mit dem DTSTART;VALUE=DATE der bisherigen .ics. termin.startDate = tag termin.endDate = tag if let ort = call.getString("ort"), !ort.isEmpty { termin.location = ort } if let text = call.getString("beschreibung"), !text.isEmpty { termin.notes = text } // Zwei Tage vorher erinnern - dieselbe Vorwarnzeit wie im TRIGGER:-P2D // der .ics, die dieser Weg ablöst. termin.addAlarm(EKAlarm(relativeOffset: -2 * 24 * 60 * 60)) DispatchQueue.main.async { [weak self] in guard let self else { return } guard let eltern = self.bridge?.viewController else { return call.reject("Kein Fenster zum Anzeigen des Kalender-Editors.") } // Ein noch offener Aufruf kann nur von einem Editor stammen, der // ohne Rueckmeldung verschwunden ist. Ihn stehen zu lassen hiesse, // ein Versprechen nie einzuloesen. self.offenerAufruf?.resolve(["status": "abgebrochen"]) self.offenerAufruf = call let editor = EKEventEditViewController() editor.event = termin editor.eventStore = self.speicher editor.editViewDelegate = self // Wegwischen wuerde den Delegaten nicht rufen und den Aufruf oben // haengen lassen; so bleiben nur "Hinzufuegen" und "Abbrechen". editor.isModalInPresentation = true eltern.present(editor, animated: true) } } public func eventEditViewController( _ controller: EKEventEditViewController, didCompleteWith action: EKEventEditViewAction ) { controller.dismiss(animated: true) let aufruf = offenerAufruf offenerAufruf = nil // .deleted gibt es hier nicht (der Termin ist neu), zaehlt aber wie ein // Abbruch: gespeichert wurde nichts. aufruf?.resolve(["status": action == .saved ? "gespeichert" : "abgebrochen"]) } /// „2027-03-14" -> Mitternacht dieses Tages in der Zeitzone des Geräts. private func tagAus(_ isoTag: String) -> Date? { let teile = isoTag.split(separator: "-").compactMap { Int($0) } guard teile.count == 3 else { return nil } var komponenten = DateComponents() komponenten.year = teile[0] komponenten.month = teile[1] komponenten.day = teile[2] var kalender = Calendar(identifier: .gregorian) kalender.timeZone = TimeZone.current return kalender.date(from: komponenten) } }