Archived
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:
co-authored by
Claude Opus 5
parent
6a7afe0647
commit
f9bc1965a5
@@ -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)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user