iOS widgets
React Native Widgets Without Duplicating Business Logic in Swift
A versioned JSON snapshot lets WidgetKit reuse TypeScript calculations, advance deadlines and respect the app’s privacy settings.
Updated
On this page
"Next service" is a short label with a surprisingly long list of dependencies.
In OdoKeep, it can depend on the vehicle, its recorded maintenance, the distance unit, the user's snooze decisions, and the current service forecast. Monthly spend has another set of rules, including converting a record at its own date when it was paid in a different currency.
Those answers already existed in TypeScript. Adding iOS widgets would have been a good opportunity to copy them into Swift and spend the next year fixing disagreements between the two versions.
Instead, the app writes the answers down.
OdoKeep is built with React Native and Expo. It has four WidgetKit widgets: vehicle details, deadlines, monthly spend, and servicing. The widget extension is a separate process. It does not run React Native or open the app's MMKV store.
The boundary between the two processes is a JSON snapshot in an App Group container. The TypeScript app builds it from the same modules used by the dashboard. Swift decodes it and draws the result.
Share finished answers across the process boundary
The snapshot is a presentation contract, rather than an export of raw records. Amounts are formatted, units are applied, service labels are translated, and deep links are already built. Even the spend-chart buckets carry their finished labels and normalised bar ratios.
The following excerpt shows the kind of shape the contract uses:
interface WidgetSpendBucket {
id: string;
label: string;
ratio: number;
isCurrent: boolean;
}
interface WidgetDeadline {
id: string;
label: string;
vehicleId: string;
vehicleName: string;
dueDate: string;
dueDateLabel: string;
warningFrom: string;
url: string;
}
There is still useful structure in the file. Swift needs identities to support vehicle selection, dates to advance the display, and ratios to render bars. What it does not need is a second implementation of the garage's financial or maintenance rules.
The dashboard's attention list, service forecast, and spend calculations feed lib/widgets/snapshot.ts. plugins/widgets/WidgetsSnapshot.swift mirrors the decoder contract. When a field changes meaning, the shared snapshot version changes too. A decoder that does not understand that version falls back to asking the user to open the app.
That is a much easier failure to investigate than a widget confidently showing the wrong interpretation of a new field.
Let the calendar advance without the app
The calendar is the deliberate exception to precomputed answers. A widget can remain visible while the app is unopened for days. A deadline that says "in three days" has to change after midnight without asking React Native to rebuild it.
The snapshot therefore stores a deadline as a local calendar day and a warningFrom day. It also stores translated countdown strings keyed by day offset:
"0" -> Today
"1" -> Tomorrow
"2" -> In 2 days
"-2" -> 2 days overdue
The real table is produced through the app's translation function and extends sixty days into the past and 120 into the future, with an overdue fallback. Swift counts calendar days and looks up the corresponding finished string. It can advance the state at the precomputed warning date without learning how the app decided that date.
For services measured in distance, the snapshot carries a fixed label instead. Waiting overnight does not add kilometres to an odometer.
WidgetTimeline.swift builds an entry for now and one per local midnight for the next week, with an .atEnd refresh policy. The snapshot stays the same while the entry date changes. This supplies future display states to WidgetKit; it does not promise that an extension runs at an exact wall-clock instant. WidgetKit controls its refresh schedule, as Apple's documentation on keeping widgets up to date explains.
Replace the snapshot before requesting a reload
Choosing when to write the file is another part of the design. The main write happens as the app leaves the foreground, close to the moment the user returns to the home screen. A four-second timer after mount supplies an initial snapshot for a session that has not backgrounded yet. Observable preference and configuration changes re-arm that timer.
The record repository does not expose a subscription for every record write, so a record added after the timer fires reaches the snapshot on the next background transition. That is the freshness boundary. A widget also cannot learn about a shared vehicle edit that the app has not yet pulled.
The writer uses synchronous file operations on that lifecycle path. It writes the entire JSON document under a scratch name, then moves it over the real snapshot with overwrite enabled. This keeps the normal decoder path away from a half-written document. The scratch file is removed before reuse if an earlier attempt was interrupted.
Only after replacement does the small Expo native module request a WidgetKit timeline reload. Writing bytes alone would leave the existing timeline in place until the system requested another one. Reloading is a request to the scheduler, so freshness still depends on iOS and a successful snapshot write.
Privacy belongs in the snapshot builder
The data allowed across the boundary is narrower than the data available in the app. When the biometric lock is enabled, the widget snapshot contains no odometer or money values. Those fields are null; the sensitive values are not serialized and then hidden in the view. The licence plate is never included, regardless of lock state. Deadlines and service information remain available.
The Swift views also mark sensitive values for system privacy redaction. That supports iOS's display controls, while the builder controls which values reach the extension at all.
Sign-out and account deletion remove the widget snapshot and request a reload. Deleting the file is part of ending the session, not just a side effect of unmounting the hook that writes it. Otherwise, a saved snapshot could outlive the account that produced it.
Reuse the boundary for Siri and Shortcuts
The same architectural choice supports Siri and Shortcuts. Their read intents use a separate presentation snapshot stored in the app's Documents directory. Those intents compile into the app target; they are not the WidgetKit extension. The builders share inputs, but the files and consumers have distinct contracts.
A write shortcut opens a deep link into an existing app form. It does not update storage from Swift. The route still applies the share role, plan limits, odometer checks, and sync behaviour that apply when the user opens that form normally. Siri parameters prefill a request; they do not bypass the app's write path.
Keep the extension reproducible with Expo prebuild
The native build is reproducible too. This project regenerates its ignored iOS directory through Expo prebuild. A target added by hand in Xcode would not survive clean generation, so plugins/with-widgets.js creates and configures the extension, links the Swift files and resources, declares the App Group, and describes the second target to EAS for signing. This follows Expo's config plugin model for changes that must survive native regeneration.
One build setting in that plugin deserves a mention: ENABLE_DEBUG_DYLIB = NO for the extension. The implementation records a failure where App Intents metadata extraction inspected the debug stub's dependencies, did not find AppIntents, and skipped extraction without failing the build. Configurable widgets then had no usable intent and displayed an empty state despite the signed-in app. The setting makes the debug extension use the binary layout expected by that metadata path.
The tests check the pure snapshot builder, the Swift decoder's required fields, deep links, localisation tables, shared colours, fonts, and native configuration. These are targeted contract checks, not a generated cross-language schema or a replacement for running the extension on a device. They catch several ways two separately compiled consumers can drift before that drift reaches the home screen.
This is the boundary I would reuse for another React Native product: let the app own the meaning of its data, and send the extension a bounded, versioned description of what it can display. Leave enough date structure for the display to age, and make the age of every other answer an explicit consequence of the last write.
These four widgets are implemented in OdoKeep for iOS. If you track a vehicle, they are a compact way to see its deadlines and next service without opening the full logbook.