Files
tobias 6016053a92 Kalender ueber EventKit, Orte nach dem Import, Fotos verkleinert, Signalsperre (2026.9.3.1-.3)
- Kalendertermine gehen nativ ueber EventKit statt ueber das Teilen-Blatt:
  Apples Kalender meldet sich beim System gar nicht als Teilen-Ziel an, ein
  anderes Dateiformat haette also nie geholfen (Abschnitt BS).
- Der Rueckblick stoesst jetzt selbst das Screening an, wenn er Fahrten
  angelegt hat - sonst blieb eine importierte Fahrt ohne Ort liegen, solange
  das Fahrzeug steht (Abschnitt BT).
- Fahrzeugfotos werden vor dem Upload im Browser auf 2000 px/WebP gebracht.
  Gemessen: 3.099.022 -> 325.994 Bytes. Damit ist die Nachrichtengrenze der
  WebSocket-Verbindung kein Thema mehr ("connection lost" auf der realen
  Instanz). Formate, die der Browser weder umwandeln noch anzeigen kann,
  werden mit klarer Meldung abgelehnt statt roh gespeichert.
- Zwei Wechsel des Fahrtsignals in derselben Sekunde kosteten eine ganze
  Fahrt: das "on" ueberholte den noch laufenden Beende-Vorgang und fiel durch
  beide Zweige. Neue asyncio-Sperre plus Regressionstest, der ohne sie
  nachweislich rot ist (Abschnitt BU).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 01:59:32 +02:00

136 lines
6.0 KiB
Swift

//
// 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)
}
}