docs(plans): add iOS home screen widget port plan

This commit is contained in:
Johannes Millan 2026-07-07 17:02:41 +02:00
parent 7f953aae0f
commit 3e56836a23

View file

@ -0,0 +1,153 @@
# iOS home screen widget (port of the Android widget)
Port of the Android task-list widget (PR #8737, plan
[2026-07-03-android-home-screen-widget.md](2026-07-03-android-home-screen-widget.md))
to iOS via a WidgetKit extension. The Android architecture — one-way versioned JSON
snapshot + last-wins done-tap queue + render-time pending overlay — is exactly the
shape WidgetKit wants, so this is a view-layer + plumbing port, not a redesign.
## Architecture mapping (reuse the `v: 1` contract unchanged)
| Android | iOS |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `KeyValStore` blob `widget_data` (SQLite) | App Group `UserDefaults(suiteName:)`, same key, same JSON |
| `TaskListWidgetProvider` + `RemoteViewsService` + XML layouts | WidgetKit extension: `TimelineProvider` + SwiftUI list |
| `JavaScriptInterface.saveToDbWrapped` / `updateWidget()` | Local Capacitor plugin: `setWidgetData(json)` + `WidgetCenter.shared.reloadTimelines` |
| Checkbox tap → `WidgetDoneQueue` (SharedPreferences) | `Button(intent:)` → AppIntent writes the same `{taskId: targetIsDone}` map to App Group defaults |
| Render-time pending-done overlay (`WidgetData.parse(pendingDoneTargets:)`) | Identical overlay in the Swift parser — port line-for-line incl. the JSON-null guards |
| Drain triggers: `onResume$` + live LocalBroadcast | Capacitor `resume` only (see limitations) |
| Header/row tap → launch activity | `widgetURL` deep link → open app (no per-task navigation, matching Android v1) |
- **Single-writer invariant carries over:** Angular is the only writer of
`widget_data`; the AppIntent writes only the queue; the widget overlays pending
targets at render time. The write race stays structurally impossible.
- **Timeline policy `.never`** — entries never expire; every refresh is an explicit
`reloadTimelines` push (from the app after a snapshot write, from the AppIntent
after a queue write). No polling, no background refresh budget games.
- **Contract:** identical `v: 1` blob (`AndroidWidgetData` in
`src/app/features/android/android-widget.model.ts`). The Swift parser becomes the
third named end; unknown `v` renders an empty widget, same as Kotlin. Rename the
TS types to platform-neutral (`WidgetData`) as part of the Angular step.
## Work items
### 1. Xcode project + signing (the friction half — no logic)
- New WidgetKit extension target `SupWidget`, bundle ID
`com.super-productivity.app.widget`, **deployment target iOS 17.0** while the app
stays at 16.0. Rationale: interactive widgets (`Button(intent:)`/AppIntents) are
17+; shipping a look-but-don't-touch fallback for 16 means a second code path and
a worse widget — below 17 the widget is simply unavailable, the app is unaffected.
Revisit only if 16.x adoption data says otherwise.
- App Groups capability on **both** targets, group ID
`group.com.super-productivity.app`.
- Apple developer portal: register the extension App ID, enable the App Group on
both App IDs, regenerate both provisioning profiles.
- CI (`.github/workflows/build-ios.yml`): signing uses a single manually-managed
profile secret (`IOS_PROVISION_PROFILE`). Needs a second secret for the extension
profile, installed the same way, plus the extra entry in export options. The
existing "Apple Distribution" cert covers both targets.
- `npx cap sync ios` must not fight the new target — extension targets live outside
Capacitor's managed group, verify once and note in the extension folder README.
### 2. Widget extension (Swift, ~250400 lines, all new)
- `WidgetData.swift`: parse the `v: 1` JSON + pending-done overlay. Port the Kotlin
parser's edge cases: version gate → empty list, absent `projectId` (Angular omits,
never null), `projectColors` lookup. Unit-test in the extension target with the
same golden JSON used by `WidgetDataTest.kt` — copy the fixture so both parsers
are locked to one shape.
- `DoneQueue.swift`: last-wins `[String: Bool]` in App Group defaults; `setTarget`,
`getAndClear`, `peek` — mirrors `WidgetDoneQueue.kt` semantics (get-and-clear
atomicity via a serial queue; UserDefaults is process-safe enough for a
single-slot JSON string, matching the SharedPreferences approach).
- `ToggleDoneIntent` (AppIntent): parameters `taskId` + `setDone` (target computed
at render time from the _displayed_ state, so repeated taps toggle — same fix as
Android punch-list item 1's spiritual sibling). Writes queue, returns; WidgetKit
re-renders automatically after an intent, overlay shows the new state.
- `TaskListWidget.swift`: `TimelineProvider` (single entry, `.never`), SwiftUI view
— header (app name + count, tap = `widgetURL`), task rows (project color bar,
title, checkbox `Button(intent:)`), empty state. `.systemMedium` + `.systemLarge`
families for v1. Static dark-leaning styling to match the Android v1 look;
follow the system `colorScheme` only if free.
### 3. Bridge plugin (Swift + ObjC stub, ~100 lines)
Local Capacitor plugin `WidgetBridgePlugin` in `ios/App/App/` following the existing
`StoreReviewPlugin.swift`/`.m` pattern:
- `setWidgetData({ json })` → write to App Group defaults, then
`WidgetCenter.shared.reloadTimelines(ofKind:)`.
- `getAndClearDoneQueue()` → returns `{ json: string | null }`.
No `getWidgetTaskQueue` equivalent — share-intent handling is out of scope.
### 4. Angular (~100150 lines, mostly generalizing)
- Extract the platform-specific write out of `WidgetDataService`: keep the
selector-read + last-pushed-JSON dedupe, branch the sink —
`IS_ANDROID_WEB_VIEW``androidInterface`, `Capacitor.getPlatform() === 'ios'`
`registerPlugin<WidgetBridgePlugin>('WidgetBridge')` (pattern:
`src/app/features/dialog-please-rate/store-review/index.ts`).
- Effects: reuse `android-widget.effects.ts` triggers by widening the gate to
"android webview OR iOS native". Triggers on iOS: state change (debounced, with
the existing hydration-guard), sync-window falling edge, and Capacitor `pause`
(App Group write is fast; fits the ~5s background grace). Drain trigger: Capacitor
`resume` + initial-data-loaded gate, feeding the existing pure
`getTaskDoneChangesToApply()` — no iOS-specific drain logic.
- Move/rename `features/android/android-widget.*`
`features/widget/` with platform-neutral names; `android-interface.ts` keeps its
role as the Android sink. Same aggregated `WIDGET_TASKS_UPDATED` snack (already in
`en.json`).
- Sync-correctness check: unchanged risk profile — effects stay `dispatch: false`
consumers of state; the drain path produces user-intent ops exactly like the
Android drain (dedup + skip-already-in-target prevents replay noise); nothing new
writes during the sync window.
## Known limitations (deliberate, matching or below Android v1)
- **No live drain while the app is alive.** Android pokes the running WebView via
LocalBroadcast; iOS has no cheap equivalent from an extension process (Darwin
notifications = over-engineering for v1). A tap while the app is foregrounded
applies on next `resume`. Mitigated by the pending overlay: the widget itself is
always immediately correct. If it ever matters: `CFNotificationCenter` Darwin
notification is the upgrade path.
- **Stale-until-next-open**, same as Android with a dead process, but hit more often
because iOS suspends the WebView aggressively. Day rollover shows yesterday's list
until next app open. Cross-client freshness while suspended stays phase 2
(BGAppRefreshTask + sync — same phase-2 slot as Android's WorkManager idea).
- **iOS 17+ only** (app itself stays iOS 16).
- Widget chrome strings English-only via the extension's strings file (parity with
Android v1 `strings.xml`).
- No task creation / undo / per-task deep link from the widget.
## Open decisions (settle before implementing)
1. App Group ID string — proposed `group.com.super-productivity.app`; hard to
change after ship (stale data stranded in the old container), pick once.
2. Whether the TS rename (`features/android/android-widget.*``features/widget/`)
lands as a preparatory refactor PR or inside the feature PR. Preparatory is
cleaner for review; the Android widget PR #8737 must merge first either way to
avoid rebasing it over the rename.
## Effort estimate
~24 focused days: ~0.51 on the Xcode target/App Group/portal/CI signing, ~11.5
on the extension + plugin, ~0.5 on the Angular generalization + specs, the rest
on-device testing (requires a Mac + real device; interactive widgets in the
simulator are flaky). App Store review is routine for widgets.
## Files
Native (all new unless noted): `ios/App/SupWidget/{TaskListWidget,WidgetData,DoneQueue,ToggleDoneIntent}.swift`,
extension `Info.plist` + entitlements, `App/App.entitlements` (App Group, edit),
`ios/App/App/WidgetBridgePlugin.swift` + `.m`, `project.pbxproj` (new target),
widget unit tests + shared golden JSON fixture.
Angular: `features/widget/widget-data.model.ts`, `features/widget/widget-data.service.ts`
(+spec), `features/widget/store/widget.selectors.ts` (+spec),
`features/widget/store/widget.effects.ts` (+spec), `features/widget/widget-bridge.ts`
(Capacitor `registerPlugin`), `root-store/feature-stores.module.ts`.
CI/release: `.github/workflows/build-ios.yml` (extension profile), new
`IOS_WIDGET_PROVISION_PROFILE` secret, export options.