Status: Living — setup runbook + payload schema (the payload is contract #5 in
compatibility.md). Background-refresh decision: ADR-0011. The Android counterpart shipped in #223 (CrosscueWidgetProvider.kt+res/layout/crosscue_widget.xml), reading the same payload.
The WidgetKit widget for streak + today’s puzzle (#114).
Like the iCloud and Google Drive setups, the code ships first and stays
inert until the one-time Xcode + Apple-portal wiring below is done:
HomeWidgetService.refresh() swallows every failure, so the app behaves
identically whether or not the widget extension exists.
lib/features/home/data/services/home_widget_service.dart
composes a versioned JSON payload and writes it to the App Group container via
the home_widget plugin. Triggered on launch, on resume, and after every
solve (app.dart, solve_screen.dart).app.dart listens for widget taps (crosscue://widget?route=…)
and forwards the route to go_router. The crosscue URL scheme is registered
in ios/Runner/Info.plist.ios/CrosscueWidget/ (CrosscueWidget.swift,
Info.plist, CrosscueWidget.entitlements). Stacked rows (streak / today /
leaderboard) where any row may be absent; families systemSmall +
accessoryRectangular.Key crosscue_widget_v1, JSON:
{
"version": 1,
"streak": { "current": 42, "best": 90 },
"today": { "puzzleId": "local:…", "title": "…", "route": "/solve/…" },
"leaderboard": null
}
leaderboard is null today. When the leaderboard work lands it becomes
{ "rank": 142, "total": 5102, "percentile": 97 } — same key, new shape.
The widget reads it as an optional row, so no migration and no widget rebuild
are needed. Keep this additive: don’t repurpose version for additive fields.
group.dev.tomhess.crosscue (must match
HomeWidgetService.appGroupId and the two .entitlements files).dev.tomhess.crosscue (Runner)dev.tomhess.crosscue.CrosscueWidget (widget — create this App ID too).APPLE_PROVISIONING_PROFILE_BASE64 (and add the widget profile to the
release signing setup — see DEPLOYMENT.md).home_widget requires a real Xcode-created target — it can’t be added by
hand-editing project.pbxproj safely.
ios/Runner.xcworkspace.CrosscueWidget,
uncheck “Include Live Activity” and “Include Configuration App Intent”.ZS9BL7472D (same as Runner) and minimum
deployment to iOS 16.0.
(Xcode names the target CrosscueWidgetExtension, bundle id
dev.tomhess.crosscue.CrosscueWidget.)CrosscueWidget.swift), a CrosscueWidgetBundle.swift
(the @main entry), and a sample Control widget (CrosscueWidgetControl.swift).
Keep the bundle’s @main; put the streak/today widget code in
CrosscueWidget.swift (as a plain struct CrosscueWidget: Widget, no
@main); trim the bundle to list only CrosscueWidget(); and empty out /
delete CrosscueWidgetControl.swift (we don’t ship a Control widget). The
final sources are committed under ios/CrosscueWidget/.Runner and
CrosscueWidgetExtension targets; check group.dev.tomhess.crosscue. Xcode
writes Runner.entitlements and CrosscueWidgetExtension.entitlements.flutter run fails with
Cycle inside Runner; building could produce unreliable results.)flutter run -d <ios-device-or-sim>, finish a puzzle (or just launch).route from the
payload).leaderboard).Status: ✅ verified on the iPhone 17 simulator (2026-06). Target builds + embeds, the app writes the versioned payload to the App Group, and the
systemSmallwidget renders the streak + today’s puzzle. Remaining: re-issue the App Store provisioning profile with the App Group capability (+ a widget profile) before a device/TestFlight build — simulator needs no provisioning.
The widget snapshot is normally refreshed only when the app runs (launch /
resume / post-solve). To keep the “today” tile current for users who don’t open
the app, a best-effort background task also refreshes it: iOS
BGAppRefreshTask and Android WorkManager, driven by one Dart callback
(lib/core/background/widget_refresh_scheduler.dart) via the workmanager
plugin. On wake it runs the Crosshare auto-download (attemptIfNeeded()) then
pushes a fresh snapshot.
What’s already wired in the repo (no action needed):
WidgetRefreshScheduler is registered post-first-frame in
app.dart; the headless callback stands up its own ProviderContainer.UIBackgroundModes = fetch and
BGTaskSchedulerPermittedIdentifiers = dev.tomhess.crosscue.refresh.WorkmanagerPlugin.registerPeriodicTask(withIdentifier:
"dev.tomhess.crosscue.refresh", …) + the plugin-registrant callback, both
before launch finishes (a BGTaskScheduler requirement).workmanager auto-registers; no manifest/Application changes.One-time Xcode step (parallel to the App Group above): on the Runner target
→ Signing & Capabilities → + Capability → Background Modes, check
Background fetch. (We’ve already written the equivalent UIBackgroundModes
key into Info.plist, so the build behaves correctly; enabling the capability
in Xcode just keeps the project UI in sync and is validated by App Store
Connect.) No new entitlement or provisioning-profile change is required.
The identifier is reverse-DNS and must stay identical across
widget_refresh_scheduler.dart, Info.plist, and AppDelegate.swift — if they
drift, iOS silently never runs the task. Cadence is best-effort: iOS decides
when (or whether) it fires from the user’s usage pattern; see the caveat in
docs/qa/ios-release-checklist.md.
home_widget
calls throw and are swallowed — the app is unaffected.CrosscueWidgetProvider.kt, reading
the same crosscue_widget_v1 payload via home_widget shared prefs).
Live Activities and the accessoryCircular/accessoryInline families
remain out of scope.leaderboard slot, not by changing the
widget contract.