Flutter owns everything inside your app window. Home screen widgets, lock screen widgets, Live Activities and Dynamic Island cards live outside that window — they are rendered by the OS, from native UI code, in a separate process that your Dart isolate never runs in. That single fact explains almost every problem teams hit when a client asks for "just a small widget showing the current order status".
This tutorial covers the parts that are genuinely hard: getting data across the process boundary, updating a widget from a push notification when the app is dead, driving a Live Activity through its full lifecycle, and keeping the whole thing buildable and testable by a team that writes Dart all day.
What you are actually building
You are building three things, not one:
- A Dart-side writer. Your Flutter app writes a small, serialisable snapshot of state to a shared container.
- A native widget. SwiftUI in a WidgetKit extension on iOS; Jetpack Glance (or an
AppWidgetProviderwithRemoteViews) on Android. This code reads the snapshot and renders it. It cannot call into your Dart code. - A refresh path. Something must tell the OS that the snapshot changed — from the app, from a push, or on a timeline schedule.
Everything below is one of those three pieces. If a design discussion stalls, it is usually because someone assumed the widget can "just ask the app" for data. It cannot.
The boundary rules, up front
Set expectations with the client before you estimate:
| Constraint | iOS (WidgetKit) | Android (Glance / RemoteViews) |
|---|---|---|
| Runs your Dart code | No | No |
| UI framework | SwiftUI, restricted widget subset | Glance composables / RemoteViews |
| Update trigger | Timeline entries, reloadTimelines, push (APNs for Live Activities) | updateAppWidget, WorkManager, FCM |
| Realistic refresh floor | ~15 minutes for timeline-driven updates | ~15–30 minutes for periodic work |
| Interactivity | Deep links, App Intents (buttons/toggles) | Deep links, actionRunCallback |
| Memory budget | Very small (tens of MB) — no heavy images | Small; RemoteViews bitmap limits |
"Realistic refresh floor" is the one that hurts. A widget showing a live countdown that needs second-level accuracy is a Live Activity or a Text(timerInterval:)-style self-ticking view, not a timeline refresh. Budget accordingly.
Step 1: share state from Dart
The home_widget package wraps the platform storage each side can see: an App Group UserDefaults container on iOS, SharedPreferences on Android.
dependencies:
home_widget: ^0.7.0
workmanager: ^0.5.2 # Android background refresh
live_activities: ^2.2.0 # optional, iOS Live Activities
Pick one shape for the payload and version it. Widgets are updated by the OS at unpredictable times, sometimes while an old build of the extension is still installed, so a payload the widget cannot parse must degrade rather than crash.
// lib/widgets/widget_bridge.dart
import 'dart:convert';
import 'package:home_widget/home_widget.dart';
class OrderSnapshot {
const OrderSnapshot({
required this.orderId,
required this.status,
required this.etaMinutes,
required this.updatedAt,
});
final String orderId;
final String status;
final int etaMinutes;
final DateTime updatedAt;
Map<String, dynamic> toJson() => {
'v': 1, // schema version: the native side checks this first
'orderId': orderId,
'status': status,
'etaMinutes': etaMinutes,
'updatedAt': updatedAt.toUtc().toIso8601String(),
};
}
class WidgetBridge {
static const _appGroupId = 'group.com.example.myapp';
static const _key = 'order_snapshot';
static Future<void> init() =>
HomeWidget.setAppGroupId(_appGroupId); // no-op on Android, required on iOS
static Future<void> publish(OrderSnapshot snapshot) async {
await HomeWidget.saveWidgetData<String>(_key, jsonEncode(snapshot.toJson()));
await HomeWidget.updateWidget(
iOSName: 'OrderStatusWidget', // WidgetKit kind identifier
androidName: 'OrderStatusReceiver', // AppWidgetProvider class name
);
}
static Future<void> clear() async {
await HomeWidget.saveWidgetData<String>(_key, null);
await HomeWidget.updateWidget(
iOSName: 'OrderStatusWidget',
androidName: 'OrderStatusReceiver',
);
}
}
Two habits worth enforcing in review:
- Write a snapshot, not a stream of fields. One JSON string keeps the native side from rendering a half-updated widget where the status is new and the ETA is stale.
- Always publish an empty state. When the user logs out, clear the key. A widget that keeps showing the previous user's order after logout is a privacy incident, not a bug.
Call publish from the same place your app already updates its own UI state — a repository or a notifier, never from inside a widget build.
Step 2: the iOS side
App Group and extension target
The extension is a separate target with its own bundle ID and entitlements. In Xcode: File ▸ New ▸ Target ▸ Widget Extension, then add the same App Group to both the Runner target and the extension. Mismatched App Groups is the number one cause of "the widget shows placeholder data forever", and it fails silently.
Set the extension's minimum deployment target to match or exceed the Runner's, and keep it out of your Dart build entirely — it does not link the Flutter engine.
Timeline provider
// OrderStatusWidget/OrderStatusWidget.swift
import WidgetKit
import SwiftUI
struct OrderEntry: TimelineEntry {
let date: Date
let orderId: String?
let status: String
let etaMinutes: Int
}
struct Provider: TimelineProvider {
private let defaults = UserDefaults(suiteName: "group.com.example.myapp")
func placeholder(in context: Context) -> OrderEntry {
OrderEntry(date: .now, orderId: nil, status: "—", etaMinutes: 0)
}
func getSnapshot(in context: Context, completion: @escaping (OrderEntry) -> Void) {
completion(readEntry())
}
func getTimeline(in context: Context, completion: @escaping (Timeline<OrderEntry>) -> Void) {
let entry = readEntry()
// Ask to be refreshed again soon; the OS may ignore or delay this.
let next = Calendar.current.date(byAdding: .minute, value: 15, to: .now)!
completion(Timeline(entries: [entry], policy: .after(next)))
}
private func readEntry() -> OrderEntry {
guard
let raw = defaults?.string(forKey: "order_snapshot"),
let data = raw.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(json["v"] as? Int) == 1 // version gate
else {
return placeholder(in: .init()) // degrade, never crash
}
return OrderEntry(
date: .now,
orderId: json["orderId"] as? String,
status: json["status"] as? String ?? "—",
etaMinutes: json["etaMinutes"] as? Int ?? 0
)
}
}
Note home_widget writes keys with its own prefix depending on version and API used — log defaults.dictionaryRepresentation().keys once on device and match the key exactly rather than guessing.
Rendering, and deep linking back into Flutter
struct OrderStatusView: View {
let entry: OrderEntry
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(entry.status).font(.headline)
if let id = entry.orderId {
Text("Order \(id)").font(.caption).foregroundStyle(.secondary)
Text("ETA \(entry.etaMinutes) min").font(.caption2)
} else {
Text("No active order").font(.caption)
}
}
.containerBackground(.fill.tertiary, for: .widget) // required on iOS 17+
.widgetURL(URL(string: "myapp://order/\(entry.orderId ?? "")"))
}
}
widgetURL arrives in your app as a normal deep link, so route it with the same go_router configuration you already use — do not build a second navigation path for widget taps. Test the cold-start case specifically: tapping a widget usually launches a terminated app, which is the code path most likely to drop the initial route.
For buttons inside the widget (iOS 17+), use App Intents with Button(intent:). The intent runs in the extension, so it can only mutate shared storage and call WidgetCenter.shared.reloadTimelines(ofKind:). Anything that needs your app's business logic must open the app instead.
Step 3: the Android side
Glance keeps this pleasantly close to Compose:
// android/app/src/main/kotlin/.../OrderStatusWidget.kt
class OrderStatusWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val prefs = HomeWidgetPlugin.getData(context)
val raw = prefs.getString("order_snapshot", null)
val snapshot = raw?.let { runCatching { JSONObject(it) }.getOrNull() }
?.takeIf { it.optInt("v") == 1 }
provideContent {
GlanceTheme {
Column(modifier = GlanceModifier.padding(12.dp)) {
Text(snapshot?.optString("status") ?: "No active order")
snapshot?.optString("orderId")?.let {
Text(
text = "ETA ${snapshot.optInt("etaMinutes")} min",
modifier = GlanceModifier.clickable(
actionStartActivity<MainActivity>(
actionParametersOf(
ActionParameters.Key<String>("route") to "/order/$it"
)
)
)
)
}
}
}
}
}
}
class OrderStatusReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = OrderStatusWidget()
}
Register the receiver in AndroidManifest.xml with an appwidget-provider meta-data XML that declares sizes and updatePeriodMillis. Set updatePeriodMillis to 0 and drive refreshes yourself; the OS minimum is 30 minutes anyway and a stale-but-cheap widget beats a battery complaint.
Since Android 12, widgets need rounded-corner-friendly layouts and a previewImage/description; since Android 15, the widget picker shows a generated preview, so ship a previewLayout that renders with plausible sample data rather than an empty state.
Step 4: updating when the app is not running
This is where most implementations quietly break. Three mechanisms, in order of preference:
1. Push-driven refresh. The server knows the order status changed, so let it say so. A silent push (content-available: 1 on iOS, a data-only FCM message on Android) wakes a background handler that writes the snapshot and reloads the widget.
@pragma('vm:entry-point')
Future<void> onBackgroundMessage(RemoteMessage message) async {
await WidgetBridge.init();
await WidgetBridge.publish(OrderSnapshot(
orderId: message.data['orderId']!,
status: message.data['status']!,
etaMinutes: int.parse(message.data['eta'] ?? '0'),
updatedAt: DateTime.now(),
));
}
Remember this handler runs in a background isolate: no access to your app's providers, no BuildContext, and any plugin it touches must be initialised inside the handler. Keep it to parse-and-write.
2. Scheduled background work (Android). workmanager with a 15-minute periodic task is fine for data that ages predictably. Expect the OS to skip runs on a dozing device, and never let the widget claim freshness it does not have — render updatedAt as "updated 2h ago" when the snapshot is old.
3. Timeline pre-computation (iOS). If you can predict the next few states — a countdown, a schedule, a shift roster — emit several timeline entries at once instead of hoping for a refresh. This is the only way to get minute-accurate widgets on iOS without a Live Activity.
var entries: [OrderEntry] = []
for minuteOffset in stride(from: 0, to: 60, by: 5) {
let date = Calendar.current.date(byAdding: .minute, value: minuteOffset, to: .now)!
entries.append(OrderEntry(date: date, orderId: id, status: status,
etaMinutes: max(0, eta - minuteOffset)))
}
completion(Timeline(entries: entries, policy: .atEnd))
Step 5: Live Activities for the genuinely live case
Live Activities (iOS 16.1+) are the right tool for a bounded, active event: a delivery in progress, a ride, a match, a workout. They render on the lock screen and in the Dynamic Island, and — critically — they can be updated by push directly to the activity, bypassing your app entirely.
final activities = LiveActivities();
await activities.init(appGroupId: 'group.com.example.myapp');
final activityId = await activities.createActivity({
'status': 'Preparing',
'etaMinutes': 22,
'orderId': orderId,
});
// Local update while the app is in the foreground.
await activities.updateActivity(activityId!, {'status': 'On the way', 'etaMinutes': 12});
// Hand the push token to your backend for remote updates.
activities.activityUpdateStream.listen((update) {
if (update is ActivityUpdatePushToken) {
api.registerLiveActivityToken(activityId, update.token);
}
});
// End it explicitly — a forgotten activity sits on the lock screen for hours.
await activities.endActivity(activityId);
The operational rules that matter:
- Always end the activity. On completion, on cancellation, and on logout. Set a
staleDatein the attributes so the OS dims it if your updates stop. - Frequent-update budget. iOS throttles high-frequency activity pushes; declare
NSSupportsLiveActivitiesFrequentUpdatesif you genuinely need them, and design the content so a missed update still reads correctly. - Widget extension shared with WidgetKit. The Live Activity UI lives in the same extension target as your home screen widget and shares the
ActivityAttributesstruct, so plan one extension, not two. - Android has no equivalent. The closest analogue is an ongoing notification with a progress indicator (plus Live Updates on Android 16 where available). Build the abstraction in Dart so the platform difference does not leak into your feature code.
Testing and CI
You cannot widget-test a WidgetKit view, but you can test almost everything that breaks:
test('snapshot survives a schema round-trip', () {
final json = snapshot.toJson();
expect(json['v'], 1);
expect(jsonDecode(jsonEncode(json))['status'], 'On the way');
});
test('logout clears shared widget data', () async {
await session.logout();
expect(await HomeWidget.getWidgetData<String>('order_snapshot'), isNull);
});
On the native side, keep the JSON parsing in a small, plain Swift/Kotlin type with its own unit tests, and give the SwiftUI view a preview per state (loading, empty, active, stale). For CI, the only extra cost is signing: the widget extension needs its own provisioning profile, so add it to your Fastlane match setup the day you create the target, not the week you try to ship.
Manual checks that repeatedly catch real bugs:
- Add the widget, then force-quit the app. Does it still render?
- Log out. Does the widget clear within one refresh?
- Reboot the device. Does the widget survive before the app has been launched?
- Change the system font size and enable dark mode. Does the layout still fit the small size class?
- Send the background push with the app terminated. Does the snapshot update?
Scoping this for a client
Home screen widgets are usually sold as small, and they are not. A realistic slice for a first release:
- One widget, one size class per platform, one data snapshot.
- Deep link on tap, no in-widget buttons.
- Refresh on app foreground plus push-driven refresh.
- Live Activity only if there is a genuine bounded event — it roughly doubles the iOS effort.
Everything beyond that (configurable widgets via App Intents, multiple size classes with distinct layouts, interactive toggles, watch complications) is a separate phase with its own native review cycle. The value of a Flutter team here is not that widgets become cross-platform — they do not — but that the shared state, the push path, the routing and the tests stay in one Dart codebase, with a thin, well-tested native layer on each side.
If you are planning a widget or Live Activity feature and want a second opinion on scope before you commit to a sprint, get in touch — our Flutter consultants have shipped this boundary on both platforms and can tell you quickly which parts are cheap and which are not.