Back to Super Productivity

iOS home screen widget (port of the Android widget)

docs/plans/2026-07-07-ios-home-screen-widget-port.md

18.14.09.8 KB
Original Source

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) 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)

AndroidiOS
KeyValStore blob widget_data (SQLite)App Group UserDefaults(suiteName:), same key, same JSON
TaskListWidgetProvider + RemoteViewsService + XML layoutsWidgetKit 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 LocalBroadcastCapacitor resume only (see limitations)
Header/row tap → launch activitywidgetURL 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, ~250–400 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 (~100–150 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_VIEWandroidInterface, 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

~2–4 focused days: ~0.5–1 on the Xcode target/App Group/portal/CI signing, ~1–1.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.