mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
docs(plans): add iOS home screen widget port plan
This commit is contained in:
parent
7f953aae0f
commit
3e56836a23
1 changed files with 153 additions and 0 deletions
153
docs/plans/2026-07-07-ios-home-screen-widget-port.md
Normal file
153
docs/plans/2026-07-07-ios-home-screen-widget-port.md
Normal 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, ~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_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
|
||||
|
||||
~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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue