How AI Agents Struggle with Flutter: 7 Gaps Experience Still Closes

TL;DR. This is not an argument against AI writing Flutter. Agents write a large share of our code, and we designed our process around that on purpose. It is an argument about what happens when nobody is orchestrating them. We ship Flutter with agents, and we audit Flutter that agents wrote unsupervised — and the same seven gaps separate the two. An unsupervised agent recomputes derived state everywhere instead of keeping one source of truth; writes no tests and no benchmarks, so review is the only gate and regressions ship freely; collapses complex async into if- and switch-pyramids over loose flags instead of modelling it as a stream; treats design tokens as constants in a constants.dart instead of the ThemeData the framework already gives it; invents a fresh UX vocabulary on every screen, because it cannot see the app; concatenates strings where ICU plural rules are mandatory; and simplifies uniformly, flattening the parts of the domain where complexity is the point. None of this is a Flutter problem, and none of it means the code is worthless. It means the whole-system decisions are still ours.
Why Flutter punishes this harder than most stacks
Every one of these failures shows up in every language. Flutter just makes them louder, for four structural reasons.
The UI is code, all the way down. There is no stylesheet sitting outside the source to keep things coherent, no cascade, no shared CSS file that a second developer is forced to notice. Every pixel is a Dart expression. If consistency is not enforced inside the code, nothing else enforces it.
Everything compiles. Flutter offers roughly ten reasonable ways to do anything, and all ten run. There is no compiler error for "you should have used the theme here", no warning for "this is the third time you derived this total", no lint for "this screen loads differently from the other thirty-nine". The feedback the agent needs does not exist unless someone builds it.
The agent never sees the frame. It writes a screen, gets a clean flutter analyze, and reports success — on a layout that overflows at 320pt, hides the submit button behind the keyboard, and fails contrast in dark mode. A human notices all three in two seconds of looking. An agent has no eyes unless you give it some.
The training data is a decade of mixed Flutter. The framework moves fast and the corpus does not. So agents confidently write RaisedButton (removed in 3.0), WillPopScope (superseded by PopScope after 3.12), MediaQuery.of(context).textScaleFactor (replaced by TextScaler in 3.16), MaterialStateProperty (renamed WidgetStateProperty in 3.19), Color.withOpacity (deprecated for withValues in 3.27), and ThemeData.accentColor, which was removed in 3.7 and has not existed for years. It is not that the model is wrong — it is that it is answering for a Flutter version nobody ships anymore, and half of it still compiles with a deprecation warning nobody reads.
Now the seven.
1. One source of truth, or recompute it everywhere
What the agent does. It derives the same value in every place that needs it. The cart total is folded in the cart page. It is folded again in the sticky checkout footer. It is folded a third time in the order summary — and that one applies the discount before tax instead of after. Nothing is wrong in any single file; the three just disagree, and the disagreement is invisible until a customer is charged the wrong amount.
The Flutter-specific version is worse: mirroring state into a widget.
class _CartPageState extends State<CartPage> {
double _total = 0; // copy #2 of the truth
@override
void initState() {
super.initState();
_total = widget.items.fold(0, (sum, i) => sum + i.price * i.qty);
}
// ...and now _total never updates when the cart does
}
This is the classic initState copy. It works in the demo because the demo never changes the cart after the page opens. It breaks the first time a quantity is edited from a bottom sheet.
What we do. The value is derived exactly once, in the state layer, and every widget reads it. If it can be computed from state, it is not state.
// One derivation. The cart page, the footer and the summary all read this.
Stream<Money> get total => _cart.map(
(c) => c.items.fold(Money.zero, (sum, i) => sum + i.subtotal),
);
Note the Money type rather than double. Agents reach for double on money constantly, and 0.1 + 0.2 != 0.3 is not academic when it is somebody's balance.
Why the agent lands there. Finding the existing derivation costs context; regenerating it inline costs nothing. Each prompt starts fresh, so "is this already computed somewhere?" is a question it structurally cannot ask. Every copy is locally reasonable. The drift is what you pay for.
2. Tests and benchmarks, or hope
What the agent does. It ships the feature and stops. No widget tests, no goldens, no benchmark. The definition of done is "it compiles and the screen looks plausible in the description I wrote of it".
That leaves code review as the only quality gate — and this is the part people underestimate. Review was already the bottleneck when humans wrote the code at human speed. Point three agents at a codebase and the volume of diff arriving at that gate goes up by an order of magnitude while the number of people qualified to review it stays at one. Review does not scale. Executable checks do.
What we do. We write the tests and the benchmarks first, and not as a virtue exercise — as the feedback loop the agent is missing.
- Widget tests for behaviour: the button disables while submitting, the error clears on retype, the list paginates at the right scroll offset.
- Golden tests for appearance. This is the single highest-leverage thing you can hand an agent working on UI, because a golden is the closest thing to giving it eyes.
matchesGoldenFileturns "did the redesign break the empty state at 320pt in dark mode with 200% text scale?" into a check that runs in CI in four seconds. Without goldens, that question is only ever answered by a human opening the app — which is to say, sometimes. - Benchmarks for the things that quietly rot. The frame budget is 16.6ms; a list that janks after someone adds a shadow and an
Opacityinside the item builder is a regression no unit test catches. Frame timings on a profile build, checked against a budget, catch it. - A regression test for every bug we fix, so the agent that fixes it cannot un-fix it three prompts later.
The pattern to internalize: an agent is excellent at satisfying a check it can run, and helpless where no check exists. Give it flutter test and it will iterate until green. Give it nothing and it will tell you it is confident. Our audit findings post covers what a codebase looks like after a year of the second option.
3. Streams, or a pyramid of flags
What the agent does. It represents async state as loose parallel fields and then branches over them.
bool _isLoading = false;
String? _error;
List<Hit> _items = [];
// build():
if (_isLoading) return const CircularProgressIndicator();
if (_error != null) return Text(_error!);
if (_items.isEmpty) return const Text('Nothing here');
return ListView(/* ... */);
Three fields, eight representable combinations, four of them meaningless. What renders when _isLoading is true and _error is set? Whatever the order of the ifs happens to be. Then the search box arrives, and the agent adds a Timer for debounce, a _requestId counter to discard stale responses, and a mounted check before each setState — a hand-rolled, subtly broken reimplementation of switchMap. Alongside it grows a utils.dart of static helpers, because the logic has nowhere structural to live.
This is the specific shape: not that agents avoid switches, but that they switch over flags they set by hand instead of over a type the compiler can check.
What we do. The state is a sealed hierarchy, so illegal combinations are unrepresentable, and the stream handles the timing.
sealed class SearchState {}
final class Idle extends SearchState {}
final class Loading extends SearchState {}
final class Failed extends SearchState { Failed(this.error); final AppError error; }
final class Loaded extends SearchState { Loaded(this.hits); final List<Hit> hits; }
// build() — the compiler enforces that every case is handled
return switch (state) {
Idle() => const SearchHint(),
Loading() => const AppSpinner(),
Failed(:final error) => AppErrorView(error),
Loaded(:final hits) when hits.isEmpty => const EmptyResults(),
Loaded(:final hits) => HitList(hits),
};
And the async part — debounceTime, switchMap and startWith are rxdart extensions on Stream, not dart:async — where the flag version quietly races:
// Debounce and cancellation are declarative. A stale query cannot win,
// because switchMap already cancelled its subscription.
Stream<SearchState> get states => _query
.debounceTime(const Duration(milliseconds: 300))
.switchMap(_search)
.startWith(Idle());
Add a new state — RateLimited, say — and the compiler lists every switch that needs updating. In the flag version, adding a state means finding every if chain by hand and hoping.
Why the agent lands there. Imperative flags are the most common pattern in the corpus and the easiest to produce one line at a time. A stream requires holding the whole state machine in your head at once, which is exactly what a context-limited generator is worst at. It is the same failure we described as callback hell in audits — it just wears a Dart costume.
4. Knowing the framework: ThemeData is not a constants file
This is the one where framework knowledge shows most plainly.
What the agent does. It creates constants.dart, fills it with static const primary = Color(0xFF6750A4), and then writes styles inline at every call site.
Text(
'Balance',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
)
That single line has four separate problems, and only the first is obvious:
- Dark mode is now a manual
if.AppColors.textPrimaryis one colour. Supporting a second theme means a ternary onTheme.of(context).brightnessat every one of the 200 sites — which is exactly what agents then write, one screen at a time. - The rebrand is a 400-file diff. A designer changing the type ramp should be a change in one file. Here it is a find-and-replace across the app, and the sites that drifted (
fontSize: 15on two screens because that generation rounded differently) will be missed. - System text scaling breaks the layout. Hardcoded sizes composed against hardcoded paddings mean a user at 200% accessibility scale gets overflow, not reflow.
Theme.of(context)overrides stop working. Wrapping a subtree in a different theme — a dark section on a light screen, a branded checkout inside a neutral app — does nothing, because nothing in the subtree asks the theme anything.
What we do. The design system lives in ThemeData, once, at the root.
MaterialApp(
theme: AppTheme.light, // ColorScheme.fromSeed + TextTheme + component themes
darkTheme: AppTheme.dark,
// ...
)
// Every widget asks the framework instead of a constants file:
Text('Balance', style: Theme.of(context).textTheme.titleMedium)
Component themes (FilledButtonThemeData, CardThemeData, InputDecorationThemeData) mean a button looks right by default, so the agent producing the twentieth screen cannot get it wrong even if it tries. For brand tokens Material has no slot for — a chart palette, a gradient, a semantic "positive/negative" pair in a fintech app — a ThemeExtension keeps them inside the same lookup instead of next to it:
final class BrandColors extends ThemeExtension<BrandColors> {
const BrandColors({required this.positive, required this.negative});
final Color positive;
final Color negative;
// copyWith / lerp ...
}
// usage
final brand = Theme.of(context).extension<BrandColors>()!;
Dark mode then costs one more ThemeData, not a ternary per widget. On Arcana the entire custom design system is expressed this way, which is why a design revision there is a diff you can read in a single review.
Why the agent lands there. A constant is locally correct and immediately satisfying: the colour appears, the screen builds. Threading a design system through the framework only pays off across many screens and a second theme — and payoff-across-the-whole-app is precisely the axis a per-prompt optimizer cannot see.
5. Eyes, and a decade of shipping
What the agent does. It builds forty screens, each one defensible on its own, that together feel like an app assembled from forty tutorials — because in a sense it was.
Loading is a CircularProgressIndicator on the home screen, a shimmer skeleton on the feed, and a custom animated logo on the profile. Errors are a SnackBar here, inline red text there, and a modal AlertDialog on the third screen. A destructive confirm is an AlertDialog on one screen and a bottom sheet on the next. Empty states have three different illustrations, two different tones of voice, and one that is just the word "Empty". Some forms validate on every keystroke, some on submit, one on focus loss. Primary buttons are full-width in one flow and wrapped-to-content in another.
Every one of those is a reasonable choice. The problem is that a user learns an app once. When the same action looks different in three places, they stop trusting that they know what will happen — and that cost shows up in support tickets, not in a lint report.
Then there is the class of defects that only exist on a screen:
RenderFlex overflowed by 27 pixelson any device narrower than the agent imagined- tap targets under 44/48dp, which test fine and miss constantly on a real thumb
- the keyboard covering the field being typed into, because nothing scrolls
- text truncated at 200% system scale
- content under the notch or behind the home indicator, because
SafeAreawas not part of the prompt - contrast that passes in light mode and fails in dark
flutter analyze is clean for every single one.
What we do. We decide the interaction vocabulary once — how loading looks, how errors surface, how destructive actions confirm, when forms validate, how navigation behaves, what an empty state says — and then every screen implements that. Ten-plus years of shipping mostly buys knowing which patterns users already have in their fingers, because thousands of other apps put them there. Familiar beats clever essentially always, and the way to check is to open the app on a real device, at a real text scale, in both themes, with a thumb.
Why the agent lands there. It genuinely cannot see. It is generating a plausible screen from a description, with no memory of the thirty-nine screens before it and no image of the one it just produced. Local plausibility is the only target available to it.
6. ICU is not optional, and concatenation is not localization
We build apps for markets where getting this wrong is visible in the first sentence of the first screen. Agents get it wrong by default.
What the agent does.
Text('$count items'); // 1 items
Text('$count ${count == 1 ? "item" : "items"}'); // "clever", still broken
Text('Hello, ' + name + '!'); // English word order, baked in
Text('${d.day}.${d.month}.${d.year}'); // wrong half the world
Text('\$${amount.toStringAsFixed(2)}'); // wrong separator, wrong position
The boolean-ternary plural is the instructive one, because it looks like the developer thought about it. It encodes an assumption that is true in English and false almost everywhere else: that a language has exactly two plural forms. Russian has four categories (one, few, many, other) — 1 файл, 2 файла, 5 файлов — so the ternary is wrong for most numbers on the screen. Arabic has six. Japanese has one, and pluralizing at all reads as broken. No amount of ternary nesting fixes this, because the rule is per-language data, not a condition you write.
The same applies down the line. Concatenation bakes English syntax into the source, so a translator cannot reorder the sentence. Decimal separators, thousands separators, and currency-symbol position are locale data (1 234,56 € versus $1,234.56). Date order is locale data. And in RTL layouts, EdgeInsets.only(left: 16) — which agents write reflexively — puts the padding on the wrong side, where EdgeInsetsDirectional.only(start: 16) would have been correct.
What we do. Messages live in ARB files with ICU MessageFormat, generated into typed accessors by gen_l10n:
{
"unreadMessages": "{count, plural, =0{No new messages} one{{count} new message} other{{count} new messages}}",
"@unreadMessages": { "placeholders": { "count": { "type": "int" } } }
}
The Russian ARB for the same key carries one/few/many/other — the format has room for the language, and the framework picks the right branch from CLDR data. NumberFormat.currency and DateFormat.yMMMd(locale) handle the rest, and directional insets keep RTL honest. The call site becomes Text(l10n.unreadMessages(count)), which is both shorter than the ternary and correct.
Why the agent lands there. String interpolation is the shortest path to text on screen, and it is visibly correct in the language the prompt was written in. The failure only appears in a locale the agent was never asked about, at a number it never tried.
7. Knowing where to simplify — and where not to
The last one is judgment, and it is the hardest to hand over.
What the agent does. It applies simplification uniformly, which produces two opposite failures in the same codebase.
It flattens complexity that was load-bearing:
try {
await api.charge(order);
} catch (_) {
// swallowed: network failure, declined card, and idempotency conflict
// are now the same event, and the retry will double-charge
}
Retry and idempotency logic gets collapsed into a single call because "it looked redundant". A token-refresh race gets a simple await because the mutex "seemed unused". Distinct failure modes get merged into one Something went wrong, which is precisely the message that makes a payment bug unreproducible. Cancellation tokens get dropped because no test referenced them.
And it over-abstracts things that were fine: an AppButton with fourteen optional named parameters and six booleans, a BaseRepository<T, ID> generic hierarchy over three endpoints, an AbstractBaseViewModel nobody can subclass without reading it end to end, and the inevitable utils.dart where forty unrelated static functions go to die.
What we do. Complexity is a budget, and it gets spent where the domain is genuinely hard: payments, offline sync and conflict resolution, auth token refresh, idempotency, anything that touches money or identity. Those places stay explicit, verbose, and boring on purpose — each failure mode named, each retry deliberate. Everywhere else, we collapse aggressively: five near-identical list tiles become one widget, the settings screen gets no abstraction layer at all, and a repository that wraps one endpoint is just a function.
Why the agent lands there. "Which parts of this domain will hurt someone if they are wrong?" is not information that exists in the code. It comes from the product, the regulatory context, and having been on the incident call. An agent optimizing for readable, minimal diffs treats a payment retry loop and a settings toggle as the same kind of thing, because syntactically they are.
What orchestration actually means
Read the seven together and the shape is clear: every one is a whole-system property — consistency, coverage, a state model, a design system, an interaction vocabulary, a locale model, a complexity budget. None of them can emerge one prompt at a time, because no prompt sees the whole. That is not a flaw to be prompted away; it is what the context window is.
So the engineer's job moves up the stack, and it is mostly four things:
Set the invariants before the code exists. The theme, the state model, the localization setup, the folder structure, the error taxonomy. Once those exist, "follow the existing pattern" has something to point at — and agents are genuinely good at following a pattern that is already in the repo. The first two days decide the next six months.
Build the feedback loops the agent does not have. A strict analysis_options.yaml with lints promoted to errors. flutter test with widget and golden coverage. A frame-time budget checked on profile builds. Every one of these converts a judgment call into a check the agent can run and iterate against on its own, which is the only way its speed becomes an asset instead of a liability.
Review decisions, not lines. Is this the same state model as the rest of the app? Is this styled from the theme? Is this the one derivation of that value? Is this string going to survive Russian? Line-by-line review of agent output does not scale and never will; decision-level review does, because the decisions are few.
Open the app. On a device, at 200% text scale, in dark mode, in both languages, with the network throttled. This takes ten minutes and finds the entire category of defects that no static check will ever report.
That is the actual difference between Flutter written by an agent and Flutter written by a team orchestrating agents. Not the code — the code is often fine. The constraints around it.
Frequently asked questions
They can write good Flutter code inside a codebase that already has structure — a theme, a state model, a localization setup, and tests to iterate against. What they cannot do is create that structure, because every one of those is a whole-system property and an agent optimizes each prompt locally. Given an existing pattern to follow, agent output is genuinely strong. Given an empty repository and no constraints, it produces forty screens that each work and do not add up to one app.
Working on a Flutter app an agent built
If you recognize your codebase in this list, it is not a sign that using AI was a mistake — it is what unsupervised agent output looks like in Flutter, every time, and it is fixable without a rewrite.
We build Flutter this way for a living: agents write a lot of the code, and our engineers own the seven decisions above. Have a look at how we do Flutter app development, run an existing codebase through an AI code audit, or book a free assessment and we will tell you which of these seven is costing you the most.

Ilya Nixan
Founder & Lead Developer
Ilya founded Nerdy Production and leads its engineering. Before that he was CTO of QIWI, one of Russia's largest payment platforms, where he ran roughly 12 engineering teams spanning web products down to card processing, PCI-DSS scope, and contactless payments — including building contactless card payments on Android via Host Card Emulation over ISO/IEC 14443, with EMV Contactless (Visa PayWave) on top. He was also a principal developer at Yandex, where he worked on Yandex.Auto — taking native Android deep into the vehicle, with heavy CAN-bus integration through a custom CAN shield. He was also a principal at Evotor, whose point-of-sale devices run on a forked AOSP, giving him a low-level view of Android most app developers never touch. He has been building software since 2010 and shipping production Flutter since 2018, and now leads delivery on the agency's flagship apps — from the chart-heavy fintech UI of ExtraETF to the fully custom design system of Arcana. He writes most of the essays on this blog and maintains the agency's open-source work, including the dxpdf DOCX-to-PDF engine. He works across Flutter, Go, Rust, TypeScript, Kotlin, Kubernetes, and Docker, with a focus on app architecture, cross-platform delivery, and building teams that ship.
More from Ilya Nixan