Building a Cross-Platform QR Code Home Widget with Flutter
Introduction
Search “Flutter home screen widget” and you’ll get the same demo a dozen times over: a little box with some text in it. It’s fine for a screenshot, but it isn’t what you ship. Real apps put live, rendered content on the home screen. A boarding pass, a balance, a QR code, and keep it in sync across both iOS and Android, sometimes several at once.
So that’s the build: a Flutter app that generates a styled QR code and drops it onto the home screen as a real native widget, on both platforms, with support for more than one card. Getting there means hitting basically every wall the tutorials skip. Full code is linked in the video description.
What Does It Do?
The application is simple:
1. Enter Your Data: Type in any text, URL, or data you want to encode as a QR code
2. Add a Label: Optionally customize the widget with a descriptive label
3. Generate & Preview: See a real-time preview of your QR code
4. Update Widget: Push the QR code to your device’s home screen widget
5. Scan Anytime: Access your QR code instantly without opening the app
The widget adapts to different sizes on both platforms, ensuring your QR code looks great whether it’s a small, medium, or large widget.
The Problem: your widget can’t call your Dart code
The catch is simple. A home screen widget is native. On iOS that’s WidgetKit; on Android it’s Jetpack Glance. And that native widget has no live wire back to your Flutter engine — it isn’t running your Dart, and it can’t call into it. So the instinct to “push the new value to the widget” is the first thing that fails. There’s nothing to push to.
What you have instead is shared storage. Picture a dropbox both sides can reach. Flutter draws the QR code, saves it as an image into that shared spot along with a little metadata, a background color, a label and then taps the OS on the shoulder: refresh. The widget wakes up, reads from that same spot, and draws whatever it finds.
Flutter writes; native reads. Nearly every bug in this feature traces straight back to that one sentence. And a corollary that will cost you an afternoon if you miss it: three layers Dart, Kotlin, and Swift — all share the same key-name strings. If those names don’t line up exactly, the whole thing quietly stops working. No crash, no log. Just a blank square.
1. Render the QR once
The Flutter side does all the writing. Two packages carry it: qr_flutter ^4.1.0 to draw the code, and the community home_widget ^0.9.0 package to talk to native. Everything routes through one class, home_widget_config.dart, which is the single contract both platforms read from.
Constants first because these are the exact strings the native code depends on:
// *** Dart ***
static const String _appGroudId = 'group.com.example.homeWidgetApp.QrWidget';
static const String _androidWidgetName = 'QrWidgetReceiver';
static const String _iosWidgetName = 'QrWidget';
static const String _keyImagePathBase = 'qr_widget_image_path';
static const String _keyBgColorBase = 'qr_widget_bg_color';
static const String _keyCardLabelBase = 'qr_widget_card_label';
static const String _keyActiveWidgetCards = 'qr_widget_active_cards';Keep them in one place and don’t touch them casually. One typo here and you’re back to staring at a blank widget. initialize() sets the App Group ID and has to run before any read or write happens.
The heart of it is updateWidgetForCard. Five steps, and the second one is the trick:
// *** Dart ***
// 1. Build the QR with qr_flutter - colors, eye/data-module shapes, size.
final qrWidget = /* QrImageView(...) wrapped in Directionality + MediaQuery */;
// 2. Render it OFF-SCREEN straight to a PNG - this is NOT a screenshot.
await HomeWidget.renderFlutterWidget(
qrWidget,
key: '${_keyImagePathBase}_$cardId',
logicalSize: const Size(155, 155),
);
// 3. Save what native needs: bg color as an ARGB int, and the label.
await HomeWidget.saveWidgetData('${_keyBgColorBase}_$cardId', widgetBgColor.toARGB32());
await HomeWidget.saveWidgetData('${_keyCardLabelBase}_$cardId', cardLabel);
// 4. Register the card in the active list.
await _addToActiveList(cardId);
// 5. Ping both platforms to reload.
await HomeWidget.updateWidget(androidName: _androidWidgetName, iOSName: _iosWidgetName);Render it, save it, tag it, ping it. renderFlutterWidget is the line worth pausing on: it rasterizes a real Flutter widget off-screen to a PNG, so you get all of qr_flutter‘s styling for free and both platforms just display a picture. The alternative; drawing the QR natively on each side, means writing and maintaining the styling twice and hoping the two stay identical. Build it once in Flutter, and they always match.
The QR itself is plain qr_flutter, styled through its eye and data-module APIs. his is the widget renderFlutterWidget rasterizes:
// *** Dart ***
QrImageView(
data: qrData,
backgroundColor: backgroundColor,
eyeStyle: QrEyeStyle(
eyeShape: eyeShape == 'circle' ? QrEyeShape.circle : QrEyeShape.square,
color: eyeColor,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape:
dataModuleShape == 'circle' ? QrDataModuleShape.circle : QrDataModuleShape.square,
color: dmColor,
),
size: 155,
version: QrVersions.auto,
)2. Android: reading it back with Glance
Modern Android widgets are Glance, which is essentially Compose for the home screen. The entry point is QrWidgetReceiver; it hands off to provideGlance, which reads the shared data with HomeWidgetPlugin.getData(context) . The same keys Flutter just wrote.
Then the gotcha that got me. Android caps the data crossing the process boundary into the widget at roughly one megabyte (the Binder transaction buffer). A full-size QR bitmap sails right past it, and your widget dies with no crash log - just a blank square. The fix is to downsample the image before you hand it over:
// *** Kotlin ***
private const val MAX_BITMAP_PX = 300
// decodeSampledBitmap(...) uses BitmapFactory's inSampleSize to load the file at ≤300pxOne detail worth calling out: the bitmap is produced with produceState keyed on both the image path and the file’s lastModified() stamp. Same path but new file contents still forces a reload, without the stamp, Glance would happily keep showing a stale cached bitmap after you saved a new QR. Color is read defensively too, since SharedPreferences may hand it back as a Long or an Int:
/// *** Kotlin ***
private fun readColorArgb(prefs, key): Int = when (val v = prefs.all[key]) {
is Long -> v.toInt(); is Int -> v; else -> 0xFFFFFFFFL.toInt()
}After that it’s easy: QrWidgetContent draws the image (or a placeholder when nothing’s saved yet), and a tap opens MainActivity.
3. iOS: the App Group
iOS is the same story in WidgetKit words. QrProvider is the timeline provider, and loadEntry is where it reads the shared data — but look closely: it reads UserDefaults(suiteName: appGroupId). A plain UserDefaults is private to your app; point it at the App Group suite and suddenly the app and the widget are reading and writing the same box. You have to enable that App Group capability in Xcode on both targets — the app and the widget extension. Miss it on one and nothing works.
// *** Swift ***
let defaults = UserDefaults(suiteName: appGroupId) // the App Group suite = the shared box
// cardId here is the configured card, or the first active one as a fallback
let imagePath = defaults?.string(forKey: "\(keyImagePathBase)_\(cardId)")
let bgColorArgb = defaults?.object(forKey: "\(keyBgColorBase)_\(cardId)") as? Int ?? 0xFFFF_FFFFColor comes back as a raw ARGB integer and gets bit-shifted into a SwiftUI Color — stored as a number because Dart, Kotlin, and Swift all speak numbers, and each side rebuilds the color its own way:
// *** Swift ***
let a = Double((bgColorArgb >> 24) & 0xFF) / 255.0
let r = Double((bgColorArgb >> 16) & 0xFF) / 255.0
let g = Double((bgColorArgb >> 8) & 0xFF) / 255.0
let b = Double( bgColorArgb & 0xFF) / 255.0
let bgColor = Color(red: r, green: g, blue: b, opacity: a)The timeline refresh policy is .never ; Timeline(entries: [entry], policy: .never) — because nothing here changes on a clock. It changes when the user saves a card, and that’s the moment Flutter calls updateWidget.
The code also handles an edge case that’s easy to miss. Flutter stores an absolute file path to the PNG — and a device migration or iCloud restore invalidates it, because the App Group container remounts under a new UUID while your saved path still points at the old one. The file name is stable, so loadImage falls back to rebuilding the path against the container this process actually has:
// *** Swift ***
guard let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: appGroupId) else { return nil }
let rebuilt = container.appendingPathComponent(”home_widget”)
.appendingPathComponent((storedPath as NSString).lastPathComponent)
return UIImage(contentsOfFile: rebuilt.path)Skip that and the widget shows a placeholder forever after a phone upgrade, even though every value looks present. That’s the kind of bug you only discover in a support ticket six months later.
4. Multiple cards from one app
Multi-card mostly falls out of the key scheme: every key ends in _<cardId>, and one comma-separated list. qr_widget_active_cards tracks which cards are live. Add a card and it joins the list; removeWidget nulls its keys and drops it.
// *** Dart ***
Future<List<String>> activeWidgetCardIds() async {
final data = await HomeWidget.getWidgetData<String>(_keyActiveWidgetCards);
if (data == null || data.isEmpty) return [];
return data.split(’,’).where((id) => id.isNotEmpty).toList();
}The interesting part is that each platform picks which card a placed widget shows in a completely different way. Android uses a configuration activity (QrWidgetConfigureActivity) that pops an AlertDialog of labels and stores the choice in Glance state under widget_card_id. iOS uses an AppIntent — SelectCardIntent with a CardQuery that reads the active cards straight from the App Group and feeds the system’s long-press configuration sheet. Two mechanisms, one outcome. And both fall back to the first active card when a widget hasn’t been configured yet or when the card it was pointed at has since been deleted in the app — otherwise a placed widget gets stranded on the placeholder with no way back.
Trade-offs and gotchas
Four things actually break this feature, and they’re worth committing to memory:
Names and keys must match across all three layers. They’re duplicated in Dart, Kotlin, and Swift by necessity — change one, change all. One typo, blank widget.
Storage is the handoff. Stale data on the widget is almost always a missing updateWidget call, not a read bug.
Downsample on Android or you hit the Binder size limit and the widget silently dies.
Color is a raw ARGB int, rebuilt on each side.
And two honest costs. Render-to-PNG is the right call here because the content only changes on a user action, so a .never timeline is correct and cheap — but it’s the wrong tool for something that genuinely changes on a schedule (a countdown, a live price), where you’d want a native timeline pulling real data instead of a frozen image. And the triplicated key strings are a maintenance tax: there’s no shared source of truth across the three languages, so a rename is a three-file change with a compiler that won’t warn you when you forget one. On a larger app you’d push those strings through generated constants.
Platform Differences Matter. Despite Flutter’s “write once, run anywhere” promise, native widgets require platform-specific implementations. Embracing this rather than fighting it led to better results.
File Paths Are Tricky. Getting the correct file paths that both the Flutter app and native widgets could access required careful testing on both platforms.
Widget Updates Aren’t Instant. Unlike app UI updates, widget updates involve communication with the system. Setting proper user expectations through loading states is crucial.
Testing on Real Devices Is Essential.Widget behavior differs significantly between simulators and real devices, especially around file access and update timing.
Getting Started
Want to try it yourself? The project requires:
1. Flutter SDK 3.9+
2. iOS 18.0+ for iOS widgets
3. Android minSdk 21 for Android widgets
4. Xcode 16+ for iOS development
5. Android Studio for Android development
The key dependencies are minimal:
environment:
sdk: ^3.12.2
dependencies:
flutter:
sdk: flutter
qr_flutter: ^4.1.0
home_widget: ^0.9.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
uses-material-design: trueWrapping up
The whole feature rests on a single idea: the widget is native, it can’t call your Dart, and shared storage is the only bridge — data written, not pushed. Match the key names, downsample on Android, treat color as an int, and remember to ping updateWidget, and the rest really is just UI.
Full walkthrough is in the video: Home Screen Widgets in Flutter. The packages are home_widget and qr_flutter. Next up I’m turning the QR side into a proper customizer — custom colors, embedded logos, saved designs — as a follow-up build.
Conclusion
Building a cross-platform home screen widget taught me that Flutter’s strength isn’t just in creating beautiful UIs — it’s in creating a unified development experience while still leveraging platform-specific features where they matter most.
The combination of Flutter’s rapid development cycle with native widget capabilities creates something truly useful: instant access to your most important QR codes, all from a single codebase.
Whether you’re building for personal use or creating a product, home screen widgets add tremendous value. And with Flutter, you can build them once and deploy everywhere.
Found this helpful? Consider:
• 💬 Leaving a comment with your questions
🔔 Subscribing for more Flutter tutorials
⭐ Starring the GitHub repository
View the original post
Resources
Have you built any home screen widgets? What challenges did you face? Drop a comment and share your experiences below!

Comments