Skip to content

Latest commit

 

History

History
391 lines (323 loc) · 18.5 KB

File metadata and controls

391 lines (323 loc) · 18.5 KB

CocoarCalendar — Integrations-Guide

Für Entwickler/Agents, die die Library in eine App einbinden. Die Library ist der Swift-Port von @cocoar/vue-calendar und verhält sich identisch zur Web-Komponente (fixture-verifiziert gegen die TS-Implementierung). Hintergrund: docs/vue-calendar-analyse.md, Projektregeln: CLAUDE.md.

1. Einbinden

SwiftPM, lokales Package (noch kein Remote):

// Package.swift der App / Xcode: File > Add Package Dependencies > Add Local…
.package(path: "../Cocoar.Calendar.iOS")
// Produkte:
.product(name: "CocoarCalendarUI", package: "CocoarCalendar")   // Views (re-exportiert Core)
.product(name: "CocoarCalendarCore", package: "CocoarCalendar") // nur Logik, falls UI nicht gebraucht

Minimum: iOS 26. import CocoarCalendarUI genügt (Core kommt via @_exported mit).

2. Das Datenmodell — die wichtigste Regel zuerst

Niemals Foundation.Date für Event-Zeiten verwenden. Die Library speichert Absicht (lokale Wandzeit + IANA-Zone), nicht Instants — exakt wie das Web (Temporal). Es gibt zwei Zeit-Formen, per Enum unverwechselbar:

// Terminiertes Event: Wandzeit + IANA-Zone pro Endpunkt.
let start = try ZonedDateTime.from(
    wall: PlainDateTime(isoString: "2026-06-15T09:00:00"),
    zone: "Europe/Vienna"                    // Quell-Zone des Events!
)
let end = try start.adding(minutes: 30)

let standup = try CalendarEvent(
    id: "standup",
    time: .timed(start: start, end: end),
    meta: MyMeta(title: "Daily Standup", colorHex: 0x10B981)
)

// Ganztägig: bewusst zonenlos, end EXKLUSIV (RFC 5545).
let vacation = try CalendarEvent(
    id: "vacation",
    time: .allDay(start: try PlainDate(isoString: "2026-06-22"),
                  end:   try PlainDate(isoString: "2026-06-27")) // bis inkl. 26.!
)

Regeln, die die Library erzwingt (throws):

  • end muss strikt nach start liegen.
  • Zone muss eine gültige IANA-Zone sein (Europe/Vienna, nicht CET-Raten).
  • Cross-Zone ist erlaubt und erwünscht (Flug: start Tokio, end Wien).

Meta / eigene App-Daten

Events sind generisch über Meta. Die Library liest nur die Konvention title / colorHex:

struct MyMeta: CalendarEventMeta {          // Hashable & Sendable
    var title: String?
    var colorHex: UInt32?                    // 0xRRGGBB; nil → Akzentfarbe
    var myAppointmentID: UUID                // beliebige eigene Felder
}

Ohne Meta: PlainCalendarEvent (= CalendarEvent<Never>).

3. Backend-Anbindung (Wire-Format — identisch zur Web-App!)

Das Backend spricht denselben Contract wie die Vue-Komponente:

// Backend → App (timed): { "local": "2026-06-15T09:00:00", "timeZoneId": "Europe/Vienna" }
let zdt = try parseScheduledTime(
    ScheduledTimeWire(local: dto.localStart, timeZoneId: dto.timeZoneId)
)   // optional: dstPolicy: .compatible|.reject|.earlier|.later (Default compatible)

// App → Backend:
let wire = formatScheduledTime(zdt)          // → local + timeZoneId, verlustfrei

// Ganztägig: "2026-06-15"
let date = try parsePlainDate(dto.date)

Nie Instants/UTC-Strings ins Backend zurückschreiben — local + timeZoneId verbatim persistieren (Round-Trip ist garantiert stabil).

4. Views

Alle Views sind SwiftUI, generisch über Meta: CalendarEventMeta, und bekommen dieselben Grundparameter: events, timezone (Display-Zone — was der Nutzer sieht; unabhängig von den Quell-Zonen der Events), today (von der App geliefert, Views lesen nie selbst die Uhr), locale, Callbacks.

WeekView — responsive (wichtigstes Verhalten)

@State var cursor: PlainDate =             // App besitzt den Cursor

WeekView(
    cursor: $cursor,                        // Binding! Strip-Paging schreibt zurück
    firstDayOfWeek: 1,                      // 0=So … 6=Sa
    events: events,
    timezone: "Europe/Vienna",
    timeRange: (startHour: 7, endHour: 20), // sichtbare Stunden
    today: today,
    locale: Locale(identifier: "de_AT"),
    allDayAxisLabel: "ganztägig",
    onTimeClick: { date, time in  },       // Tap auf leeren Slot (gesnappt)
    onDateClick: { date in  },             // Tap auf Header/Ganztägig-Zelle
    onEventClick: { event in  },
    scrollRequest: $scrollRequest            // TimeGridScrollRequest(time:) → scrollt zur Stunde
)

Verhalten (automatisch, nicht überschreiben):

  • .compact (iPhone hoch): Wochen-Strip oben (Tag antippen, Woche swipen)
    • Tages-Timeline in voller Breite.
  • .regular (iPad, iPhone quer): echtes 7-Spalten-Grid.
  • visibleDays: Override für WorkWeek-Fälle (nur diese Tage rendern).

DayView / MonthView / AgendaView

DayView(cursor: cursor, events: events, timezone: tz,
        timeRange: (7, 20), today: today, )              // eine Spalte

MonthView(cursor: cursor, firstDayOfWeek: 1, events: events,
          timezone: tz, today: today, locale: loc,
          onDateClick: , onEventClick: )                  // 6×7-Raster, Pills + Multi-Day-Bars

AgendaView(cursor: cursor, agendaLengthDays: 30,
           showEmptyDays: false, events: events, timezone: tz,
           today: today, locale: loc,
           allDayLabel: "Ganztägig", continuationTag: "(fortges.)",
           todayBadgeLabel: "heute",
           onDateClick: , onEventClick: ,
           scrollRequest: $agendaScroll)                    // AgendaScrollRequest.scrollToDate(…)

Month-Verhalten wie im Web: kein „+N more" — Zellen scrollen intern; mehrtägige Events werden pro Wochenzeile segmentiert. Agenda: Ganztägig vor Terminen, Fortsetzungstage mit (cont.)-Tag, native pinned Section-Headers.

CalendarShell + CalendarController — der empfohlene Einstieg

Die Komplett-Lösung analog <CoarCalendar>: ein @Observable-Controller (Zustand, Navigation, Events) + eine Shell-View (Toolbar mit ‹ Heute ›, per-View-Range-Label, Segmented-Switcher; Body wechselt zwischen allen fünf Views; Header passt sich der Breite an — einzeilig auf iPad, zweizeilig auf iPhone). Ausführliches Beispiel: Doc-Comment auf CalendarShell.

// Controller EINMAL erzeugen (z. B. @State im Screen). Handler am besten
// direkt bei der Konstruktion setzen — onRangeChange feuert schon beim
// ersten Erscheinen; wer es erst in .task setzt, verpasst das erste Fenster.
let controller = CalendarController<MyMeta>(
    view: .week,
    timezone: TimeZone.current.identifier,   // Display-Zone
    locale: Locale(identifier: "de_AT"),
    firstDayOfWeek: 1,
    timeRange: (startHour: 7, endHour: 20)
)
// Entweder statisch:
controller.setEvents(events)
// … oder per Loader (gecacht pro view|timezone|start|end, debounced,
// Generation-Guard; refresh()/refreshRange() zum Invalidieren):
controller.setEventsLoader { window in
    try await api.fetchEvents(from: window.start, to: window.end,
                              timeZoneId: window.timezone)
}
controller.onEventClick = { event in  }
controller.onTimeClick = { date, time in  }
controller.onRangeChange = { window in  }   // z. B. Analytics/Prefetch

// "Hier Termin anlegen" (Long-Press auf LEERE Fläche; Long-Press auf
// einem Event bleibt Drag). Zeit-Slot gesnappt wie onTimeClick:
controller.onTimeLongPress = { date, time in  }   // leerer Zeit-Slot (Tag/Woche)
controller.onDateLongPress = { date in  }         // leere Monatszelle / Ganztägig-Zelle

// Doppel-Tap auf leere Flächen — volle Gesten-Matrix pro Fläche:
// {Tap, Doppel-Tap, Long-Press} × {Monatszelle, Zeit-Slot}. Die App wählt
// die Semantik (z. B. Tap = markieren, Doppel = Tagesansicht, Long = anlegen).
// ACHTUNG Latenz: Ist ein Doppel-Tap-Handler gesetzt, wartet der Single-Tap
// derselben Fläche ~¼ s auf den möglichen zweiten Tap (System-Fenster);
// ungesetzt (nil) wird die Geste gar nicht angehängt und Taps bleiben sofort.
controller.onDateDoubleClick = { date in  }       // Monatszelle
controller.onTimeDoubleClick = { date, time in  } // Zeit-Slot, gesnappt wie onTimeClick

// Swipe-Paging in der Monatsansicht ist automatisch aktiv (links/rechts =
// next()/prev(), identisch zu den Chevron-Buttons).

// ── Rezept: Gesten mit Navigation verknüpfen ──────────────────────────
// Die Hooks sind REINE Notifications — die Library navigiert bei
// Tap/Doppel-Tap/Long-Press nie selbst (Web-Verhalten). OB dabei das
// Datum gesetzt wird, entscheidet die App im Handler: `controller.cursor`
// und `controller.view` sind schreibbar. Je eine Zeile:
controller.onDateClick = { controller.cursor = $0 }        // Tap = Tag markieren
controller.onDateDoubleClick = { date in                    // Doppel-Tap = Tagesansicht
    controller.cursor = date
    controller.view = .day
}
controller.onDateLongPress = { öffneCreateSheet(für: $0) } // Long-Press = anlegen
// Kein cursor-Write im Handler = Datum bleibt stehen (z. B. nur Analytics).
// Einzige eingebaute Ausnahme: der Tag-Tap im compact-Wochen-Strip setzt
// den Cursor IMMER mit — dort ist die Auswahl der sichtbare Tag, die
// Invariante "controller.cursor == sichtbarer Tag" ist App-lesbar garantiert.

// In der View (alle Labels lokalisierbar — auch die view-internen):
CalendarShell(
    controller: controller,
    todayLabel: "Heute",
    viewLabel: { view in  },                 // lokalisierte Switcher-Labels
    allDayAxisLabel: "ganztägig",             // Achsen-Label des Ganztägig-Bands
    agendaAllDayLabel: "Ganztägig",           // Agenda-Zeitspalte für All-Day-Zeilen
    agendaContinuationTag: "(fortges.)",      // Agenda-Fortsetzungstage
    agendaTodayBadgeLabel: "heute",           // HEUTE-Badge im Agenda-Header
    agendaTimeLabel: { event in  },          // optional: Agenda-Zeitlabel pro Event ersetzen
    eventContent: { event in AnyView(MyEventCell(event)) } // optional: eigenes Rendering
)

Agenda-Zeitlabel-Default: timed Events MIT Ende zeigen die Spanne (09:00 – 10:30), Punkt-Termine (ohne Ende) nur die Startzeit, All-Day das agendaAllDayLabel. agendaTimeLabel ersetzt die Regel komplett pro Event.

Navigation imperativ: controller.goToToday(), next(), prev(), goTo(date), setView(.month) (respektiert availableViews), scrollToTime(…)/scrollToDate(…). controller.visibleEvents und controller.loading sind beobachtbar.

5. Theming (Light + Dark, an die App anpassbar)

Ein Environment-Wert mit zwei Presets: CalendarTheme.light (die Vue-Token-Fallbacks) und CalendarTheme.dark (seit @cocoar/vue-calendar 2.15.0 shippt das Web dieselben Werte als styles/tokens-dark.css — beide Seiten bleiben literal vergleichbar). Empfohlener Standard — folgt dem System-Schema:

CalendarShell(controller: controller)
    .calendarTheme(light: .light, dark: .dark)

An die App anpassen: Alle ~30 Tokens sind vars — Kopien anpassen und als Paar übergeben:

var light = CalendarTheme.light
light.accent = Color(hex: 0x7C3AED)          // Brand-Farbe
light.backgroundToday = light.accent.opacity(0.06)
var dark = CalendarTheme.dark
dark.accent = Color(hex: 0x8B5CF6)           // hellere Dark-Variante der Brand
CalendarShell(controller: controller)
    .calendarTheme(light: light, dark: dark)

Fixes Theme (ignoriert das Schema): .calendarTheme(.light). Farbige Events: meta.colorHex füllt die Karte komplett (Web-Verhalten) — in beiden Modi; Event-Farben werden bewusst nicht umgerechnet.

Fremdmonats-Tage (Monatsansicht) dimmen: backgroundOtherMonth (Zellhintergrund), textOtherMonth (Tageszahl) und otherMonthContentOpacity (dimmt Tageszahl + Event-Pills, Default 1 = kein Dimmen; Multi-Day-Bars sind ausgenommen — sie spannen zeilenweit über Monatsgrenzen):

var light = CalendarTheme.light
light.otherMonthContentOpacity = 0.4

6. Häufige Fehler (bitte lesen)

Fehler Richtig
Event-Zeiten aus Date + TimeZone.current zusammenbauen ZonedDateTime.from(wall:zone:) mit der Quell-Zone des Termins
Display-Zone in die Events schreiben timezone:-Parameter der Views ist NUR Anzeige; Events behalten ihre Quell-Zonen
end inklusiv denken end ist überall exklusiv (1-Tages-Event: end = start + 1 Tag)
Auf Gerätetyp branchen (iPad vs iPhone) Nichts tun — WeekView brancht selbst über Size-Class
Events pro Render neu erzeugen (neue Objekte) Events stabil halten (Identität = id); Layout ist deterministisch
UTC als Display-Zone „zur Sicherheit" Echte Nutzer-Zone übergeben (TimeZone.current.identifier)
DST-Fehler abfangen und ignorieren DstResolutionError bedeutet: Policy-Entscheidung nötig (siehe DstPolicy)

7. Drag & Drop / Resize (alle Flächen: Zeit-Grid, Month-Grid, All-Day-Band)

Verschieben und Größe-Ändern von Events ist da und verhält sich wie im Web (event-drop). Aktivierung ausschließlich über den Controller — einen onEventDrop-Handler setzen genügt, sonst bleibt der Kalender read-only (Cards bekommen nur einen Tap, es wird nie ein Drag gestartet; exakt das Vue-Verhalten „kein Handler ⇒ kein Drop").

Ein Handler, alle Flächen. onEventDrop (und optional canDrop / dstPolicy) gilt nicht nur fürs terminierte Tages-/Wochen-Grid, sondern feuert mit demselben Closure auch für die Datums-Flächen:

  • Month-Grid — Pill/Bar verschieben (month) und links/rechts resizen (month-resize-start / -end).
  • All-Day-Band (über dem Zeit-Grid in Day-/Week-View) — Bar verschieben (allDay) und resizen (allDay-resize-start / -end).

Es gibt keinen zweiten Handler für diese Flächen: das eine controller.onEventDrop aktiviert sie alle. Der Payload hat für Datums-Flächen aber eine andere Form (siehe unten) — ein Handler, der nur terminierte Drops erwartet, muss das berücksichtigen.

// WICHTIG: onEventDrop (und optional canDrop) direkt bei der Konstruktion
// setzen — NICHT erst in .onAppear. Beide sind @ObservationIgnored, d. h. ein
// späteres Zuweisen triggert KEIN Re-Render der Shell/Views, und das Grid
// bliebe read-only. (onEventClick etc. dürfen später kommen, weil sie zur
// Laufzeit gelesen werden; die Drag-Verdrahtung wird beim ersten Render fixiert.)
let controller = CalendarController<MyMeta>(view: .week, timezone: tz, /* … */)
controller.onEventDrop = { payload in
    // payload.original (Vor-Drag-Snapshot), payload.next (aufgelöste Zeit),
    // payload.target (Datum/Minuten/valid), payload.disambiguation (DST).
    try? api.reschedule(payload.event.id, to: payload.next)
}
// Optional: pro Hit-Test validieren. false ⇒ roter Ghost, kein Commit.
controller.canDrop = { event, target in
    // ACHTUNG: target.minutes ist auf Month-Grid UND All-Day-Band IMMER nil
    // (zeitfreie Fläche). Dieser `?? true`-Fallback wird dort also zu „immer
    // erlaubt". Wer Datums-Drops separat validieren will, per target.date /
    // Event-Typ verzweigen statt über minutes.
    target.minutes.map { $0 >= 9 * 60 } ?? true   // z. B. „nicht vor 09:00"
}
// Optional: DST-Politik für gedroppte Wandzeiten (Default .compatible).
controller.dstPolicy = .reject   // markiert Gap/Overlap-Ziele vorab invalid

Gesten (iOS-nativ, Apple-Calendar-Idiom): Tap öffnet das Event; Long-Press ~0,35 s dann ziehen = verschieben (Dauer bleibt erhalten); die 6-pt-Bänder = Resize — im Zeit-Grid an Ober-/Unterkante, auf Month-Bars und All-Day-Bars an der linken/rechten Kante (geclippte Bar-Enden haben keinen Handle). Beim Ziehen an den Rand des Zeit-Grids wird kontinuierlich gescrollt (Month-Grid und Band brauchen das nicht). Ohne CalendarController gehen alle Flächen auch direkt an WeekView / DayView / MonthView / AllDayBand (gleiche Parameter onEventDrop: / canDrop: / dstPolicy:).

Payload-Form je Fläche. Derselbe EventDropPayload kommt aus allen Flächen, aber target.minutes und die Gestalt von next.time unterscheiden sich:

Fläche / Modus target.minutes payload.next.time
Zeit-Grid (timed move/resize) Minuten-im-Tag (Int) .timed(start, end)
Month-Grid / All-Day-Band, All-Day-Event (move + resize) nil .allDay(start, end) (Dauer bleibt erhalten; end exklusiv)
Month-Grid, terminiertes Event per Pill verschoben (month) nil .timed(start, end) auf 00:00 des Ziel-Tags — minutes == nil wird als 0 gelesen (kann eine DST-Lücke treffen → disambiguation)
Month-Grid, terminiertes Event resized (month-resize-*) nil .timed(start, end) — die bewegte Seite behält ihre Wandzeit, nur der Tag wandert

Praktisch heißt das: ein Handler, der stumpf api.reschedule(id, to: payload.next) aufruft, funktioniert weiter — next trägt immer die aufgelöste Ziel-Zeit (all-day oder timed). Wer aber aus payload.target.minutes eine Uhrzeit ableitet, muss den nil-Fall (Month/Band) explizit behandeln; sonst verliert / verwürfelt er Datums-Drops. Auf next.time per switch (.allDay / .timed) verzweigen ist der robuste Weg.

Die Drop-Mathematik (applyMoveToEvent, buildDropPayload, EventDropPayload mit disambiguation-Feld) ist fixture-verifiziert gegen die TS-Implementierung.

8. Was (noch) nicht da ist

  • Recurrence (RRULE) — Serien bis dahin im Backend expandieren und als konkrete Events liefern (gleiches Vorgehen wie im Web dokumentiert).
  • Dark Theme (kommt später), Timeline-View (bewusst raus).
  • Month-Zeilen-Expansion kommt NIE (Entscheidung 2026-07-10): Das war im Web nur ein Workaround; auf iOS scrollen volle Zellen intern — alle Events bleiben ohne Expansion erreichbar.

Hinweis WorkWeek am iPhone (compact): Der Wochen-Strip zeigt nur die Arbeitstage; fällt der Cursor auf ein Wochenende, springt die Auswahl auf den nächsten Arbeitstag. Am iPad (regular) werden nur die Arbeitstage als Spalten gerendert — das Lade-Fenster bleibt in beiden Fällen die volle Woche.

9. Referenz: Kern-Typen auf einen Blick

Typ Zweck
PlainDate / PlainTime / PlainDateTime zonenlose Kalender-Werte (ISO-Parsing via init(isoString:))
ZonedDateTime Wandzeit + IANA-Zone + Instant; withTimeZone, adding(minutes:/days:)
CalendarEvent<Meta> / EventTime Event; .timed / .allDay, end exklusiv
CalendarEventMeta / BasicEventMeta title/colorHex-Konvention
ScheduledTimeWire + parse/formatScheduledTime, parsePlainDate Backend-Contract
DstPolicy / DstResolutionError Gap/Overlap-Auflösung (compatible/reject/earlier/later)
CalendarView, computeViewWindow, navigateCursor Sichtfenster + Navigation (Core)
TimeGridScrollRequest / AgendaScrollRequest programmatisches Scrollen
CalendarTheme + .calendarTheme(_:) Design-Tokens (Light)