chore(repo): iOS-App als ios/ ins Hauptrepo uebernehmen

Calendarr-IOS wird mit vollstaendiger Historie als Unterverzeichnis
ios/ eingebunden. Damit liegen Server, Web, Android und iOS in einem
gemeinsamen Repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-14 09:20:12 +02:00
co-authored by Claude Opus 5
107 changed files with 19373 additions and 0 deletions
@@ -0,0 +1,289 @@
# Apple Watch app and watch-face complications
Date: 2026-09-11
Status: implemented — pending on-device verification
## Goal
Put "what is my next appointment" on the wrist as watch-face complications, with a
tap-through app that shows a plain agenda list and lets the user narrow which
calendars count. Nothing more.
## Non-goals
- Creating or editing events on the watch. The snapshot is deliberately lossy —
no notes, recurrence rules or permissions — which is enough to *display* a
calendar and deliberately not enough to edit one. Editing would mean a second
API client and is out of scope.
- Running without the iPhone. The watch is a read-only consumer of the phone's
snapshot. Direct server access stays available as a later option (the server is
reachable over HTTPS) but is not built now.
- Wear OS parity. A separate project with its own decision.
## Platform floor
iOS 26 (unchanged) and watchOS 26. Nothing below that needs to work, so no
availability branching.
## Design rules
- **No emojis as icons**, anywhere — SF Symbols only (`Image(systemName:)`). An
emoji renders differently on every platform and font, and complications tint
SF Symbols with the watch face's colour, which an emoji ignores.
- Exactly one element per complication is marked `widgetAccentable()`.
- The watch app's background stays black rather than following `snapshot.theme`;
the always-on display and OLED make that the right choice. `snapshot.theme`
supplies accents only.
## Architecture
```
iPhone Apple Watch
────────────────────────────────── ──────────────────────────────
CalendarStore.publishWidgetSnapshot()
├─ SnapshotStore.write() (App Group iOS)
├─ WidgetTimelineNotifier.reload()
└─ WatchSyncService.push() ──WCSession──▶ WatchSnapshotReceiver
├─ SnapshotStore.write() (App Group watchOS)
├─ SharedSessionStore.write()
└─ WidgetCenter.reloadAllTimelines()
BGAppRefreshTask (new)
└─ refresh → publishWidgetSnapshot() Watch app foreground
└─ sendMessage ──▶ pull when reachable
```
App Groups are per-device: the phone's container is not the watch's. The watch
therefore receives the snapshot over WatchConnectivity and writes it into *its
own* App Group container using the same `SnapshotStore` from CalendarrKit. One
format, one reader, no drift — the watch is exactly the read-only consumer the
CalendarrKit README already describes.
`CalendarrAppGroup.current` is already correct for watchOS: the
`#if os(macOS) || targetEnvironment(macCatalyst)` guard falls through to the
unprefixed identifier, which is what watchOS requires.
## Module changes
| Module | Change |
|---|---|
| `CalendarrKit` | Add `.watchOS(.v26)` to `platforms`. No code change — the Foundation-only constraint pays off here. |
| `CalendarrKit` | New `Snapshot/WatchCoverage.swift`: the transport trimming policy plus `CalendarrSnapshot.trimmedForWatch()`. Shared by sender and receiver so they cannot disagree about it. |
| iOS app | New `Services/WatchSyncService.swift` — WCSession sender, called from `publishWidgetSnapshot()`. |
| iOS app | New `Services/BackgroundRefresh.swift` — `BGAppRefreshTask` that refreshes events, republishes the snapshot and pushes to the watch. Benefits the existing widgets too. |
| New target | `CalendarrWatch` (watchOS app): receiver, agenda, event detail, calendar filter. |
| New target | `CalendarrWatchComplications` (watchOS widget extension): the complications. |
## Transport
### Trimming policy
`WatchCoverage`, mirroring the existing `SnapshotCoverage`:
- `daysBehind = 1`
- `daysAhead = 14`
- `maxEvents = 120`
`trimmedForWatch()` must narrow `coverageStart`/`coverageEnd` along with the
events. Carrying 14 days of events while still claiming a 42-day window would
make the watch render an empty week that it has no information about — the exact
failure the README warns against. If the `maxEvents` cap truncates before
`daysAhead` is reached, `coverageEnd` is pulled back to the start of the day of
the last retained event.
Trimmed payload is roughly 20–30 KB, far below the WatchConnectivity limit.
### Three delivery paths
Each one alone leaves a gap, so all three ship:
1. **`updateApplicationContext`** — the main path. Carries only the latest state,
survives restarts, and has no queue to overflow. Delivered when the watch app
next launches or wakes.
2. **`transferCurrentComplicationUserInfo`** — sent only when the *next* event
changed identity (first event after now, compared by id and start). This is
the only path that wakes the watch app while it is not running, which is why
it exists; its daily budget (~50) is why it is not used for every write.
Additionally rate-limited to at most 4 per hour.
3. **`sendMessage` pull on watch app foreground** — covers "I am looking at it
right now and it is stale".
Payload is a property-list dictionary of `Data` blobs: `snapshot`, `calendars`,
`session`, plus an integer `schema` matching `CalendarrSnapshot.currentSchema`. A
receiver refuses a higher schema rather than guessing, same rule as the file
format.
Only the watch **app** runs WCSession; a widget extension cannot. The extension
always reads what the app last wrote.
## Timeline strategy
This deliberately differs from the iOS provider, which emits 24 hourly entries
(`CalendarrTimelineProvider.swift`). On the watch, entries are placed **on event
boundaries**: now, plus every event start and end inside the next 24 hours,
deduplicated and sorted, with hourly filler so at least 24 hours are covered, and
capped at 100 entries. The complication then flips in the exact minute the next
appointment changes, with no network call and no refresh budget spent.
Refresh policy is `.atEnd`. Data changes arrive by push; when the timeline simply
runs out it regenerates from the same snapshot, which is cheap and correct.
Countdowns use `Text(event.start, style: .timer)`, which updates live without
consuming timeline entries and dims correctly on the always-on display.
## Complication catalogue
Eight kinds. Families in brackets.
| Kind | Families | Content |
|---|---|---|
| `WatchNextEvent` | rectangular, inline | Time, title, location |
| `WatchCornerCountdown` | corner | Value: live countdown. Curved label: `"14:30 Zahnarzt"` |
| `WatchCornerTime` | corner | Value: start time. Curved label: event title |
| `WatchCountdown` | circular, inline | Remaining time; circular draws a draining ring |
| `WatchDate` | circular, inline | Day number and month |
| `WatchTodayCount` | circular, inline | Number of events today |
| `WatchNextTwo` | rectangular | The next two events, time plus title |
| `WatchDatePlusEvent` | rectangular | Weekday and date plus the next event |
`WatchDate`, `WatchTodayCount` and `WatchCountdown` are ports of the existing
lock-screen widgets in `LockScreenWidgetViews.swift`, which already render
`accessoryCircular`, `accessoryRectangular` and `accessoryInline`.
### Which face shows what
A round Infograph-style face has **no rectangular slot**: it offers four corners
and a circular sub-dial. The rectangular kinds therefore only appear on
Modular-family faces. Since the user's face is round, `accessoryCorner` is the
primary slot and gets the most attention; the rectangular kinds still ship
because they are cheap and cover the other faces.
### accessoryCorner
New and watchOS-only. The curved bezel text comes from
`.widgetLabel { Text(...) }` — WidgetKit lays it along the edge, so no manual arc
drawing.
The hard constraint: a corner holds **one** value plus the curved label. A second
inner line runs into the curved text and must not be attempted. Both corner kinds
therefore carry a single value and put the context in the label.
Two content variants ship rather than one, because they share the whole data path
and differ only in formatting — the user picks on the watch face. A
date-in-the-corner variant was considered and dropped: it carries no appointment
information, which is the whole point of the complication.
watchOS tints complications monochrome using the watch face's colour, so
per-calendar colours are not available in complications. Calendar colours appear
in the app only.
## Watch app
- **Agenda** — a scrolling list grouped by day, day headers, one row per event
with a leading bar in the event's colour, time range or all-day label, and
title. Covers the trimmed window; days outside `snapshot.covers(date)` are not
rendered as empty but greyed with a note.
- **Event detail** — title, time, location, calendar name. Read-only.
- **Calendar filter** — one row per calendar from `readCalendars()`, rendered as a
`Button` with an SF Symbol checkbox (`checkmark.square.fill` / `square`) plus
the calendar's colour dot. Deliberately **not** a watchOS `Toggle`: its switch
reads as a state indicator you wait on rather than a control you tap.
The background stays black rather than following `snapshot.theme`, because the
always-on display and OLED make black the right choice on the watch.
`snapshot.theme` is used for accents only.
## Calendar filter semantics
Stored in the watch's App Group `UserDefaults` under
`watchHiddenCalendarKeys: [String]`, so the widget extension reads the same
value and one toggle affects every complication at once.
The snapshot already excludes hidden and banished calendars, so the watch filter
narrows *further* and never contradicts the phone. `CalendarSelectionIntent` is
ported so a complication can override the app-level filter, but the in-app
toggles are the primary path: intent configuration is awkward to operate on the
watch face itself.
## Error and empty states
`SnapshotReadResult` already covers most of them. One case is new.
| State | Display |
|---|---|
| `.neverWritten` / nothing received yet | "Open Calendarr on your iPhone once" |
| `.loggedOut` | "Sign in on your iPhone" — never show the previous events |
| `.incompatible` | "Update the watch app" |
| outside `covers(date)` | Greyed with a note, never rendered as "nothing scheduled" |
| Snapshot present but visibly old | Show `writtenAt` as "as of …" rather than implying freshness |
The last one has no iPhone equivalent: the phone writes its own cache, so it is
never stale in a way the reader cannot see. The watch can hold a snapshot from
days ago because the phone was unreachable, and must say so.
## iOS background refresh
- Register `BGAppRefreshTaskRequest` with identifier
`com.scarriffleservices.calendarr.ios.refresh` in `CalendarrApp`, rescheduling
after every run with `earliestBeginDate` 30 minutes out.
- Info.plist needs `BGTaskSchedulerPermittedIdentifiers` and the `fetch`
background mode.
- The task fetches the coverage window, calls `publishWidgetSnapshot()`, and lets
the existing call chain reload widgets and push to the watch. It must finish
well inside the ~30 s budget and set an expiration handler.
iOS decides when this runs; frequency follows how the user actually opens the
app. This improves freshness, it does not guarantee it.
## Build, signing and distribution
- Bundle identifiers: `com.scarriffleservices.calendarr.ios.watchkitapp` and
`com.scarriffleservices.calendarr.ios.watchkitapp.complications`. The
`.watchkitapp` suffix is required for a companion watch app;
`WKCompanionAppBundleIdentifier` points at the iOS app.
- Both new targets need `com.apple.security.application-groups` set to
`group.com.scarriffleservices.calendarr`, unprefixed.
- No keychain access group on the watch: there is no token there to protect.
- Register both bundle IDs and enable the App Group for them in the developer
portal.
- App Store Connect: a watch app is not a new app record, it is a new target in
the existing one — a normal version submission and a normal review. Watch
screenshots are additionally required.
- The server needs no change at all.
## Risks
1. **Xcode does not currently support the test device.** Xcode 26.6 ships the
watchOS 26.5 SDK; the target watch runs watchOS 27 beta. Installing builds on
it needs an Xcode with watchOS 27 support. Code can be written and built
against watchOS 26 in the meantime, but on-device verification is blocked
until that Xcode is installed. This is the first thing to resolve.
2. **WatchConnectivity background delivery is opportunistic** and cannot be
verified on this machine — only on a real paired device.
3. **Complication push budget.** Path 2 is deliberately rate-limited; if pushes
are exhausted, freshness silently degrades to path 1. The stale-snapshot
display state is what keeps that honest rather than wrong.
4. **First run is a chicken-and-egg.** The watch app can be installed before the
phone has ever pushed. The empty state must name the fix rather than look
broken.
5. **App review** may ask for demo credentials, since the app needs a
self-hosted server to show anything.
## Verification
Per the project's working rules, this machine builds but does not run apps:
```
xcodebuild -project "Calendarr iOS.xcodeproj" -scheme CalendarrWatch \
-sdk watchsimulator CODE_SIGNING_ALLOWED=NO build
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift build # CalendarrKit
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test
```
Behaviour, layout and complication refresh are verified by the user on the
device. Nothing about the look is asserted here.
## Later, not now
- Direct HTTPS fetch from the watch, for freshness independent of the phone.
- Creating events on the watch.
- Wear OS equivalent.