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,17 +1,19 @@
|
||||
//
|
||||
// CalendarrWatchApp.swift
|
||||
// CalendarrWatch Watch App
|
||||
//
|
||||
// Created by Guido Schmit on 11.09.2026.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct CalendarrWatch_Watch_AppApp: App {
|
||||
struct CalendarrWatchApp: App {
|
||||
@State private var model = WatchSnapshotModel()
|
||||
|
||||
init() {
|
||||
// The session has to be live before the first payload arrives, and a
|
||||
// complication push can wake this app with no UI on screen at all.
|
||||
WatchSnapshotReceiver.shared.activate()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
WatchAgendaView()
|
||||
.environment(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// CalendarrWatch Watch App
|
||||
//
|
||||
// Created by Guido Schmit on 11.09.2026.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
Image(systemName: "globe")
|
||||
.imageScale(.large)
|
||||
.foregroundStyle(.tint)
|
||||
Text("Hello, world!")
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import SwiftUI
|
||||
import CalendarrCore
|
||||
|
||||
/// The whole app: a scrolling agenda grouped by day, plus a way to reach the
|
||||
/// calendar filter. Read-only by design — the snapshot carries neither
|
||||
/// recurrence rules nor permissions, so it is deliberately not enough to edit.
|
||||
///
|
||||
/// Deliberately does **not** apply `snapshot.theme` to the background. The
|
||||
/// always-on display and OLED make black the right choice here; the theme only
|
||||
/// contributes the per-event accent colours.
|
||||
struct WatchAgendaView: View {
|
||||
@Environment(WatchSnapshotModel.self) private var model
|
||||
@State private var showingFilter = false
|
||||
|
||||
private var calendar: Calendar {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.locale = WatchL10n.locale(model.language)
|
||||
return calendar
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
content
|
||||
.navigationTitle(WatchL10n.t("watch.title", model.language))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { showingFilter = true } label: {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
.accessibilityLabel(WatchL10n.t("watch.calendars", model.language))
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingFilter) { WatchCalendarFilterView() }
|
||||
}
|
||||
.onAppear { WatchSnapshotReceiver.shared.requestRefresh() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let snapshot = model.visible, !groups(in: snapshot).isEmpty {
|
||||
List {
|
||||
ForEach(groups(in: snapshot), id: \.day) { group in
|
||||
Section(WatchEventFormat.dayHeader(group.day, language: model.language,
|
||||
calendar: calendar)) {
|
||||
ForEach(group.events) { event in
|
||||
NavigationLink {
|
||||
WatchEventDetailView(event: event,
|
||||
calendarName: model.calendarName(for: event.calendarKey),
|
||||
language: model.language)
|
||||
} label: {
|
||||
WatchEventRow(event: event, language: model.language)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(WatchL10n.t("watch.as_of", model.language)) \(WatchEventFormat.time(snapshot.writtenAt, language: model.language))")
|
||||
// Beyond the coverage window the snapshot carries no
|
||||
// information, which is not the same as "nothing
|
||||
// scheduled". Saying so is what keeps the empty tail of
|
||||
// the list from reading as a free afternoon.
|
||||
Text("\(WatchL10n.t("watch.no_information", model.language)) \(coverageEndLine(snapshot))")
|
||||
if let error = WatchSnapshotReceiver.shared.lastError {
|
||||
Text(error).foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.listStyle(.carousel)
|
||||
} else {
|
||||
WatchStatusView(result: model.result, language: model.language)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DayGroup {
|
||||
let day: Date
|
||||
let events: [SnapshotEvent]
|
||||
}
|
||||
|
||||
/// The last day the snapshot can speak for, formatted for the footer.
|
||||
private func coverageEndLine(_ snapshot: CalendarrSnapshot) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = WatchL10n.locale(model.language)
|
||||
formatter.setLocalizedDateFormatFromTemplate("d MMM")
|
||||
let lastCovered = calendar.date(byAdding: .day, value: -1, to: snapshot.coverageEnd)
|
||||
return formatter.string(from: lastCovered ?? snapshot.coverageEnd)
|
||||
}
|
||||
|
||||
/// Group the covered window by day. Days the snapshot cannot speak for are
|
||||
/// simply absent — never rendered as "nothing scheduled", which would look
|
||||
/// correct and be false.
|
||||
private func groups(in snapshot: CalendarrSnapshot) -> [DayGroup] {
|
||||
let calendar = self.calendar
|
||||
let from = max(snapshot.coverageStart, calendar.startOfDay(for: Date()))
|
||||
var buckets: [Date: [SnapshotEvent]] = [:]
|
||||
|
||||
for event in snapshot.events where event.end > from {
|
||||
var day = calendar.startOfDay(for: max(event.start, from))
|
||||
// A multi-day event appears on each day it covers.
|
||||
while day < event.end && day < snapshot.coverageEnd {
|
||||
buckets[day, default: []].append(event)
|
||||
guard let next = calendar.date(byAdding: .day, value: 1, to: day) else { break }
|
||||
day = next
|
||||
}
|
||||
}
|
||||
|
||||
return buckets
|
||||
.map { DayGroup(day: $0.key, events: $0.value.sorted { $0.start < $1.start }) }
|
||||
.sorted { $0.day < $1.day }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import SwiftUI
|
||||
import CalendarrCore
|
||||
|
||||
/// Narrows what this watch shows, on top of what the phone already filtered.
|
||||
/// One toggle here affects the agenda and every complication at once.
|
||||
struct WatchCalendarFilterView: View {
|
||||
@Environment(WatchSnapshotModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if model.calendars.isEmpty {
|
||||
Text(WatchL10n.t("watch.waiting", model.language))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(model.calendars) { calendar in
|
||||
row(calendar)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(WatchL10n.t("watch.calendars", model.language))
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ calendar: SnapshotCalendar) -> some View {
|
||||
let shown = !model.isHidden(calendar.id)
|
||||
return Button {
|
||||
model.setHidden(calendar.id, shown)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
// A checkbox, not a Toggle: a switch reads as a state indicator
|
||||
// you wait on rather than a control you tap.
|
||||
Image(systemName: shown ? "checkmark.square.fill" : "square")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(shown ? Color.accentColor : Color.secondary)
|
||||
Circle()
|
||||
.fill(Color(watchHex: calendar.colorHex))
|
||||
.frame(width: 7, height: 7)
|
||||
Text(calendar.name)
|
||||
.font(.system(size: 14))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityAddTraits(shown ? [.isSelected] : [])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
import CalendarrCore
|
||||
|
||||
/// Everything the snapshot carries about one event, which is deliberately not
|
||||
/// everything the server knows: notes, recurrence and permissions are dropped
|
||||
/// on the way out, so there is nothing here to edit.
|
||||
struct WatchEventDetailView: View {
|
||||
let event: SnapshotEvent
|
||||
/// Nil when the calendar list has not arrived yet, which is possible right
|
||||
/// after a first install.
|
||||
let calendarName: String?
|
||||
let language: String
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Circle()
|
||||
.fill(Color(watchHex: event.colorHex))
|
||||
.frame(width: 8, height: 8)
|
||||
.padding(.top, 5)
|
||||
Text(event.title)
|
||||
.font(.headline)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Label(WatchEventFormat.timeRange(event, language: language),
|
||||
systemImage: "clock")
|
||||
.font(.footnote)
|
||||
|
||||
Label(dateLine, systemImage: "calendar")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if !event.location.isEmpty {
|
||||
Label(event.location, systemImage: "mappin.and.ellipse")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if let calendarName {
|
||||
Label(calendarName, systemImage: "list.bullet.rectangle")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private var dateLine: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = WatchL10n.locale(language)
|
||||
formatter.setLocalizedDateFormatFromTemplate("EEEE d MMMM")
|
||||
return formatter.string(from: event.start)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import SwiftUI
|
||||
import CalendarrCore
|
||||
|
||||
/// One event: a colour bar for the calendar, the time range, the title.
|
||||
struct WatchEventRow: View {
|
||||
let event: SnapshotEvent
|
||||
let language: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 7) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color(watchHex: event.colorHex))
|
||||
.frame(width: 3)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(WatchEventFormat.timeRange(event, language: language))
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(event.title)
|
||||
.font(.system(size: 14))
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
/// Local copy of the hex initialiser: `CalendarrCore` is Foundation-only by
|
||||
/// design, so colour parsing cannot live there.
|
||||
init(watchHex hex: String) {
|
||||
let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var value: UInt64 = 0
|
||||
Scanner(string: cleaned).scanHexInt64(&value)
|
||||
guard cleaned.count == 6 else { self = .gray; return }
|
||||
self.init(red: Double((value >> 16) & 0xFF) / 255,
|
||||
green: Double((value >> 8) & 0xFF) / 255,
|
||||
blue: Double(value & 0xFF) / 255)
|
||||
}
|
||||
}
|
||||
|
||||
enum WatchEventFormat {
|
||||
static func timeRange(_ event: SnapshotEvent, language: String) -> String {
|
||||
if event.isAllDay { return WatchL10n.t("watch.allday", language) }
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = WatchL10n.locale(language)
|
||||
formatter.dateFormat = "HH:mm"
|
||||
let start = formatter.string(from: event.start)
|
||||
guard event.end > event.start else { return start }
|
||||
return "\(start) – \(formatter.string(from: event.end))"
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// "Heute", "Morgen", else "Do 11. Sep".
|
||||
static func dayHeader(_ day: Date, language: String,
|
||||
calendar: Calendar, now: Date = Date()) -> String {
|
||||
if calendar.isDate(day, inSameDayAs: now) {
|
||||
return WatchL10n.t("watch.today", language)
|
||||
}
|
||||
if let tomorrow = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now)),
|
||||
calendar.isDate(day, inSameDayAs: tomorrow) {
|
||||
return WatchL10n.t("watch.tomorrow", language)
|
||||
}
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = WatchL10n.locale(language)
|
||||
formatter.setLocalizedDateFormatFromTemplate("EEE d MMM")
|
||||
return formatter.string(from: day)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import SwiftUI
|
||||
import CalendarrCore
|
||||
|
||||
/// Every reason there is nothing to show, named. "Never received", "signed out"
|
||||
/// and "genuinely empty" call for different sentences, and showing the wrong one
|
||||
/// sends the user looking in the wrong place.
|
||||
struct WatchStatusView: View {
|
||||
let result: SnapshotReadResult
|
||||
let language: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(message)
|
||||
.font(.footnote)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var symbol: String {
|
||||
switch result {
|
||||
case .ok: return "calendar"
|
||||
case .loggedOut: return "person.crop.circle.badge.xmark"
|
||||
case .neverWritten: return "iphone.and.arrow.forward"
|
||||
case .incompatible: return "arrow.down.circle"
|
||||
case .unreadable: return "exclamationmark.triangle"
|
||||
}
|
||||
}
|
||||
|
||||
private var message: String {
|
||||
switch result {
|
||||
case .ok: return WatchL10n.t("watch.no_events", language)
|
||||
case .loggedOut: return WatchL10n.t("watch.sign_in", language)
|
||||
case .neverWritten: return WatchL10n.t("watch.waiting", language)
|
||||
case .incompatible: return WatchL10n.t("watch.update", language)
|
||||
case .unreadable: return WatchL10n.t("watch.unreadable", language)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
import WidgetKit
|
||||
import CalendarrCore
|
||||
|
||||
/// What the watch views read. Holds the last received snapshot, the calendar
|
||||
/// list, and this watch's filter.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class WatchSnapshotModel {
|
||||
private(set) var result: SnapshotReadResult = .neverWritten
|
||||
private(set) var calendars: [SnapshotCalendar] = []
|
||||
private(set) var hiddenKeys: Set<String> = WatchFilterStore.load()
|
||||
|
||||
private let store = SnapshotStore()
|
||||
private var changeToken: SnapshotChangeNotifier.ObservationToken?
|
||||
|
||||
init() {
|
||||
reload()
|
||||
// The receiver writes through SnapshotStore, which broadcasts. Listening
|
||||
// here means the UI does not have to be notified separately.
|
||||
changeToken = SnapshotChangeNotifier.observe { [weak self] in
|
||||
Task { @MainActor in self?.reload() }
|
||||
}
|
||||
}
|
||||
|
||||
func reload() {
|
||||
result = store.read()
|
||||
calendars = store.readCalendars()
|
||||
}
|
||||
|
||||
/// The snapshot with this watch's filter applied, or nil when there is
|
||||
/// nothing usable to show.
|
||||
var visible: CalendarrSnapshot? {
|
||||
result.snapshot?.filtered(excludingCalendarKeys: hiddenKeys)
|
||||
}
|
||||
|
||||
var language: String { result.snapshot?.language ?? "system" }
|
||||
|
||||
func isHidden(_ key: String) -> Bool { hiddenKeys.contains(key) }
|
||||
|
||||
/// The display name for an event's calendar. Events carry only a
|
||||
/// `calendarKey`; the names travel separately, in the calendar list.
|
||||
func calendarName(for key: String) -> String? {
|
||||
calendars.first { $0.id == key }?.name
|
||||
}
|
||||
|
||||
func setHidden(_ key: String, _ hidden: Bool) {
|
||||
if hidden { hiddenKeys.insert(key) } else { hiddenKeys.remove(key) }
|
||||
WatchFilterStore.save(hiddenKeys)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
import WidgetKit
|
||||
@_spi(Writer) import CalendarrCore
|
||||
|
||||
/// Takes what the phone sends and writes it into this watch's own App Group
|
||||
/// container, using the same `SnapshotStore` the phone writes with. One wire
|
||||
/// format, one reader, no drift.
|
||||
///
|
||||
/// Only the app runs a `WCSession`; a widget extension cannot. The complications
|
||||
/// always read what this wrote.
|
||||
@MainActor
|
||||
final class WatchSnapshotReceiver: NSObject {
|
||||
static let shared = WatchSnapshotReceiver()
|
||||
|
||||
private let store = SnapshotStore()
|
||||
private let sessions = SharedSessionStore()
|
||||
|
||||
/// Shown in the agenda footer, because a silent failure here looks exactly
|
||||
/// like an empty calendar.
|
||||
private(set) var lastError: String?
|
||||
|
||||
func activate() {
|
||||
guard WCSession.isSupported() else { return }
|
||||
WCSession.default.delegate = self
|
||||
WCSession.default.activate()
|
||||
}
|
||||
|
||||
/// Ask the phone for the current state. Covers "the app is open in front of
|
||||
/// me and looks stale"; the other two paths are push-driven.
|
||||
func requestRefresh() {
|
||||
let session = WCSession.default
|
||||
guard session.activationState == .activated, session.isReachable else { return }
|
||||
|
||||
session.sendMessage(["request": "snapshot"],
|
||||
replyHandler: { reply in
|
||||
Task { @MainActor in self.apply(reply) }
|
||||
},
|
||||
errorHandler: { error in
|
||||
Task { @MainActor in self.lastError = error.localizedDescription }
|
||||
})
|
||||
}
|
||||
|
||||
func apply(_ dict: [String: Any]) {
|
||||
guard !dict.isEmpty else { return }
|
||||
do {
|
||||
let payload = try WatchTransportPayload.decode(dict)
|
||||
// This write posts SnapshotChangeNotifier, which is what the read
|
||||
// model listens for — so the UI updates without being told twice.
|
||||
try store.write(payload.snapshot)
|
||||
try store.writeCalendars(payload.calendars)
|
||||
if let session = payload.session { try sessions.write(session) }
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
lastError = nil
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WatchSnapshotReceiver: WCSessionDelegate {
|
||||
nonisolated func session(_ session: WCSession,
|
||||
activationDidCompleteWith state: WCSessionActivationState,
|
||||
error: Error?) {
|
||||
if let error {
|
||||
Task { @MainActor in self.lastError = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
/// The main path: latest state only, delivered when this app launches or wakes.
|
||||
nonisolated func session(_ session: WCSession,
|
||||
didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||
Task { @MainActor in self.apply(applicationContext) }
|
||||
}
|
||||
|
||||
/// The complication push, which is the only path that wakes this app while
|
||||
/// it is not running.
|
||||
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
||||
Task { @MainActor in self.apply(userInfo) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user