glance_widget 2.0.0 copy "glance_widget: ^2.0.0" to clipboard
glance_widget: ^2.0.0 copied to clipboard

Create instant-updating home screen widgets for Android (Jetpack Glance) and iOS (WidgetKit). Supports Simple, Progress, List, Image, Chart, Calendar, and Gauge widget templates.

glance_widget #

pub package License: MIT

Create instant-updating home screen widgets for Android and iOS. Built with Jetpack Glance (Android) and WidgetKit (iOS).

List, Image and Chart widgets Gauge, Image and Progress widgets Progress and Bitcoin widgets

Why glance_widget? #

Unlike other packages (e.g., home_widget) that only provide a data bridge and leave you to write every widget's UI in native Swift/Kotlin, glance_widget ships the widgets themselves.

On Android there is genuinely nothing native to write: declare the receivers in your manifest and you are done. On iOS, WidgetKit requires every app to own its widget extension target, so you create that target in Xcode once and copy in the ready-made Swift views this package provides — you write the glue, not the widgets.

glance_widget home_widget
Widget UI 7 ready-to-use templates Write native code yourself
Type Safety sealed class + generic controllers — compile-time errors String keys — runtime errors
Real-time Updates DebouncedWidgetController — 100ms coalescing, auto-flush on app background Not available — build it yourself
Background Updates Built-in GlanceBackground (WorkManager + Timeline) Requires external flutter_workmanager
iOS Push Built-in iOS 26+ APNs support Not available
Error Reporting Every call applies or throws with the reason Silent Future<bool> you can forget to check
Platform Safety Silent no-op on Web/desktop, queryable via GlanceWidget.isSupported Crashes on unsupported platforms
Native Code None on Android; copy-in extension on iOS Required for every widget

Features #

  • Instant Updates - Widgets update in < 1 second on both platforms
  • Cross-Platform - Same API for Android and iOS
  • 7 Widget Templates - Simple, Progress, List, Image, Chart, Calendar, and Gauge
  • Theme Support - Light/Dark themes with full customization, plus opt-in Material You on Android 12+
  • Read a widget back - getWidgetData answers what a widget is currently showing, typed, and it survives the app being killed
  • Deep Links - All widgets support custom deep link URIs
  • Interactive Actions - Tap, checkbox toggle, item tap handling. Ticking a checkbox never launches the app on either platform, and taps survive being delivered to a process with no Flutter engine in it
  • Background Updates - Android widgets update even when app is closed (WorkManager)
  • Timeline Refresh - iOS widgets refresh periodically via WidgetKit timeline policy
  • iOS 26+ Push Updates - Server-triggered widget updates via APNs
  • Lock Screen Widgets - Android keyguard widgets, and iOS accessory families (circular, rectangular, inline) on the lock screen and in the Smart Stack
  • Real-time Data - Debounced controller for high-frequency updates (crypto, stocks)
  • Widget Configuration - Handle widget setup flow when users add widgets
  • In-App Preview - GlancePreview draws the widget in the app, per platform, so you can iterate on hot reload

Platform Comparison #

Feature Android (Jetpack Glance) iOS (WidgetKit)
Update Speed < 1 second < 1 second (app foreground)
Background Updates WorkManager (15 min+) Timeline-based (.after policy)
Server Push N/A iOS 26+ (APNs)
Lock Screen Supported (keyguard) Accessory families (all templates except Image)
Interactive Actions ActionCallback (checkbox + taps) App Intents (checkbox), URL (taps)
Min Version Android 8.0 (API 26) iOS 17.0
Rounded corners GlanceTheme.borderRadius, Android 12+ only GlanceTheme.borderRadius
Wallpaper colours useDynamicColor, Android 12+ only Not available
Adapts to slot size All templates All templates
Live Activities Not available (throws UnsupportedError) iOS 16.2+, Lock Screen + Dynamic Island
Reading a widget back getWidgetData, dropped with the widget getWidgetData, dropped by forgetWidget

Material You (Android 12+) #

Set useDynamicColor: true and the widget takes its colours from the user's wallpaper instead of the ones on the theme:

await GlanceWidget.updateSimpleWidget(
  widgetId: 'price',
  data: const SimpleWidgetData(title: 'BTC', value: '\$64,120'),
  theme: GlanceTheme.dark().copyWith(useDynamicColor: true),
);

It is off by default, and it is worth leaving off unless you mean it -- you are handing your widget's appearance to whatever the user has set as their background.

The theme's own colours are still required, and still used. They are the fallback for every device that cannot supply a palette:

Result
Android 12+ Wallpaper colours (surface, onSurface, onSurfaceVariant, primary)
Android 8-11 The colours on your GlanceTheme
iOS The colours on your GlanceTheme

That second row is the reason this is gated rather than passed straight through. Glance's dynamic colour providers resolve through resources whose values-v31 variant points at the system palette -- and whose plain values variant is the static Material baseline, #ff6750a4. Handing them to an Android 11 device does not fall back to your theme; it silently repaints the widget purple. So below Android 12 the request is ignored and your colours are used.

Light and dark follow the system automatically when dynamic colour is on, so isDark stops mattering.

Interactive checkboxes #

Ticking a checkbox in ListWidget used to open a URL, which launched the app to change one boolean -- on the lock screen, a full unlock and a cold start. It now runs an App Intent inside the widget extension instead: the box changes immediately, the app stays closed, and the widget reloads in place. Android does the same thing with an ActionCallback.

Your action handler does not change. The interaction is queued -- in the App Group on iOS, in SharedPreferences on Android -- and replayed into the same onAction stream the next time Dart is listening, carrying the time it actually happened rather than the time the app opened:

GlanceWidget.onAction.listen((action) {
  if (action.type == 'checkboxToggle') {
    final index = action.payload!['itemIndex'] as int;
    final checked = action.payload!['value'] as bool;
    // ... your model
  }
});

Two consequences worth knowing:

  • The widget's stored state is already flipped when your handler runs. The box would otherwise spring back the moment the timeline reloaded, which reads as the tap having failed. Your next updateListWidget is still the source of truth and overwrites it.
  • The backlog is capped at 100 actions. An app that is never reopened would otherwise grow the queue without limit in storage the user cannot see. Past 100, the oldest are dropped.

Item taps -- as opposed to checkbox taps -- still open the app when the widget has a deepLinkUri, because opening the app is what a deep link is for. Without one they are reported through the same queue.

The Android half of this matters more than it looks. A widget tap is delivered to your app's process, and the system is entitled to start that process from cold purely to deliver it -- with no Flutter engine in it, and therefore no onAction stream to receive anything. The previous lambda-based actions ran in exactly that process and wrote to a null listener, so an unlucky tap was simply lost, with nothing in the logs to say so. Queueing to disk first removes the race: the handler no longer has to be alive at the moment of the tap.

Lock Screen and Smart Stack (iOS) #

Six of the seven templates render in the iOS accessory families as well as on the home screen. Adding one is the same call you already make -- the template picks its own layout from the family the system asks for:

Family Where it appears What the templates draw
.accessoryCircular Lock screen, Smart Stack One value, or a progress ring
.accessoryRectangular Lock screen, Smart Stack Title plus two lines, a bar, or a sparkline
.accessoryInline Beside the lock screen clock One line of text

ImageWidget has no accessory layouts. The system draws these families in a single tint at roughly 58pt across; a photo reduced to that is a smear, and offering the family would put an unreadable widget in the picker. It stays a home screen template.

Two more things the lock screen changes, both deliberate:

  • GlanceTheme colours are ignored. The system tints an accessory widget itself. The templates do not pass accentColor through, because doing so would read like it worked while changing nothing on screen.
  • The background is transparent. An accessory widget sits on the user's wallpaper. The parts that need a backdrop ask for the system's own dimmed one.

GlancePreview renders the home screen layouts. There is no preview for the accessory families yet -- the tint and vibrancy come from the lock screen compositor, so a faithful in-app copy is not currently possible.

Live Activities and the Dynamic Island (iOS) #

A Live Activity is not a home screen widget, so it is not another template. It is a card that shows something in progress -- a delivery, a match, a download -- on the Lock Screen and in the Dynamic Island, and it ends when the thing does.

if (await GlanceWidget.areLiveActivitiesEnabled()) {
  await GlanceWidget.startLiveActivity(
    activityId: 'delivery-42',
    content: const LiveActivityContent(
      title: '🛵 Order on its way',
      status: '12 min away',
      progress: 0.4,
      stats: {'Driver': 'Sam', 'Items': '3'},
    ),
  );
}

// ... later
await GlanceWidget.updateLiveActivity(
  activityId: 'delivery-42',
  content: const LiveActivityContent(title: '🛵 Order on its way', status: '3 min away', progress: 0.8),
);

await GlanceWidget.endLiveActivity(
  activityId: 'delivery-42',
  content: const LiveActivityContent(title: '🛵 Order delivered', status: 'Left at your door', progress: 1),
);

activityId is your own name for the activity. ActivityKit assigns an id you never see, so this is what the update and the end find it by.

The first character of title is what the collapsed Dynamic Island shows, which is why the example leads with an emoji. status is the other half of that collapsed view. stats appear only when there is room -- the Lock Screen card and the expanded island -- and are drawn in the order you wrote them.

An activity outlives your app #

It stays on the Lock Screen across a relaunch, and the user can dismiss it without your app hearing about it. So an app resuming with work still in flight asks rather than remembers:

if (await GlanceWidget.isLiveActivityRunning('delivery-42')) {
  await GlanceWidget.updateLiveActivity(activityId: 'delivery-42', content: ...);
} else {
  await GlanceWidget.startLiveActivity(activityId: 'delivery-42', content: ...);
}

startLiveActivity refuses an id that is already running rather than quietly placing a second card the first id no longer names.

One fixed shape #

A widget extension has to name a concrete ActivityAttributes type at compile time, and ActivityKit matches a running activity to the presentation that draws it by that type. So the plugin can offer exactly one shape -- LiveActivityContent -- and a different one means editing your own copy of GlanceLiveActivityWidget.swift, which also means LiveActivityContent stops describing it. That is the cost, stated rather than hidden behind a Map.

The match is by type name and shape, not by module, which is what lets the plugin own the type while your extension declares its own copy. That was measured, not assumed: findings-live-activity-module-boundary.md.

What Android does #

startLiveActivity, updateLiveActivity and endLiveActivity throw UnsupportedError there. Android 16's Live Updates are the nearest thing and are a notification rather than a widget; a plugin that quietly did nothing would leave you believing something was on screen.

areLiveActivitiesEnabled() and isLiveActivityRunning() answer false instead of throwing, so they are safe to branch on anywhere.

Setup: NSSupportsLiveActivities in your app's Info.plist, and GlanceLiveActivityWidget.swift copied into your widget extension. See WIDGET_SETUP.md.

Widget Templates #

Template Description Use Cases
SimpleWidget Title + Value + Subtitle Crypto prices, weather, stats
ProgressWidget Circular/Linear progress Downloads, goals, battery
ListWidget Scrollable item list with checkboxes To-do, shopping, activities
ImageWidget Photo with title and subtitle Photo of the day, album art
ChartWidget Line, bar, or sparkline chart Revenue trends, analytics
CalendarWidget Date header with event list Daily schedule, meetings
GaugeWidget Radial or dashboard metrics CPU usage, performance scores

What a small widget shows #

A launcher can make a widget as small as the minResizeWidth/minResizeHeight in your widget info XML, and it does not scroll: whatever does not fit is cut off the bottom, with nothing on screen to say it was there. Every template measures its own slot and picks one of three layouts.

Band Slot height Idea
Compact under 80dp Only the thing the widget exists to show
Medium 80-180dp Adds the title, and detail that earns its line
Expanded 180dp and up Everything

What each template gives up first, in order:

Template Compact Medium
Simple value only + title
Progress percentage only, no dial + title, small dial
Gauge one metric row two rows, no title
Chart the plot + title
Image the picture + title and caption
Calendar the events + date block
List the items + a second line per item

Labels beside a number are never what gets dropped -- an unlabelled reading does not say which reading it is. Titles are, because they restate what is already under them.

Nothing here needs configuring. If you would rather a template kept its full layout at every size, give it a minResizeHeight of 180dp in its widget info XML and the compact and medium branches become unreachable.

Installation #

Add to your pubspec.yaml:

dependencies:
  glance_widget: ^2.0.0

The Android and iOS implementations are endorsed, so they come along automatically — you do not list them yourself.

Requirements #

Platform Minimum Version
Flutter 3.32+
Dart 3.8+
Android API 26 (Android 8.0)
iOS 17.0

Both CocoaPods and Swift Package Manager are supported on iOS.


Android Setup #

1. Configure Manifest #

Add widget receivers to android/app/src/main/AndroidManifest.xml:

<application>
    <!-- Simple Widget -->
    <receiver
        android:name="dev.glance.widget.android.templates.SimpleWidgetReceiver"
        android:exported="true">
        <intent-filter>
            <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
        </intent-filter>
        <meta-data
            android:name="android.appwidget.provider"
            android:resource="@xml/simple_widget_info" />
    </receiver>

    <!-- Progress Widget -->
    <receiver
        android:name="dev.glance.widget.android.templates.ProgressWidgetReceiver"
        android:exported="true">
        <intent-filter>
            <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
        </intent-filter>
        <meta-data
            android:name="android.appwidget.provider"
            android:resource="@xml/progress_widget_info" />
    </receiver>

    <!-- List Widget -->
    <receiver
        android:name="dev.glance.widget.android.templates.ListWidgetReceiver"
        android:exported="true">
        <intent-filter>
            <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
        </intent-filter>
        <meta-data
            android:name="android.appwidget.provider"
            android:resource="@xml/list_widget_info" />
    </receiver>

    <!-- Image Widget -->
    <receiver
        android:name="dev.glance.widget.android.templates.ImageWidgetReceiver"
        android:exported="true">
        <intent-filter>
            <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
        </intent-filter>
        <meta-data
            android:name="android.appwidget.provider"
            android:resource="@xml/image_widget_info" />
    </receiver>

    <!-- Chart Widget -->
    <receiver
        android:name="dev.glance.widget.android.templates.ChartWidgetReceiver"
        android:exported="true">
        <intent-filter>
            <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
        </intent-filter>
        <meta-data
            android:name="android.appwidget.provider"
            android:resource="@xml/chart_widget_info" />
    </receiver>

    <!-- Calendar Widget -->
    <receiver
        android:name="dev.glance.widget.android.templates.CalendarWidgetReceiver"
        android:exported="true">
        <intent-filter>
            <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
        </intent-filter>
        <meta-data
            android:name="android.appwidget.provider"
            android:resource="@xml/calendar_widget_info" />
    </receiver>

    <!-- Gauge Widget -->
    <receiver
        android:name="dev.glance.widget.android.templates.GaugeWidgetReceiver"
        android:exported="true">
        <intent-filter>
            <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
        </intent-filter>
        <meta-data
            android:name="android.appwidget.provider"
            android:resource="@xml/gauge_widget_info" />
    </receiver>
</application>

2. Create Widget Info XML #

Create android/app/src/main/res/xml/simple_widget_info.xml (repeat for each template):

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:minWidth="180dp"
    android:minHeight="110dp"
    android:targetCellWidth="3"
    android:targetCellHeight="2"
    android:resizeMode="horizontal|vertical"
    android:widgetCategory="home_screen|keyguard"
    android:updatePeriodMillis="0" />

3. Set SDK Versions #

In android/app/build.gradle.kts:

android {
    compileSdk = flutter.compileSdkVersion
    defaultConfig {
        // Jetpack Glance app widgets require API 26.
        minSdk = 26
    }
}

That is the whole Android setup. You do not need to enable Jetpack Compose in your app: the plugin renders with Compose, so it brings its own Compose compiler and applies it only to itself, tracking whatever Kotlin version your app resolved. Apps that need to pin a different one can set glance.kotlinVersion in android/gradle.properties.


iOS Setup #

1. Create Widget Extension #

In Xcode:

  1. Open ios/Runner.xcworkspace
  2. File → New → Target → Widget Extension
  3. Name: GlanceWidgets
  4. Click Finish

2. Configure App Groups #

Both targets need the same App Group, and both need to be told its name.

  1. Select Runner target → Signing & Capabilities → + App Groups
  2. Add: group.com.yourcompany.yourapp
  3. Select GlanceWidgets target → repeat with the same App Group ID
  4. Add this to both targets' Info.plist:
<key>GlanceWidgetAppGroup</key>
<string>group.com.yourcompany.yourapp</string>

Step 4 is not optional and not cosmetic. The entitlement grants access to the group; it does not tell the plugin which group to use. Without the key the plugin falls back to the example app's group, which your app has no entitlement for -- UserDefaults(suiteName:) returns nil, every update fails, and the widget sits on its placeholder data with nothing in the console to explain it.

If the key is missing you will now see this in Console.app, which is the one place the old version said nothing at all:

No App Group configured. Add a GlanceWidgetAppGroup key to the app's
Info.plist ... every widget update will do nothing.

3. Add Widget Files #

Copy the ready-made views from glance_widget_ios/example/ios/GlanceWidgets/ into your extension target:

  • GlanceWidgets.swift
  • SharedModels.swift (reads GlanceWidgetAppGroup from the extension's Info.plist; nothing to edit)
  • SimpleWidget.swift
  • ProgressWidget.swift
  • ListWidget.swift
  • ImageWidget.swift
  • ChartWidget.swift
  • CalendarWidget.swift
  • GaugeWidget.swift

4. Set the Deployment Target #

This plugin requires iOS 17. Set it in Xcode under Runner → General → Minimum Deployments, and in ios/Podfile as well if your project still uses CocoaPods:

platform :ios, '17.0'

Under Swift Package Manager the Xcode value (IPHONEOS_DEPLOYMENT_TARGET) is the one that counts, and it reaches the build only through flutter build ios--config-only is enough. flutter pub get rewrites FlutterGeneratedPluginSwiftPackage at Flutter's own 15.0 default every time it runs, so going straight from pub get to xcodebuild stops with requires minimum platform version 17.0 for the iOS platform however the project is configured. Run a build first.

5. Configure URL Scheme #

Add to ios/Runner/Info.plist:

<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>glancewidget</string>
        </array>
    </dict>
</array>

See the iOS Widget Setup Guide for detailed instructions.

6. Placing More Than One Widget of the Same Template #

widgetId identifies a widget instance, so two SimpleWidgets can show different things — one 'btc', one 'eth'. On Android that routing is automatic. On iOS the person placing the widget chooses which id it shows, because only they know which of the two home-screen widgets is meant to be which:

Long-press the widget → Edit Widget → pick an id from Widget.

The list offers the ids your app has actually sent data for. Until an instance is configured it shows whichever id was updated most recently, so a freshly placed widget is never blank.

This is why the templates use AppIntentConfiguration rather than StaticConfiguration — the latter carries no per-instance parameter, so every placed widget would read the same payload. If you write your own template, keep the intent, and pass configuration.widgetId into load...Widget(widgetId:).


Checking your setup #

Run this from your project root once the setup above is done:

dart run glance_widget:doctor

It reads your manifest, Gradle config, Info.plist files and entitlements, and reports what will stop a widget working. It needs neither Xcode nor the Android SDK and finishes in well under a second.

It exists because almost every way to get this wrong fails quietly:

Mistake What you see without the doctor
android:exported="false" on a receiver App installs, widget never appears in the picker
No APPWIDGET_UPDATE intent filter Widget can be placed, then never updates
minSdk below 26 Build fails with a uses-sdk merger error that never says "widget"
GlanceWidgetAppGroup missing Every update silently does nothing
The two Info.plist files naming different groups Both stores work; they are just not the same store
A group the target has no entitlement for UserDefaults(suiteName:) returns nil, no error raised

The doctor exits 1 when it finds something that will actually break, and 0 for warnings alone, so it works as a CI step:

- run: dart run glance_widget:doctor

It stays silent when it cannot tell — an unreadable minSdk expression, a platform directory that is not there — and says so separately rather than counting it as a pass. A platform it could not inspect is never reported as healthy.


Usage #

Simple Widget #

import 'package:glance_widget/glance_widget.dart';

await GlanceWidget.simple(
  id: 'crypto_btc',
  title: 'Bitcoin',
  value: '\$94,532.00',
  subtitle: '+2.34%',
  subtitleColor: Colors.green,
  deepLinkUri: 'myapp://crypto/btc',
);

Type-Safe Controllers #

For advanced use cases, use generic type-safe controllers:

import 'package:glance_widget/glance_widget.dart';

// Convenience controller — compile-time type safety
final controller = SimpleWidgetController(widgetId: 'crypto_btc');

await controller.update(SimpleWidgetData(
  title: 'Bitcoin',
  value: '\$94,532.00',
  subtitle: '+2.34%',
  subtitleColor: Colors.green,
));

// Listen for widget interactions
controller.onAction.listen((action) {
  print('Widget tapped: ${action.type}');
});

// Don't forget to dispose
controller.dispose();

Available controllers:

  • SimpleWidgetController
  • ProgressWidgetController
  • ListWidgetController
  • ImageWidgetController
  • ChartWidgetController
  • CalendarWidgetController
  • GaugeWidgetController

Or use the generic form directly:

final ctrl = GlanceWidgetController<ChartWidgetData>(widgetId: 'chart1');
await ctrl.update(ChartWidgetData(
  title: 'Revenue',
  dataPoints: [12, 19, 15, 25, 22, 30, 28],
));
ctrl.dispose();

Progress Widget #

await GlanceWidget.progress(
  id: 'daily_goal',
  title: 'Steps Today',
  progress: 0.75,
  subtitle: '7,500 / 10,000',
  progressType: ProgressType.circular,
  progressColor: Colors.green,
);

List Widget #

await GlanceWidget.list(
  id: 'todo_list',
  title: 'Today\'s Tasks',
  items: [
    GlanceListItem(text: 'Buy groceries', checked: true),
    GlanceListItem(text: 'Call mom', checked: false),
  ],
  showCheckboxes: true,
);

Image Widget #

await GlanceWidget.image(
  id: 'photo',
  title: 'Photo of the Day',
  imageBase64: base64EncodedImage,
  subtitle: 'Beautiful sunset',
  fit: ImageFit.cover,
);

Chart Widget #

await GlanceWidget.chart(
  id: 'revenue',
  title: 'Revenue',
  dataPoints: [12, 19, 15, 25, 22, 30, 28],
  chartType: ChartType.line,
  color: Colors.blue,
  subtitle: 'Last 7 days',
);

Calendar Widget #

await GlanceWidget.calendar(
  id: 'events',
  title: 'Today\'s Events',
  date: DateTime.now(),
  events: [
    CalendarEvent(time: '09:00', title: 'Standup', color: Colors.green),
    CalendarEvent(time: '14:00', title: 'Review', color: Colors.blue),
  ],
);

Gauge Widget #

await GlanceWidget.gauge(
  id: 'monitor',
  title: 'System Monitor',
  metrics: [
    GaugeMetric(label: 'CPU', value: 45, maxValue: 100, color: Colors.green, unit: '%'),
    GaugeMetric(label: 'Memory', value: 72, maxValue: 100, color: Colors.orange, unit: '%'),
  ],
  gaugeType: GaugeType.radial,
);

Updating Many Widgets At Once #

Each per-template call crosses the platform channel once. A dashboard that refreshes twenty widgets that way pays twenty round trips and re-serialises the shared theme twenty times. GlanceWidget.batch sends them together:

await GlanceWidget.batch(
  [
    GlanceWidgetUpdate(
      widgetId: 'btc',
      data: SimpleWidgetData(title: 'Bitcoin', value: r'$94,532'),
    ),
    GlanceWidgetUpdate(
      widgetId: 'steps',
      data: ProgressWidgetData(title: 'Steps', progress: 0.72),
    ),
    GlanceWidgetUpdate(
      widgetId: 'week',
      data: ChartWidgetData(title: 'This week', dataPoints: [3, 5, 4, 8]),
    ),
  ],
  theme: GlanceTheme.dark(),
);

Measured on the same twenty updates:

round trips payload bytes
twenty GlanceWidget.simple calls 20 5700
one GlanceWidget.batch call 1 2974

Templates can be mixed freely, and theme applies to every update that does not carry one of its own.

Partial failure. A batch does not stop at the first failure: one widget missing from the home screen is no reason to leave the rest showing stale data. Every update is attempted, the ones that succeeded stay applied, and GlanceWidgetBatchException names the rest.

try {
  await GlanceWidget.batch(updates);
} on GlanceWidgetBatchException catch (e) {
  for (final failure in e.failures) {
    debugPrint('${failure.widgetId} was not updated: ${failure.message}');
  }
}

A malformed update is a different matter: it is refused before anything is sent, with a GlanceWidgetValidationException, so a mistake in your code cannot half-apply to the home screen. The same goes for sending the same widgetId twice in one batch, which would otherwise race.

Theme Configuration #

await GlanceWidget.setTheme(GlanceTheme.dark());

// Or custom theme
await GlanceWidget.setTheme(GlanceTheme(
  backgroundColor: Color(0xFF1A1A2E),
  textColor: Colors.white,
  secondaryTextColor: Color(0xFFB0B0B0),
  accentColor: Colors.orange,
  borderRadius: 16.0,
  isDark: true,
));

Handle Widget Actions #

GlanceWidget.onAction.listen((action) {
  switch (action.type) {
    case GlanceActionType.tap:
      print('Widget ${action.widgetId} tapped');
      break;
    case GlanceActionType.checkboxToggle:
      print('Item ${action.itemIndex} toggled to ${action.value}');
      break;
    case GlanceActionType.itemTap:
      print('Item ${action.itemIndex} tapped');
      break;
    case GlanceActionType.configure:
      // Show configuration UI, then:
      GlanceWidget.completeWidgetConfiguration(action.widgetId);
      break;
    default:
      break;
  }
});

All widget types support deepLinkUri parameter:

await GlanceWidget.simple(
  id: 'btc',
  title: 'Bitcoin',
  value: '\$94,532',
  deepLinkUri: 'myapp://crypto/btc',  // Opens when widget is tapped
);

Reading a widget back #

getWidgetData answers what a widget is currently showing. It reads the widget's own copy, not your app's memory of it, so it survives the app being killed and answers after a reinstall.

final snapshot = await GlanceWidget.getWidgetData('btc-price');

if (snapshot == null) {
  // Never written, or forgotten. Nothing is on that widget.
} else {
  print(snapshot.updatedAt);          // when the plugin last pushed
  switch (snapshot.data) {
    case SimpleWidgetData(:final value):
      if (value == latestPrice) return;   // already showing it; skip the write
    case _:
      break;
  }
}

snapshot.data is the sealed WidgetData that was sent, so a switch over it is checked for exhaustiveness by the compiler. updatedAt and theme sit on the snapshot rather than inside the data, because they were never part of it.

Three answers, not two. null means there is no record. A record this version of the plugin cannot read -- written by a newer one -- throws GlanceWidgetFormatException instead. Only the first of those means the widget is blank and safe to overwrite unseen, so they cannot share a return value.

The record is dropped wherever the id is: by forgetWidget on both platforms, and on Android also when the last widget carrying the id leaves the home screen.

When the record is written differs by platform, the same way getActiveWidgetIds does. iOS writes on every update, whether or not a widget has ever been placed, because WidgetKit gives an extension no way to ask what is on the home screen. Android writes only when the update reaches a placed widget: with nothing carrying the id on the home screen the update fails with NO_WIDGET_INSTANCE, nothing is showing, and getWidgetData answers null.


Previewing a widget in the app #

Seeing a change to widget data normally means a build, an install, a long-press on the home screen and a trip through the widget picker. GlancePreview draws the widget inside your app instead, so the loop closes at hot reload.

GlancePreview(
  data: const SimpleWidgetData(title: 'Steps', value: '8,241'),
  theme: GlanceTheme.dark(),
  size: GlanceWidgetSize.medium,
)

It renders per platform, not an average of the two, and defaults to the host the app is running on. Pass platform: to see the other one, or put both side by side:

Row(
  spacing: 16,
  children: [
    GlancePreview(data: data, platform: GlancePlatform.android),
    GlancePreview(data: data, platform: GlancePlatform.ios),
  ],
)

The two hosts genuinely differ, and the preview shows the differences rather than hiding them:

Android (Jetpack Glance) iOS (WidgetKit)
SimpleWidgetData.iconName not drawn drawn as an SF Symbol
Radial gauge first metric only, from a rasterised bitmap one gauge per metric
Charts rasterised at 600x300 and stretched laid out as views at the real size
GlanceTheme.borderRadius applied from Android 12; square below applied
No theme set falls back to the dark palette follows the device colour scheme
Calendar header day and weekday inside an accent square weekday above the day number
Empty chart "No chart data" "No data"

What it cannot show:

  • Pictures. The plugin downloads and downsamples an image on the device, so an image widget draws its placeholder.
  • SF Symbols. iconName only resolves on an Apple platform; the preview holds the space with a plain shape.
  • Dates. Both hosts format with the device's locale; the preview writes English names rather than adding a localisation dependency.
  • Launcher decoration. Android tints and clips widgets in ways that change between launchers and OS versions. From Android 12 the launcher rounds every widget at the system radius on top of whatever the theme asks for, so a borderRadius under 16 looks squarer in the preview than on a device.

Previewing an older Android #

GlanceModifier.cornerRadius is refused below Android 12 -- Glance logs Cannot set the rounded corner of views before Api 31 and moves on -- and rounded widget corners are an Android 12 feature in the first place, so a widget is square there whatever borderRadius says. The plugin supports back to 8.0, so the preview takes the SDK level it should imitate:

GlancePreview(
  data: data,
  platform: GlancePlatform.android,
  androidApiLevel: 30, // Android 11: square corners
)

It defaults to 31.

Background Updates (Android) #

await GlanceBackground.configureUpdate(
  widgetId: 'crypto_btc',
  template: GlanceTemplate.simple,
  apiUrl: 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd',
  intervalMinutes: 15,
  title: 'Bitcoin',
  valuePath: r'$.bitcoin.usd',
  valuePrefix: r'$',
);

// Cancel
await GlanceBackground.cancelUpdate('crypto_btc');

// Check status
final status = await GlanceBackground.getUpdateStatus('crypto_btc');

Timeline Refresh (iOS) #

await GlanceBackground.configureTimelineRefresh(
  widgetId: 'weather',
  intervalMinutes: 30,
);

await GlanceBackground.cancelTimelineRefresh('weather');

DebouncedWidgetController (Real-time Data) #

For high-frequency updates like crypto prices or live scores:

final controller = DebouncedWidgetController<SimpleWidgetData>(
  widgetId: 'crypto_btc',
  theme: GlanceTheme.dark(),
  debounceInterval: Duration(milliseconds: 100),
  maxWaitTime: Duration(milliseconds: 500),
  stalenessThreshold: Duration(seconds: 15),
);

priceStream.listen((price) {
  controller.scheduleUpdate(SimpleWidgetData(
    title: 'Bitcoin',
    value: '\$${price.toStringAsFixed(2)}',
  ));
});

// Flushes pending updates automatically when app goes to background
controller.dispose();

iOS 26+ Server Push Updates #

if (await GlanceWidget.isWidgetPushSupported()) {
  final token = await GlanceWidget.getWidgetPushToken();
  if (token != null) {
    await api.registerWidgetPushToken(token);
  }
}

When a widget is removed #

A widget dragged off the home screen used to leave everything behind: its payload, its downsampled image on disk, and its id in getActiveWidgetIds() forever.

On Android this is now automatic. Removing the last widget carrying an id runs onDelete, which deletes the cached image and forgets the id. Placing the same widget twice and removing one copy changes nothing -- the other is still rendering it.

On iOS nothing observes it. WidgetKit does not tell an extension its widget was removed, and WidgetCenter.getCurrentConfigurations answers with configuration intents that only the app defining them can decode, so the plugin cannot reconcile on your behalf. Tell it when an id is finished:

await GlanceWidget.forgetWidget('parcel_4821');

That drops the payload for every template, the image file, and the id, on both platforms. It does not remove a widget from the home screen -- no API on either platform can. A widget still carrying the id renders its placeholder afterwards, because there is nothing left to read.

What getActiveWidgetIds() means #

The ids this app has data stored for, in ascending order. Not the ids on the home screen: neither platform will tell you that. An id appears as soon as you write to it, whether or not a widget has been placed, because the data is sitting there waiting for one.

An id leaves the list when
Android its last widget is removed, or you call forgetWidget
iOS you call forgetWidget

Before v2.0.0 it returned every id ever written, on both platforms, in an order that could differ between two calls.

Errors and platform support #

Every update either applies or throws GlanceWidgetException carrying the platform's reason. There is no success flag to forget to check:

try {
  await GlanceWidget.simple(id: 'btc', title: 'Bitcoin', value: r'$94,532');
} on GlanceWidgetException catch (e) {
  debugPrint('${e.code}: ${e.message}');
}

Platforms without a home screen widget system (Web, macOS, Windows, Linux) are a separate case: there every call is a silent no-op, so shared code needs no platform branches. Branch only where the user would notice:

if (GlanceWidget.isSupported) const AddWidgetButton(),

DebouncedWidgetController.scheduleUpdate returns before its dispatch happens, so it cannot throw to you. Its timer-driven failures arrive on a stream instead:

controller.errors.listen((e) => debugPrint('widget update failed: $e'));
await controller.flush(); // this one throws to you directly

Architecture #

Package Description
glance_widget Main package with cross-platform API
glance_widget_platform_interface Platform-independent interface
glance_widget_android Android implementation (Jetpack Glance)
glance_widget_ios iOS implementation (WidgetKit)

Example #

Check the example directory for a complete demo app showing all 7 widget types.

cd example
flutter run

Contributing #

Contributions are welcome! Please read our contributing guidelines before submitting PRs.

License #

MIT License - see LICENSE for details.

10
likes
160
points
47
downloads

Documentation

Documentation
API reference

Publisher

verified publisherabdullahtas.dev

Weekly Downloads

Create instant-updating home screen widgets for Android (Jetpack Glance) and iOS (WidgetKit). Supports Simple, Progress, List, Image, Chart, Calendar, and Gauge widget templates.

Homepage
Repository (GitHub)
View/report issues

Topics

#widget #android #ios #glance #widgetkit

License

MIT (license)

Dependencies

flutter, glance_widget_android, glance_widget_ios, glance_widget_platform_interface, logging

More

Packages that depend on glance_widget

Packages that implement glance_widget