Add the watch app and its eight complications

The watch receives the phone's payload over WatchConnectivity and writes it
into its own App Group container through the same SnapshotStore, so there is
one wire format and one reader. The agenda, the detail screen and the filter
all read that; the complications read what the app last wrote, since an
extension cannot run a WCSession of its own.

Complication timelines land on event boundaries rather than a schedule, so the
next appointment flips in the exact minute it changes without spending refresh
budget.

CalendarrWatchShared holds what both watch targets need, attached to each the
way Shared/ is attached to the app and the widgets.

recommendations() is implemented because watchOS requires it — iOS has a
default, the watch does not, since the complication gallery has to be
populated. Found by building, not by reading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-11 17:08:27 +02:00
co-authored by Claude Opus 5
parent 6a7afe0647
commit f9bc1965a5
23 changed files with 1190 additions and 223 deletions
@@ -1,86 +0,0 @@
//
// CalendarrWatch.swift
// CalendarrWatch
//
// Created by Guido Schmit on 11.09.2026.
//
import WidgetKit
import SwiftUI
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), emoji: "😀")
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let entry = SimpleEntry(date: Date(), emoji: "😀")
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
var entries: [SimpleEntry] = []
// Generate a timeline consisting of five entries an hour apart, starting from the current date.
let currentDate = Date()
for hourOffset in 0 ..< 5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
let entry = SimpleEntry(date: entryDate, emoji: "😀")
entries.append(entry)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
// func relevances() async -> WidgetRelevances<Void> {
// // Generate a list containing the contexts this widget is relevant in.
// }
}
struct SimpleEntry: TimelineEntry {
let date: Date
let emoji: String
}
struct CalendarrWatchEntryView : View {
var entry: Provider.Entry
var body: some View {
VStack {
HStack {
Text("Time:")
Text(entry.date, style: .time)
}
Text("Emoji:")
Text(entry.emoji)
}
}
}
struct CalendarrWatch: Widget {
let kind: String = "CalendarrWatch"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
if #available(watchOS 10.0, *) {
CalendarrWatchEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
} else {
CalendarrWatchEntryView(entry: entry)
.padding()
.background()
}
}
.configurationDisplayName("My Widget")
.description("This is an example widget.")
}
}
#Preview(as: .accessoryRectangular) {
CalendarrWatch()
} timeline: {
SimpleEntry(date: .now, emoji: "😀")
SimpleEntry(date: .now, emoji: "🤩")
}
@@ -1,17 +0,0 @@
//
// CalendarrWatchBundle.swift
// CalendarrWatch
//
// Created by Guido Schmit on 11.09.2026.
//
import WidgetKit
import SwiftUI
@main
struct CalendarrWatchBundle: WidgetBundle {
var body: some Widget {
CalendarrWatch()
CalendarrWatchControl()
}
}
@@ -0,0 +1,145 @@
import WidgetKit
import SwiftUI
@main
struct CalendarrWatchComplicationBundle: WidgetBundle {
var body: some Widget {
WatchNextEventWidget()
WatchCornerCountdownWidget()
WatchCornerTimeWidget()
WatchCountdownWidget()
WatchDateWidget()
WatchTodayCountWidget()
WatchNextTwoWidget()
WatchDatePlusEventWidget()
}
}
private extension View {
/// Accessory families paint their own background; ours must stay out of
/// the way so the watch face's tint comes through.
func complicationChrome(_ language: String) -> some View {
containerBackground(for: .widget) { Color.clear }
.environment(\.locale, WatchL10n.locale(language))
}
}
struct WatchNextEventWidget: Widget {
let kind = "WatchNextEvent"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
NextEventComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Nächster Termin")
.description("Zeit, Titel und Ort des nächsten Termins.")
.supportedFamilies([.accessoryRectangular, .accessoryInline])
}
}
struct WatchCornerCountdownWidget: Widget {
let kind = "WatchCornerCountdown"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
CornerCountdownComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Restzeit (Ecke)")
.description("Restzeit bis zum nächsten Termin, Name am Rand.")
.supportedFamilies([.accessoryCorner])
}
}
struct WatchCornerTimeWidget: Widget {
let kind = "WatchCornerTime"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
CornerTimeComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Uhrzeit (Ecke)")
.description("Uhrzeit des nächsten Termins, Name am Rand.")
.supportedFamilies([.accessoryCorner])
}
}
struct WatchCountdownWidget: Widget {
let kind = "WatchCountdown"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
CountdownComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Countdown")
.description("Ring, der bis zum nächsten Termin leerläuft.")
.supportedFamilies([.accessoryCircular, .accessoryInline])
}
}
struct WatchDateWidget: Widget {
let kind = "WatchDate"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
DateComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Datum")
.description("Tag und Monat.")
.supportedFamilies([.accessoryCircular, .accessoryInline])
}
}
struct WatchTodayCountWidget: Widget {
let kind = "WatchTodayCount"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
TodayCountComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Termine heute")
.description("Anzahl der heutigen Termine.")
.supportedFamilies([.accessoryCircular, .accessoryInline])
}
}
struct WatchNextTwoWidget: Widget {
let kind = "WatchNextTwo"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
NextTwoComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Die nächsten zwei")
.description("Die beiden nächsten Termine.")
.supportedFamilies([.accessoryRectangular])
}
}
struct WatchDatePlusEventWidget: Widget {
let kind = "WatchDatePlusEvent"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
DatePlusEventComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Datum + Termin")
.description("Datum und nächster Termin in einem Slot.")
.supportedFamilies([.accessoryRectangular])
}
}
@@ -1,54 +0,0 @@
//
// CalendarrWatchControl.swift
// CalendarrWatch
//
// Created by Guido Schmit on 11.09.2026.
//
import AppIntents
import SwiftUI
import WidgetKit
struct CalendarrWatchControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(
kind: ".watchkitapp.CalendarrWatch",
provider: Provider()
) { value in
ControlWidgetToggle(
"Start Timer",
isOn: value,
action: StartTimerIntent()
) { isRunning in
Label(isRunning ? "On" : "Off", systemImage: "timer")
}
}
.displayName("Timer")
.description("A an example control that runs a timer.")
}
}
extension CalendarrWatchControl {
struct Provider: ControlValueProvider {
var previewValue: Bool {
false
}
func currentValue() async throws -> Bool {
let isRunning = true // Check if the timer is running
return isRunning
}
}
}
struct StartTimerIntent: SetValueIntent {
static let title: LocalizedStringResource = "Start a timer"
@Parameter(title: "Timer is running")
var value: Bool
func perform() async throws -> some IntentResult {
// Start / stop the timer based on `value`.
return .result()
}
}
@@ -0,0 +1,49 @@
import SwiftUI
import WidgetKit
import CalendarrCore
// A corner holds **one** value plus the curved bezel label. A second inner line
// runs into the curved text — do not add one. `.widgetLabel` is what makes
// WidgetKit lay the text along the edge; there is no arc to draw by hand.
/// Value: live countdown. Label: time and title.
struct CornerCountdownComplicationView: View {
let entry: WatchComplicationEntry
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
Group {
if let next {
Text(WatchComplicationSupport.countdownTarget(next, at: entry.date), style: .timer)
.widgetAccentable()
} else {
Image(systemName: "calendar")
}
}
.widgetLabel {
Text(WatchComplicationSupport.cornerLabel(next, at: entry.date, language: entry.language))
}
}
}
/// Value: the appointment's clock time. Label: the title.
struct CornerTimeComplicationView: View {
let entry: WatchComplicationEntry
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
Group {
if let next {
Text(next.isAllDay
? WatchL10n.t("watch.allday", entry.language)
: WatchComplicationSupport.time(next.start, language: entry.language))
.widgetAccentable()
} else {
Image(systemName: "calendar")
}
}
.widgetLabel {
Text(next?.title ?? WatchL10n.t("watch.no_events", entry.language))
}
}
}
@@ -0,0 +1,113 @@
import SwiftUI
import WidgetKit
import CalendarrCore
/// Time, title, location. The variant that answers the actual question.
struct NextEventComplicationView: View {
let entry: WatchComplicationEntry
@Environment(\.widgetFamily) private var family
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
switch family {
case .accessoryInline:
inline(next)
default:
rectangular(next)
}
}
@ViewBuilder
private func rectangular(_ next: SnapshotEvent?) -> some View {
VStack(alignment: .leading, spacing: 1) {
if let next {
Text(next.isAllDay
? WatchL10n.t("watch.allday", entry.language)
: WatchComplicationSupport.time(next.start, language: entry.language))
.font(.system(size: 12, weight: .semibold))
.widgetAccentable()
Text(next.title)
.font(.system(size: 15))
.lineLimit(1)
if !next.location.isEmpty {
Text(next.location)
.font(.system(size: 11))
.lineLimit(1)
}
} else {
Image(systemName: "calendar").font(.system(size: 13)).widgetAccentable()
Text(WatchL10n.t("watch.no_events", entry.language)).font(.system(size: 13))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func inline(_ next: SnapshotEvent?) -> some View {
let text: String = {
guard let next else { return WatchL10n.t("watch.no_events", entry.language) }
if next.isAllDay { return next.title }
return "\(WatchComplicationSupport.time(next.start, language: entry.language)) \(next.title)"
}()
return Label(text, systemImage: "calendar")
}
}
/// The next two, so back-to-back appointments are visible at once.
struct NextTwoComplicationView: View {
let entry: WatchComplicationEntry
var body: some View {
let events = WatchComplicationSupport.upcoming(in: entry.snapshot, at: entry.date, limit: 2)
VStack(alignment: .leading, spacing: 3) {
if events.isEmpty {
Image(systemName: "calendar").font(.system(size: 13)).widgetAccentable()
Text(WatchL10n.t("watch.no_events", entry.language)).font(.system(size: 13))
} else {
ForEach(events) { event in
HStack(spacing: 5) {
Text(event.isAllDay
? WatchL10n.t("watch.allday", entry.language)
: WatchComplicationSupport.time(event.start, language: entry.language))
.font(.system(size: 12, weight: .semibold))
.widgetAccentable()
Text(event.title)
.font(.system(size: 13))
.lineLimit(1)
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
/// Weekday and date plus the next appointment, so one slot does both jobs.
struct DatePlusEventComplicationView: View {
let entry: WatchComplicationEntry
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
VStack(alignment: .leading, spacing: 2) {
Text(dateLine)
.font(.system(size: 11, weight: .semibold))
.widgetAccentable()
if let next {
Text(next.isAllDay
? next.title
: "\(WatchComplicationSupport.time(next.start, language: entry.language)) \(next.title)")
.font(.system(size: 14))
.lineLimit(1)
} else {
Text(WatchL10n.t("watch.no_events", entry.language)).font(.system(size: 14))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var dateLine: String {
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(entry.language)
formatter.setLocalizedDateFormatFromTemplate("EEE d MMMM")
return formatter.string(from: entry.date).uppercased()
}
}
@@ -0,0 +1,113 @@
import SwiftUI
import WidgetKit
import CalendarrCore
/// A draining ring plus the remaining time.
struct CountdownComplicationView: View {
let entry: WatchComplicationEntry
@Environment(\.widgetFamily) private var family
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
switch family {
case .accessoryInline:
inline(next)
default:
circular(next)
}
}
@ViewBuilder
private func circular(_ next: SnapshotEvent?) -> some View {
if let next {
let target = WatchComplicationSupport.countdownTarget(next, at: entry.date)
Gauge(value: WatchComplicationSupport.gaugeFraction(to: target, at: entry.date)) {
Image(systemName: "calendar")
} currentValueLabel: {
Text(target, style: .timer)
.font(.system(size: 12))
}
.gaugeStyle(.accessoryCircularCapacity)
.widgetAccentable()
} else {
ZStack {
AccessoryWidgetBackground()
Image(systemName: "calendar").font(.system(size: 18))
}
}
}
@ViewBuilder
private func inline(_ next: SnapshotEvent?) -> some View {
if let next {
Label {
Text(WatchComplicationSupport.countdownTarget(next, at: entry.date), style: .timer)
} icon: {
Image(systemName: "timer")
}
} else {
Label(WatchL10n.t("watch.no_events", entry.language), systemImage: "calendar")
}
}
}
/// Day number and month. No appointment information — a date replacement.
struct DateComplicationView: View {
let entry: WatchComplicationEntry
@Environment(\.widgetFamily) private var family
private var day: Int {
Calendar(identifier: .gregorian).component(.day, from: entry.date)
}
var body: some View {
switch family {
case .accessoryInline:
Label("\(WatchComplicationSupport.monthAbbreviation(entry.date, language: entry.language)) \(day)",
systemImage: "calendar")
default:
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 0) {
Text("\(day)")
.font(.system(size: 22, weight: .bold))
.minimumScaleFactor(0.7)
.widgetAccentable()
Text(WatchComplicationSupport.monthAbbreviation(entry.date, language: entry.language))
.font(.system(size: 8, weight: .semibold))
}
}
}
}
}
/// How many appointments today. Says "busy", not "what".
struct TodayCountComplicationView: View {
let entry: WatchComplicationEntry
@Environment(\.widgetFamily) private var family
private var count: Int {
WatchComplicationSupport.todayCount(in: entry.snapshot, at: entry.date)
}
var body: some View {
switch family {
case .accessoryInline:
Label("\(count) \(WatchL10n.t("watch.events_today", entry.language))",
systemImage: "calendar")
default:
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 0) {
Text("\(count)")
.font(.system(size: 22, weight: .bold))
.widgetAccentable()
Text(WatchL10n.t("watch.events_today", entry.language).uppercased())
.font(.system(size: 7, weight: .semibold))
.lineLimit(1)
.minimumScaleFactor(0.6)
}
}
}
}
}
@@ -0,0 +1,44 @@
import AppIntents
import WidgetKit
import CalendarrCore
/// One calendar option in a complication's configuration.
struct WatchCalendarEntity: AppEntity, Identifiable {
let id: String
let name: String
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Kalender"
static var defaultQuery = WatchCalendarEntityQuery()
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: LocalizedStringResource(stringLiteral: name))
}
}
/// Reads the calendars from this watch's App Group container, so configuration
/// needs no network call and no phone.
struct WatchCalendarEntityQuery: EntityQuery {
func entities(for identifiers: [String]) async throws -> [WatchCalendarEntity] {
let wanted = Set(identifiers)
return SnapshotStore().readCalendars()
.filter { wanted.contains($0.id) }
.map { WatchCalendarEntity(id: $0.id, name: $0.name) }
}
func suggestedEntities() async throws -> [WatchCalendarEntity] {
SnapshotStore().readCalendars().map { WatchCalendarEntity(id: $0.id, name: $0.name) }
}
}
/// Per-complication override of the calendar selection. The in-app toggles are
/// the primary path — intent configuration on a watch face is awkward to
/// operate — so an empty selection means "whatever the app is set to".
struct WatchCalendarSelectionIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Kalender auswählen"
static var description = IntentDescription("Leer = die Auswahl aus der Watch-App.")
// Must be optional: WidgetConfigurationIntent requires optional parameter
// types, and the watchOS SDK enforces it where iOS lets a bare array pass.
@Parameter(title: "Kalender")
var selectedCalendars: [WatchCalendarEntity]?
}
@@ -0,0 +1,62 @@
import WidgetKit
import AppIntents
import SwiftUI
import CalendarrCore
struct WatchComplicationEntry: TimelineEntry {
let date: Date
let snapshot: CalendarrSnapshot?
var language: String { snapshot?.language ?? "system" }
}
/// Builds every Calendarr complication's timeline.
///
/// Entries land on event boundaries rather than on a fixed schedule, so the
/// complication flips in the exact minute the next appointment changes without
/// a network call and without spending refresh budget. New *data* arrives by
/// push from the phone, which is why the refresh policy is `.atEnd`: when the
/// timeline simply runs out, regenerating from the same snapshot is cheap and
/// correct.
struct WatchComplicationProvider: AppIntentTimelineProvider {
typealias Entry = WatchComplicationEntry
typealias Intent = WatchCalendarSelectionIntent
func placeholder(in context: Context) -> WatchComplicationEntry {
WatchComplicationEntry(date: .now, snapshot: nil)
}
func snapshot(for configuration: WatchCalendarSelectionIntent,
in context: Context) async -> WatchComplicationEntry {
WatchComplicationEntry(date: .now, snapshot: resolved(configuration))
}
func timeline(for configuration: WatchCalendarSelectionIntent,
in context: Context) async -> Timeline<WatchComplicationEntry> {
let snapshot = resolved(configuration)
let now = Date()
let dates = snapshot.map { ComplicationTimeline.entryDates(from: now, in: $0) } ?? [now]
return Timeline(entries: dates.map { WatchComplicationEntry(date: $0, snapshot: snapshot) },
policy: .atEnd)
}
/// What the complication gallery offers before anyone configures anything.
/// Required on watchOS — iOS has a default implementation, the watch does
/// not, because the gallery has to be populated.
///
/// One entry, with nothing selected: that means "whatever the app is set
/// to", which is the filter the user already chose there.
func recommendations() -> [AppIntentRecommendation<WatchCalendarSelectionIntent>] {
[AppIntentRecommendation(intent: WatchCalendarSelectionIntent(),
description: Text("Calendarr"))]
}
/// The app-level filter applies first; a per-complication selection narrows
/// further. An empty selection means "whatever the app is set to".
private func resolved(_ configuration: WatchCalendarSelectionIntent) -> CalendarrSnapshot? {
guard case .ok(let snapshot) = SnapshotStore().read() else { return nil }
return snapshot
.filtered(excludingCalendarKeys: WatchFilterStore.load())
.filtered(toCalendarKeys: Set((configuration.selectedCalendars ?? []).map(\.id)))
}
}
@@ -0,0 +1,58 @@
import Foundation
import CalendarrCore
enum WatchComplicationSupport {
/// The first event that has not ended yet — including one running now,
/// which is what "what is next" means when you are already in a meeting.
/// Matches `WatchPushPolicy.nextEventIdentity`, deliberately.
static func nextEvent(in snapshot: CalendarrSnapshot?, at now: Date) -> SnapshotEvent? {
snapshot?.events.filter { $0.end > now }.min { $0.start < $1.start }
}
static func upcoming(in snapshot: CalendarrSnapshot?, at now: Date, limit: Int) -> [SnapshotEvent] {
guard let snapshot else { return [] }
return Array(snapshot.events.filter { $0.end > now }.sorted { $0.start < $1.start }.prefix(limit))
}
static func todayCount(in snapshot: CalendarrSnapshot?, at now: Date,
calendar: Calendar = Calendar(identifier: .gregorian)) -> Int {
guard let snapshot else { return 0 }
let dayStart = calendar.startOfDay(for: now)
guard let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return 0 }
return snapshot.events.filter { $0.start < dayEnd && $0.end > dayStart }.count
}
/// What a countdown counts to: the start if it has not begun, otherwise the
/// end. Counting up from a start time that has passed reads as broken.
static func countdownTarget(_ event: SnapshotEvent, at now: Date) -> Date {
event.start > now ? event.start : event.end
}
/// How full a draining ring should be, over a two-hour lead window.
static func gaugeFraction(to target: Date, at now: Date) -> Double {
let window: TimeInterval = 2 * 3600
return min(1, max(0, target.timeIntervalSince(now) / window))
}
static func time(_ date: Date, language: String) -> String {
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(language)
formatter.dateFormat = "HH:mm"
return formatter.string(from: date)
}
static func monthAbbreviation(_ date: Date, language: String) -> String {
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(language)
formatter.dateFormat = "LLL"
return formatter.string(from: date).uppercased()
}
/// The curved bezel label for a corner complication: time plus title, or a
/// plain "no events".
static func cornerLabel(_ event: SnapshotEvent?, at now: Date, language: String) -> String {
guard let event else { return WatchL10n.t("watch.no_events", language) }
if event.isAllDay { return event.title }
return "\(time(event.start, language: language)) \(event.title)"
}
}