Flutter Home Widgets: All Tests Green, the Agent Said It Was Fine, and the Number on the Home Screen Was Still Wrong
A +0.00% that was never measured, and a net worth truncated into a different number. Neither one breaks the build. They just quietly show the wrong thing.
Flutter Home Widget Traps · Part 1 of 2 | Part 2: Flutter Home Widgets on iOS: Background Refresh Didn’t Complete Once in Four and a Half Days
Introduction: all tests green, and the number on the home screen was still wrong#
Moniit is my personal asset-tracking app (Flutter, iOS and Android). Most of its home screen widget was written by me and an agent together: I decided the spec, read the reviews and did acceptance testing on real devices; the agent wrote the implementation and the tests. At the end of August the app had more than 1,300 tests, all green, and four review agents had each gone over it from a different angle.
Then I found out the widget had never once launched on an iPhone (Part 2, section 1).
That isn’t the agent’s fault. Most of the traps in these two posts come from code that is written the “right” way. “Compare the latest snapshot with the previous one” has always been correct inside the app. Shrinking text to a minimum and then truncating it is SwiftUI’s default behavior. Follow the official docs, or hand the job to an agent, and this is the code you get. It compiles, passes lint, passes the tests, and then on a certain kind of user’s home screen it quietly shows the wrong thing.
The official docs teach you how to build a widget. These two posts are about where it still goes wrong after it’s built:
- Part 1 (this post): are the numbers shown correctly? How to split the architecture, two traps that make the numbers lie, and the main points on Android
- Part 2: do the numbers update? Background refresh on iOS, the keychain, WidgetKit’s budget, and what to show when only part of the data is missing
Most traps come with a rule you can copy, and Part 2 ends with a step-by-step implementation order. Every figure is a real screenshot or is computed from measured data.
Putting a number on the home screen is signing a contract you never read#
The motivation was simple. Users want to check “how much did I make today” several times a day, and the only way to do it was: unlock the phone, open the app, wait for the card on the home page to load, then tap once more to switch it to the daily change. Four actions to see one number.
Home screen widgets exist for exactly this kind of glance. Add one, and the number is just there.
I thought it was a small feature. Install a package, write a SwiftUI view, put the number in. An afternoon’s work.
The Flutter half really wasn’t hard. The hard part was the half I hadn’t thought about: that little square is not inside your app. It runs in a different process. It can’t read your database, can’t call your state, can’t load your translation files, and the system wakes it only a few dozen times a day. All you can hand it is a bundle of strings.
And what you’re putting there is the user’s money.
A number inside the app doesn’t have to answer for its freshness, because the user sees it by opening the app, and that action already means “I’m going to check now.” A number on the home screen has no such action.
What this post helps you avoid
- On days the user never opens the app, the widget shows a
+0.00%that was never measured - With an 11-digit net worth, a 9-digit daily change gets truncated to
+123,456,7…, which reads as a different number - The preview the user sees in the widget picker is a blank white card

1. The widget isn’t your app. It’s a note stuck on the fridge door#
When you write Flutter, everything on screen lives in one world: widgets read providers, providers read repositories, repositories read the database. To update the screen you change state and the framework redraws.
A home screen widget is more like a note stuck on the fridge door: it says whatever you last wrote on it, and it doesn’t update while you’re out.
The technical limits look like this:
- On iOS, the WidgetKit extension is a separate process with a much smaller memory limit than the app (the commonly quoted figure is around 30 MB; Apple hasn’t published a number), and it has no Dart VM. Your Flutter engine doesn’t exist there.
- On Android, an App Widget uses
RemoteViews, and the launcher’s process does the actual drawing. What you hand over is a set of layout instructions, and the launcher draws from them. - Neither side can open Drift, call a Riverpod provider or read
.arbtranslation files. The app’s networking layer isn’t there either: no Dio, no interceptors, no token refresh.
At first the only channel was one piece of shared storage: App Group UserDefaults on iOS, SharedPreferences on Android. What you can put in it is strings.
iOS later gained four more channels, all so the widget could value the portfolio itself (Part 2, section 5). The full picture now:

So the architecture was decided by the limits, not by me: the app computes → writes to shared storage → asks the system to redraw.
(The home_widget package (0.9.3) offers another route: renderFlutterWidget, which renders a Flutter view to an image and pins that up. I didn’t use it; the reason is at the end of the next section.)
2. So the architecture has one rule: keep the native side dumb on purpose#
Since the channel only carries strings, the next question is: what gets done in Dart, and what in Swift / Kotlin?
My answer is a single rule:
The native code is dumb on purpose.
Every number format, every translated word, every state decision is computed in Dart and written as one “string → string” JSON bundle. Swift and Kotlin do one thing: lay out strings that are already final.

So the payload looks like this (excerpt):
{
"state": "ready", // ready / empty / signedOut
"trend": "up", // up / down / flat (meaning, not color)
"netWorthText": "1,240,500", // already formatted, no currency symbol, see section 5
"netWorthShortText": "124.1萬", // short form when the full number doesn't fit, used by Android (萬 = 10,000)
"dailyRateText": "+1.24%", // sign already added
"dailyValueText": "+15,200",
"labelNetWorth": "淨資產", // already translated; the widget never touches .arb
"updatedAtText": "更新於 14:32",
"updatedAtEpochMs": "1756450320000", // for the native staleness check
"dailyFigureDateIso": "2026-08-29", // which day these daily figures describe
"localeName": "zh_TW" // the app's language, not necessarily the phone's
}
The first version’s native side was small to the point of feeling wrong: under 800 lines of Swift and Kotlin combined, all layout.
There’s one reason to split it this way: flutter test can’t reach Swift.
Any logic you put on the native side is logic automated tests will never see, and it will slowly drift away from its twin in Dart. Change how negative numbers display in Dart, and Swift won’t know. Six months later the two disagree and nothing turns red.
So the rule runs the other way: before adding any decision, ask whether it can go back into the function that builds the payload. That’s a pure function: in go two snapshots plus the user’s preferences, out comes a bundle of strings. No database, no network, no Riverpod.
This also answers the question from section 1: why not use renderFlutterWidget to render the screen to an image? Because an image is dead. A PNG can’t hide “yesterday’s daily change” by itself at midnight, and it can’t decide based on width whether to switch to a short form. Go that way, and the widget can only ever show what the app looked like the last time it was awake. It couldn’t do any of the things in the next section.
I broke this rule once, in Part 2, section 5. The way I broke it is an extension of the rule: the Swift valuation code is checked against the same fixture as the Dart code.
3. The exceptions: the clock and the width can only be read at draw time#
The rule above has exceptions. The first version had two; now there are four, and before adding each one I asked: could this go back into Dart?
The first three need the clock at the moment the view is drawn, not the clock at the moment the app wrote the data:
- Is
dailyFigureDateIsoalready behind the device’s today? (After midnight the daily figures must be hidden.) - Is
updatedAtEpochMsmore than 24 hours old? (Then show “Data may be outdated”.) - Was the payload published today? (A new user’s first-day hint is only true that day; see section 4.)
Dart can’t decide these when it writes, because at write time the answer to all of them is “no”. It only becomes “yes” as time moves on, and by then your app is long gone.
The fourth needs the width of the view itself: does this number fit? The width depends on the device, the widget size, how many cells the user stretched it to and the text size setting. Only the moment of layout knows (section 5).

iOS has a mechanism that fits this well, but first you need to know how WidgetKit works, because it’s nothing like “the app draws whenever it wants.”
What you give the system isn’t a view. It’s a schedule. That schedule is called a timeline. It holds a list of entries, and each entry is a pair: “at this time, draw with this content.” Once you hand over the timeline you can clock out; when the time comes, the system takes the matching entry and draws it, without needing (or being able to call) your app.
So the first version put two entries in the timeline instead of one: one for now, and one at the next local midnight. Same payload, evaluated again with a clock that has passed midnight. That makes “put yesterday’s figures away at midnight” deterministic, without hoping WidgetKit happens to wake me at the right time. Today the timeline has one entry every 15 minutes, up to one hour ahead or local midnight (whichever comes first), so the midnight entry is still always there.
The 00:00 row of the figure is worth its own paragraph: net worth stays on screen; only the daily figures are hidden. That wasn’t an accident. Net worth only claims “this was the value at some point”, and it’s shown with a timestamp, so it never expires. The daily change carries a claim about “today”, and after midnight that claim is false.
The same bundle of data, with two fields that have different shelf lives. I only understood that after building this feature.
To decide “has midnight passed”, the native side compares the device’s today with the date in the payload. There was a bug here I couldn’t see myself; a review agent caught it during a parallel review. iOS built today’s date string with Calendar.current, which follows Settings → General → Language & Region → Calendar. The app has no calendar feature, but iPhone lets users switch the calendar to Buddhist or Japanese. Under the Buddhist calendar this year is 2569, which never equals the 2026-… that Dart writes into the payload, so the daily figures would never show. The fix pins the calendar to Gregorian for the comparison (Calendar(identifier: .gregorian)), while the time zone still follows the user. Android formats the date with Locale.US and was never affected. I found this one by reading the code; I didn’t reproduce it on a device by switching calendars.
Color works the same way, but in the opposite direction: it has to be left to the native side. The system can switch between light and dark mode while the app isn’t running, so any color baked into the payload may already be wrong by draw time. The payload carries only meaning, like trend: "up", and each side resolves it through its own theme resources: on Android, two color resources in values/ and values-night/; on iOS, color sets in the asset catalog.
Apple’s docs say this more bluntly than I do: for system appearance changes and locale changes, don’t request a timeline reload from your app; the system updates the widget itself. Leaving it to native resources isn’t just cleaner. You shouldn’t spend that budget on a color change at all.
4. Trap 1: a “today” that was never measured#
Everything so far was design. This is where the feature started teaching me things.
After the code was written and the tests were green, I asked one question: “If the user doesn’t open the app at all today, what does this widget show?”
The logic at the time was “take the latest snapshot and compare it with the last one before today.” Sounds fine.
But on a day the app was never opened, “the latest snapshot” and “the last one before today” are the same snapshot.
So the widget computes +0.00% and stamps it with today’s date.
In plain words it’s saying: “I measured today, and your assets didn’t change.”
What actually happened: nothing was measured today.

For a finance app this isn’t “data that’s a bit old.” It’s actively misleading. A user who sees 0.00% feels reassured, and that reassurance was handed out with nothing behind it.
There’s a second layer that makes it worse: that stamped date happens to pass the native midnight check from section 3. The safety net ends up working against its own purpose. The midnight check is there to put the numbers away when there’s no new data, and here it feeds in a fake “measured today” stamp.
The fix makes the widget read the same pair of data as the in-app card: “the last snapshot of today” and “the last snapshot before today”. If today’s doesn’t exist, the daily change is null and no date stamp is written.
// A daily change needs a measurement taken *today*. Falling back to the
// latest snapshot would compare yesterday's close against itself and
// report a flat day, claiming a measurement that never happened.
final hasDailyFigures = todaySnapshot != null && baselineSnapshot != null;
// ...
dailyFigureDateIso: hasDailyFigures ? _isoDate(now) : '',
The interesting part: the card inside the app never had this problem. It always paired “today’s last snapshot” with “the last one before today”, and showed nothing when today had no data.
The same calculation that is correct in the app turns into a lie when copied to the widget, because the widget is seen on exactly the days the user doesn’t open the app, which is a situation the in-app card never meets. The bug isn’t in the code. It’s in who sees it, and when.
The copy for this waiting state is part of correctness too. My first version said the daily change would show from tomorrow. I changed it to “Daily change after today’s update”, because the numbers come back as soon as the day’s first update lands: the next background refresh or the next time the app is opened. Seeing “from tomorrow” just after midnight reads like a widget that has given up.
That change was only half right. It’s right for “not measured yet today” and wrong for “there has never been a previous day.” On a new user’s first day there’s no record before today at all, so no number of updates today will produce a daily change. A new user who sees “after today’s update” keeps coming back to refresh, waiting for a number that won’t arrive today.
So the payload now carries two lines: “Daily change after today’s update” and “Daily change starts tomorrow”. The second is only given when there’s no baseline at all, and the native side only uses it on the day the payload was published; after midnight, “tomorrow” is already today, so it switches back to the first line. That’s the third clock in the list in section 3. The copy should say when the number will come back, and that depends on whether what’s missing is today or yesterday.
Rule: The daily figures need both “today’s measurement” and “a baseline from before today”. If either is missing, show no number and write no today date stamp. Falling back to the latest snapshot as the baseline claims a measurement that never happened. The waiting copy should name whichever one is missing.
5. Trap 2: a net worth missing digits is far worse than a smaller font#
Long numbers are a problem every widget that shows money runs into. The first step is dropping the currency symbol: with a symbol on every number, even ordinary amounts get truncated to -1.8... on the small size. But that only pushes the problem back a few digits, as becomes clear once you think in Korean won and Japanese yen.
The same wealth written in KRW has about two more digits than in Taiwan dollars, and in JPY about one more. A KRW user with around ten million US dollars who gains 1% today sees +140,000,000. In the half-width slot of the small size, a number like that first shrinks to 45%, still doesn’t fit at the minimum, and SwiftUI cuts it with an ellipsis.

+123,456,7… doesn’t read like a truncated number. It reads like a different number.
When I measured, a large chunk of the width had been eaten. Since iOS 17, WidgetKit adds its own margin around the content, and our view added another 12pt of padding on top. Measured on the iPhone 17 simulator, the text sat 30pt from the card’s edge (so WidgetKit’s margin is about 18pt): on a 164pt-wide card, more than a third of the width was gone, left and right combined. After keeping that padding only below iOS 17, the small size gained about 20% more content width.
The medium size broke on height. The daily figures were stacked as “label on one line, number on the next”, which made the content a few points taller than the widget, and SwiftUI handles vertical overflow by shrinking every text that’s allowed to shrink. So today’s percentage and the time below it shrank to half size, while the change next to them stayed normal.

With the label and number on one line, the medium size saved enough height that the text could get bigger: .subheadline for labels and .title3 for numbers (15pt and 20pt at the default text size).
What to show when it doesn’t fit#
In the end, length came down to one rule: if the full number fits, show it. Never show ”…”.
iOS uses ViewThatFits to try, in order, the full number at three font sizes from large to small; only if none fits does it switch to a short form such as 123.5億, 14억 or 12.35B. iOS 15 has no ViewThatFits and keeps the old shrinking behavior.

The short form has three details, and I nearly got each one wrong:
- It follows the app’s language, not the phone’s. A user can pick Japanese in the app and keep the phone in Chinese, so the payload carries
localeNameas well. - Chinese, Japanese and Korean use their own ten-thousand and hundred-million units (万, 億, 만, 억); other languages use K, M, B, and the decimal point is always ”.”. Spanish originally gave
12,35 mil M, and that decimal comma gets confused with the thousands commas of the full number. - Percentages are never abbreviated. Rounded to a whole number,
-99.99%becomes-100%, which looks like everything is gone. A percentage is at most eleven characters, so shrinking the font is enough.

On Android the hard part is different: RemoteViews can’t ask “does this text fit?” The layout is drawn by the launcher in its own process; all you hand over is instructions.
So Android does it in two layers. The first is the TextView’s autoSizeTextType (API 26+), which shrinks net worth automatically between 26sp and 18sp. The second is in the provider: Paint.measureText measures how wide the full number is at the minimum size and compares it with the widget width the launcher reports; only if it doesn’t fit does it switch to the short form. Dart computes the short form and puts it in the payload, checked against the same fixture as the Swift version. One more that’s easy to miss: when the user resizes the widget, the system doesn’t send an update, so you have to redraw in onAppWidgetOptionsChanged yourself.

The full set of measurements is in this table. ”—” means a combination I didn’t measure; I’d rather leave it blank than fill it with a guess.

First impressions#
The same round fixed two first-impression problems. They don’t affect whether the numbers are right; they affect whether users add the widget at all.
In Android’s widget picker, Moniit’s preview was a blank white card. previewLayout pointed straight at the real layout, and the real layout has no text until the provider fills it in. That’s the one moment a user decides whether to add the widget. The preview now uses a layout with sample numbers.
The other is when the app hasn’t published any data yet: a fresh install that hasn’t signed in, or an update where the user added the widget before opening the app. The screen showed only a “Net Worth” title and looked broken. Both platforms now add a line, “Open Moniit to show your net worth”. That line lives in the extension’s own localized resources (the extension’s .lproj on iOS, res/values*/ on Android), because what’s missing is exactly the payload that carries all the other labels.

6. On the Android side#
Android has fewer traps than iOS, for a practical reason: WorkManager’s periodic work runs at the declared period on the emulator (on real devices it’s still subject to Doze and standby buckets), while on iOS the system decides when you get to run (all of Part 2 is about this). But Android has its own rules. They’re scattered through the sections above, so here they are in one place:
- The launcher draws the view.
RemoteViewsonly hands over layout instructions, and you can’t ask “does this text fit?”, so long numbers rely onautoSizeTextTypeplus your own measurement withPaint.measureText(Trap 2) - When the user resizes, the system doesn’t send an update; redraw yourself in
onAppWidgetOptionsChanged - Leave light and dark to
values/andvalues-night/; the payload carries only meaning liketrend(section 3) previewLayoutneeds sample numbers, or the widget picker shows a white card
And one thing deliberately not done: the Android widget doesn’t value the portfolio itself. On iOS, when the app doesn’t wake, the widget fetches prices and computes net worth on its own (Part 2, section 5), which requires putting the quantity and symbol of every holding somewhere the widget can read. On Android that place is SharedPreferences, which isn’t encrypted. Since WorkManager does run, there’s no reason to lay out the holdings in an unencrypted file.
Finally, to be clear about scope: the Android half has only been verified on the emulator (Pixel, API 35). Some Android makers (Xiaomi, OPPO and vivo, for example) stop background work more aggressively, and I haven’t tested on any real device yet. Every trap in Part 2 was caught on a real iPhone; Android doesn’t have the same level of evidence yet.
Conclusion#
Neither of these traps breaks the build. “Compare the latest snapshot with the previous one” has always been correct in the app, and truncating after shrinking to a minimum is SwiftUI’s default. Both pass compilation, lint and more than a thousand tests, and then show the wrong thing on a certain kind of user’s home screen: people who don’t open the app, and people with more digits in their net worth.
After this round, the way I accept work changed. I don’t stop at the agent reporting green tests. I list scenarios and ask it to answer, one by one, “what does the widget show at this moment?”: the user hasn’t opened the app for days, it’s just past midnight, the net worth has 11 digits, it’s a fresh install that hasn’t signed in. A number in the app is seen because the user chose to open it; a number on the home screen has no such action, and gets seen on days and at widths you didn’t think of. The agent won’t come up with these scenarios on its own, and neither will the tests, because both assume the screen is seen while the app is running. Listing the scenarios is your job.
This post covered “is what’s shown correct?”. Part 2 is the other half: does this number update? On iOS that’s much harder than I expected: background refresh didn’t complete once in four and a half days on my phone, a locked phone can’t read the token and doesn’t say so, and in the end I had the widget compute net worth itself.
Part 2 ends with a step-by-step implementation order that pulls together the key points from both posts.
→ Part 2: Flutter Home Widgets on iOS: Background Refresh Didn’t Complete Once in Four and a Half Days
References#
home_widget: the Flutter-side bridge for shared storage and widget updates- Android — App Widgets overview: the limits of
RemoteViewsandAppWidgetProvider - Android — Autosizing TextViews: auto-sizing that also works inside
RemoteViews - Apple — ViewThatFits: try the full number at a few sizes in order, and switch to a short form only if none fits
- Apple — WidgetKit: the timeline provider and entry model