cd /news/artificial-intelligence/how-to-review-ai-generated-flutter-c… · home topics artificial-intelligence article
[ARTICLE · art-76499] src=dev.to ↗ pub= topic=artificial-intelligence verified=true sentiment=· neutral

How to Review AI-Generated Flutter Code (Before It Breaks Production)

A developer found that AI-generated Flutter code consistently makes seven structural mistakes, including bad state management, missing tests, and hardcoded values. One example involved an agent using a subprocess call to `curl` for a GET request instead of Dart's built-in HTTP clients, highlighting a pattern where agents optimize for statistically dominant patterns over idiomatic code. The developer recommends writing tests first and deriving values from state to avoid these issues.

read8 min views1 publishedJul 28, 2026

Every unsupervised AI agent we've reviewed that wrote Flutter code made the same seven mistakes.

These aren't typos or stylistic differences. They're structural failures that compound—bad state management plus missing tests plus hardcoded colors means the codebase becomes expensive to theme, hard to test, and impossible to maintain at scale.

Here's a small one to set the tone: a developer asked an agent to implement a GET request to an external service in a Dart project. The agent's solution was to shell out to curl

via Process.run

and parse the stdout.

Not package:http

. Not dio

. Not even dart:io

's own HttpClient

. A subprocess call to a CLI tool, inside a language that's had first-class HTTP clients since Dart 1.0.

That one is worth sitting with, because it's not really a Flutter problem — it's the whole pattern in miniature. The agent wasn't "wrong" that curl can make a GET request. It optimized for "this pattern appears constantly in training data" over "this is the idiomatic way to do it in the language I'm currently writing." Bash and curl show up in approximately every tutorial, README, and Stack Overflow answer ever written. package:http

shows up in Dart-specific docs. Given no other constraint, the agent reached for the statistically dominant pattern, not the contextually correct one.

The seven gaps below are the same failure mode, just less obvious than "shells out to curl." Here's what we found, with real code examples and the fixes that work.

The Problem: Agents recalculate the same values across multiple locations instead of maintaining one source of truth.

Imagine a checkout flow where the cart total is computed three separate ways:

(items.sum + tax) - discount

items.sum - discount + tax

(items.sum - discount) * (1 + taxRate)

Different calculations. Same semantic meaning. One will break first.

The Fix: Derive values once in the state layer using streams. Let all widgets read from that single source:

// Instead of computing at call sites:
final total = items.fold(0, (sum, item) => sum + item.price);

// Compute once, derive everywhere:
final totalStream = itemsStream.map((items) {
  final subtotal = items.fold(0, (sum, item) => sum + item.price);
  final tax = subtotal * taxRate;
  final withDiscount = subtotal - appliedDiscount;
  return withDiscount + tax;
});

The principle: if it can be computed from state, it is not state.

Every recomputation is a sync bug waiting to happen.

The Problem: "It ships the feature and stops. No widget tests, no goldens, no benchmark."

Agents don't write tests because they can't run them in isolation. They generate code, you integrate it, and only then do you see:

Tests become feedback loops the agent can iterate against. Golden files provide visual regression detection—catching overflow, dark-mode contrast failures, and text truncation at 200% scale automatically.

The Fix: Write tests first, then tests become the specification:

group('SearchPage', () {
  testWidgets('shows spinner while ', (tester) async {
    await tester.pumpWidget(SearchPage(state: const SearchState.()));
    expect(find.byType(CircularProgressIndicator), findsOneWidget);
  });

  testWidgets('shows error and allows retry', (tester) async {
    final state = SearchState.failed(error: AppError.network);
    await tester.pumpWidget(SearchPage(state: state));
    expect(find.text('Network error'), findsOneWidget);
    await tester.tap(find.byIcon(Icons.refresh));
    expect(find.byType(CircularProgressIndicator), findsOneWidget);
  });

  testWidgets('respects 200% text scale', (tester) async {
    tester.binding.window.textScaleFactorTestValue = 2.0;
    addTearDown(tester.binding.window.clearTextScaleFactorTestValue);
    await tester.pumpWidget(SearchPage(state: const SearchState.idle()));
    // No overflows
    expect(find.byType(OverflowBox), findsNothing);
  });
});

The Problem: Agents use loose parallel boolean fields and branch over them, creating unmaintainable conditionals.

bool _is = false;
String? _error;
List<Hit> _items = [];

// This represents 8 possible combinations, 4 of which are invalid
if (_is) return CircularProgressIndicator();
if (_error != null) return Text(_error!);
if (_items.isEmpty) return Text('No results');
return ListView(children: _items.map(...).toList());

The UI code doesn't express the actual state machine. It's guessing based on flag combinations. And if completes before you clear the error, the UI gets confused.

The Fix: Use sealed state hierarchies. Express all valid states explicitly:

sealed class SearchState {}
final class Idle extends SearchState {}
final class  extends SearchState {}
final class Success extends SearchState { final List<Hit> items; }
final class Failed extends SearchState { final AppError error; }
final class Empty extends SearchState {}

// The UI is now explicit:
return switch (state) {
  Idle() => SearchHint(),
  () => AppSpinner(),
  Failed(:final error) => AppErrorView(error),
  Empty() => EmptyResultsPlaceholder(),
  Success(:final items) => SearchResultsList(items),
};

No invalid combinations. No pyramids of if-statements. The compiler enforces exhaustiveness.

Quick question: have you caught the flag-pyramid anti-pattern in a PR before? That's usually the first place AI-generated Flutter code goes wrong — four more gaps below.

The Problem: Agents create constant files and hardcode values at every call site instead of using ThemeData

.

// constants.dart
const primaryColor = Color(0xFF1F77D2);
const lightGrey = Color(0xFFF5F5F5);
const baseTextSize = 16.0;

// Usage everywhere:
Text('Hello', style: TextStyle(color: Color(0xFF1F77D2), fontSize: 16))
Container(color: Color(0xFFF5F5F5))

This cascades into four problems:

color: isDark ? darkGrey : lightGrey

at every call siteTheme.of(context)

The Fix: Express the design system once in ThemeData

, TextTheme

, and component themes:

MaterialApp(
  theme: ThemeData(
    colorScheme: ColorScheme.fromSeed(seedColor: Color(0xFF1F77D2)),
    textTheme: TextTheme(
      bodyMedium: TextStyle(fontSize: 16),
      titleLarge: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
    ),
  ),
  darkTheme: ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: Color(0xFF1F77D2),
      brightness: Brightness.dark,
    ),
  ),
);

// Usage: widgets ask the theme
Text('Hello', style: Theme.of(context).textTheme.bodyMedium)
Container(color: Theme.of(context).colorScheme.surface)

Rebrand once. Every widget updates automatically.

The Problem: 40 defensible screens that collectively feel like "40 tutorials stitched together."

A user loads the home screen and sees CircularProgressIndicator

. They navigate to the feed and see a custom shimmer skeleton. They check their profile and see a branded animation.

Each is correct. Together, they're incoherent.

Same pattern with errors:

SnackBar

AlertDialog

And destructive actions:

AlertDialog

The Invisible Failures:

The agent can't see these because it doesn't render frames:

The Fix: Decide your interaction vocabulary once. How do / errors / confirmations / validation feedback / empty states work? Implement that pattern everywhere. Test on real devices:

// Define once:
class AppOverlay extends StatelessWidget {
  const AppOverlay({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) => Center(
    child: CircularProgressIndicator(
      valueColor: AlwaysStoppedAnimation(
        Theme.of(context).colorScheme.primary,
      ),
    ),
  );
}

class AppErrorView extends StatelessWidget {
  final AppError error;
  final VoidCallback? onRetry;

  const AppErrorView({required this.error, this.onRetry, Key? key})
    : super(key: key);

  @override
  Widget build(BuildContext context) => Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(Icons.error_outline, size: 56, color: Colors.red),
        SizedBox(height: 16),
        Text(error.displayMessage, textAlign: TextAlign.center),
        if (onRetry != null) ...[
          SizedBox(height: 24),
          ElevatedButton(onPressed: onRetry, child: Text('Retry')),
        ]
      ],
    ),
  );
}

// Use everywhere: home, feed, profile
return switch (state) {
  () => AppOverlay(),
  Failed(:final error) => AppErrorView(error: error, onRetry: onRetry),
  // ...
};

The Problem: Agents bake English assumptions into the code.

Text('$count items');  // Output: "1 items" (grammatically wrong)

Text('$count ${count == 1 ? "item" : "items"}');
// Wrong for Russian (4 plural forms), Arabic (6 forms), Polish (3 forms)

Text('Hello, ' + name + '!');  // English word order locked in

Text('${d.day}.${d.month}.${d.year}');  // US date format; wrong for most of world

Text('\$${amount.toStringAsFixed(2)}');
// $ before number (US), but euros go after: "10 €", Arabic: "﷼ ١٠"

None of these are internationalization—they're English with variables.

The Fix: Use ARB files with gen_l10n

and intl

package:

arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
// lib/l10n/app_en.arb
{
  "unreadMessages": "{count, plural, =0{No new messages} one{{count} message} other{{count} messages}}"
}

// lib/l10n/app_ru.arb
{
  "unreadMessages": "{count, plural, =0{Нет новых сообщений} =1{{count} сообщение} few{{count} сообщения} other{{count} сообщений}}"
}
Text(l10n.unreadMessages(count))

// Use NumberFormat for currency
Text(NumberFormat.currency(locale: 'en_US').format(amount))

// Use DateFormat for dates
Text(DateFormat.yMd(l10n.localeName).format(date))

// Use EdgeInsetsDirectional for RTL
Padding(
  padding: EdgeInsetsDirectional.only(start: 16),
  child: Text('Hello'),
)

One source of truth for each language. Plurals, gender, word order—all handled by ICU.

The Problem: Agents flatten complexity uniformly—collapsing load-bearing logic while over-abstracting trivial code.

Over-simplified (dangerous):

try {
  await api.charge(order);
} catch (_) {
  setState(() => error = 'Payment failed');
}

This swallows three completely different failures:

All three are now identical. Retry logic is broken.

Over-abstracted (waste):

// 14-parameter widget for 5 button styles
class AppButton extends StatelessWidget {
  final String label;
  final VoidCallback onPressed;
  final Color? backgroundColor;
  final Color? foregroundColor;
  final EdgeInsets? padding;
  final double? borderRadius;
  final double? elevation;
  final bool is;
  final bool isEnabled;
  // ... 5 more parameters

  const AppButton({
    required this.label,
    required this.onPressed,
    this.backgroundColor,
    // ...
  });
}

// vs. five concrete widgets
class PrimaryButton extends StatelessWidget { ... }
class SecondaryButton extends StatelessWidget { ... }
class TertiaryButton extends StatelessWidget { ... }

The generic version is harder to use and reason about.

The Fix: Complexity is a budget. Spend it on genuinely hard domains:

Load-bearing (explicit, verbose):

Trivial (collapse aggressively):

// Explicit payment error handling
sealed class PaymentError {}
final class NetworkTimeout extends PaymentError {}
final class CardDeclined extends PaymentError { final String reason; }
final class IdempotencyConflict extends PaymentError {}

try {
  await api.charge(order, idempotencyKey: uuid);
} on PaymentError catch (e) {
  switch (e) {
    case NetworkTimeout() => _retryPayment();
    case CardDeclined(:final reason) => _showCardError(reason);
    case IdempotencyConflict() => _confirmChargeAlready();
  }
}

// Trivial UI: no abstraction needed
ListView(
  children: [
    SettingsTile('Notifications', isEnabled: notificationsOn, onChange: ...),
    SettingsTile('Dark Mode', isEnabled: darkModeOn, onChange: ...),
    SettingsTile('Analytics', isEnabled: analyticsOn, onChange: ...),
  ],
)

💡

The core principle:Complexity is a budget. Spend it on genuinely hard domains — payments, offline sync, auth, idempotency. Collapse aggressively everywhere else.

Four reasons why Flutter makes these problems louder than in other frameworks:

Theme.of(context)

here." Hardcoded colors feel fine until day 50.RaisedButton

, WillPopScope

, MaterialStateProperty

).Every one of these failures shows up in every language.

Flutter just makes them louder.

The difference is orchestration:

When AI agents operate inside a codebase with strong invariants and feedback loops, they move fast. When they create codebases from scratch without those constraints, they move fast in the wrong direction.

Familiar beats clever essentially always. And the only way that agent speed becomes an asset instead of a liability is through structural constraints decided before coding begins.

ThemeData

once, not hardcoded values at 200 call sites.Discussion: What's the #1 AI-generated code smell you've caught in review? Drop it in the comments — collecting the worst offenders for a follow-up post.

Want the full deep-dive with more examples? Read the complete article on nerdy.pro.

── more in #artificial-intelligence 4 stories · sorted by recency
── more on @flutter 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-to-review-ai-gen…] indexed:0 read:8min 2026-07-28 ·