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:
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
Reference in New Issue
Block a user