{"slug": "how-to-review-ai-generated-flutter-code-before-it-breaks-production", "title": "How to Review AI-Generated Flutter Code (Before It Breaks Production)", "summary": "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.", "body_md": "Every unsupervised AI agent we've reviewed that wrote Flutter code made the same seven mistakes.\n\nThese 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.\n\nHere'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`\n\nvia `Process.run`\n\nand parse the stdout.\n\nNot `package:http`\n\n. Not `dio`\n\n. Not even `dart:io`\n\n's own `HttpClient`\n\n. A subprocess call to a CLI tool, inside a language that's had first-class HTTP clients since Dart 1.0.\n\nThat 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`\n\nshows up in Dart-specific docs. Given no other constraint, the agent reached for the statistically dominant pattern, not the contextually correct one.\n\nThe 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.\n\n**The Problem:** Agents recalculate the same values across multiple locations instead of maintaining one source of truth.\n\nImagine a checkout flow where the cart total is computed three separate ways:\n\n`(items.sum + tax) - discount`\n\n`items.sum - discount + tax`\n\n`(items.sum - discount) * (1 + taxRate)`\n\nDifferent calculations. Same semantic meaning. One will break first.\n\n**The Fix:** Derive values once in the state layer using streams. Let all widgets read from that single source:\n\n```\n// Instead of computing at call sites:\nfinal total = items.fold(0, (sum, item) => sum + item.price);\n\n// Compute once, derive everywhere:\nfinal totalStream = itemsStream.map((items) {\n  final subtotal = items.fold(0, (sum, item) => sum + item.price);\n  final tax = subtotal * taxRate;\n  final withDiscount = subtotal - appliedDiscount;\n  return withDiscount + tax;\n});\n```\n\nThe principle: **if it can be computed from state, it is not state.**\n\nEvery recomputation is a sync bug waiting to happen.\n\n**The Problem:** \"It ships the feature and stops. No widget tests, no goldens, no benchmark.\"\n\nAgents don't write tests because they can't run them in isolation. They generate code, you integrate it, and only then do you see:\n\nTests 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.\n\n**The Fix:** Write tests *first*, then tests become the specification:\n\n```\ngroup('SearchPage', () {\n  testWidgets('shows spinner while loading', (tester) async {\n    await tester.pumpWidget(SearchPage(state: const SearchState.loading()));\n    expect(find.byType(CircularProgressIndicator), findsOneWidget);\n  });\n\n  testWidgets('shows error and allows retry', (tester) async {\n    final state = SearchState.failed(error: AppError.network);\n    await tester.pumpWidget(SearchPage(state: state));\n    expect(find.text('Network error'), findsOneWidget);\n    await tester.tap(find.byIcon(Icons.refresh));\n    expect(find.byType(CircularProgressIndicator), findsOneWidget);\n  });\n\n  testWidgets('respects 200% text scale', (tester) async {\n    tester.binding.window.textScaleFactorTestValue = 2.0;\n    addTearDown(tester.binding.window.clearTextScaleFactorTestValue);\n    await tester.pumpWidget(SearchPage(state: const SearchState.idle()));\n    // No overflows\n    expect(find.byType(OverflowBox), findsNothing);\n  });\n});\n```\n\n**The Problem:** Agents use loose parallel boolean fields and branch over them, creating unmaintainable conditionals.\n\n```\nbool _isLoading = false;\nString? _error;\nList<Hit> _items = [];\n\n// This represents 8 possible combinations, 4 of which are invalid\nif (_isLoading) return CircularProgressIndicator();\nif (_error != null) return Text(_error!);\nif (_items.isEmpty) return Text('No results');\nreturn ListView(children: _items.map(...).toList());\n```\n\nThe UI code doesn't express the actual state machine. It's guessing based on flag combinations. And if loading completes *before* you clear the error, the UI gets confused.\n\n**The Fix:** Use sealed state hierarchies. Express all valid states explicitly:\n\n```\nsealed class SearchState {}\nfinal class Idle extends SearchState {}\nfinal class Loading extends SearchState {}\nfinal class Success extends SearchState { final List<Hit> items; }\nfinal class Failed extends SearchState { final AppError error; }\nfinal class Empty extends SearchState {}\n\n// The UI is now explicit:\nreturn switch (state) {\n  Idle() => SearchHint(),\n  Loading() => AppSpinner(),\n  Failed(:final error) => AppErrorView(error),\n  Empty() => EmptyResultsPlaceholder(),\n  Success(:final items) => SearchResultsList(items),\n};\n```\n\nNo invalid combinations. No pyramids of if-statements. The compiler enforces exhaustiveness.\n\n**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.\n\n**The Problem:** Agents create constant files and hardcode values at every call site instead of using `ThemeData`\n\n.\n\n``` js\n// constants.dart\nconst primaryColor = Color(0xFF1F77D2);\nconst lightGrey = Color(0xFFF5F5F5);\nconst baseTextSize = 16.0;\n\n// Usage everywhere:\nText('Hello', style: TextStyle(color: Color(0xFF1F77D2), fontSize: 16))\nContainer(color: Color(0xFFF5F5F5))\n```\n\nThis cascades into four problems:\n\n`color: isDark ? darkGrey : lightGrey`\n\nat every call site`Theme.of(context)`\n\n**The Fix:** Express the design system *once* in `ThemeData`\n\n, `TextTheme`\n\n, and component themes:\n\n```\nMaterialApp(\n  theme: ThemeData(\n    colorScheme: ColorScheme.fromSeed(seedColor: Color(0xFF1F77D2)),\n    textTheme: TextTheme(\n      bodyMedium: TextStyle(fontSize: 16),\n      titleLarge: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),\n    ),\n  ),\n  darkTheme: ThemeData(\n    colorScheme: ColorScheme.fromSeed(\n      seedColor: Color(0xFF1F77D2),\n      brightness: Brightness.dark,\n    ),\n  ),\n);\n\n// Usage: widgets ask the theme\nText('Hello', style: Theme.of(context).textTheme.bodyMedium)\nContainer(color: Theme.of(context).colorScheme.surface)\n```\n\nRebrand once. Every widget updates automatically.\n\n**The Problem:** 40 defensible screens that collectively feel like \"40 tutorials stitched together.\"\n\nA user loads the home screen and sees `CircularProgressIndicator`\n\n. They navigate to the feed and see a custom shimmer skeleton. They check their profile and see a branded loading animation.\n\nEach is correct. Together, they're incoherent.\n\nSame pattern with errors:\n\n`SnackBar`\n\n`AlertDialog`\n\nAnd destructive actions:\n\n`AlertDialog`\n\n**The Invisible Failures:**\n\nThe agent can't see these because it doesn't render frames:\n\n**The Fix:** Decide your interaction vocabulary **once**. How do loading / errors / confirmations / validation feedback / empty states work? Implement that pattern everywhere. Test on real devices:\n\n``` js\n// Define once:\nclass AppLoadingOverlay extends StatelessWidget {\n  const AppLoadingOverlay({Key? key}) : super(key: key);\n\n  @override\n  Widget build(BuildContext context) => Center(\n    child: CircularProgressIndicator(\n      valueColor: AlwaysStoppedAnimation(\n        Theme.of(context).colorScheme.primary,\n      ),\n    ),\n  );\n}\n\nclass AppErrorView extends StatelessWidget {\n  final AppError error;\n  final VoidCallback? onRetry;\n\n  const AppErrorView({required this.error, this.onRetry, Key? key})\n    : super(key: key);\n\n  @override\n  Widget build(BuildContext context) => Center(\n    child: Column(\n      mainAxisAlignment: MainAxisAlignment.center,\n      children: [\n        Icon(Icons.error_outline, size: 56, color: Colors.red),\n        SizedBox(height: 16),\n        Text(error.displayMessage, textAlign: TextAlign.center),\n        if (onRetry != null) ...[\n          SizedBox(height: 24),\n          ElevatedButton(onPressed: onRetry, child: Text('Retry')),\n        ]\n      ],\n    ),\n  );\n}\n\n// Use everywhere: home, feed, profile\nreturn switch (state) {\n  Loading() => AppLoadingOverlay(),\n  Failed(:final error) => AppErrorView(error: error, onRetry: onRetry),\n  // ...\n};\n```\n\n**The Problem:** Agents bake English assumptions into the code.\n\n```\nText('$count items');  // Output: \"1 items\" (grammatically wrong)\n\nText('$count ${count == 1 ? \"item\" : \"items\"}');\n// Wrong for Russian (4 plural forms), Arabic (6 forms), Polish (3 forms)\n\nText('Hello, ' + name + '!');  // English word order locked in\n\nText('${d.day}.${d.month}.${d.year}');  // US date format; wrong for most of world\n\nText('\\$${amount.toStringAsFixed(2)}');\n// $ before number (US), but euros go after: \"10 €\", Arabic: \"﷼ ١٠\"\n```\n\nNone of these are internationalization—they're English with variables.\n\n**The Fix:** Use ARB files with `gen_l10n`\n\nand `intl`\n\npackage:\n\n```\n# l10n.yaml\narb-dir: lib/l10n\ntemplate-arb-file: app_en.arb\noutput-localization-file: app_localizations.dart\n// lib/l10n/app_en.arb\n{\n  \"unreadMessages\": \"{count, plural, =0{No new messages} one{{count} message} other{{count} messages}}\"\n}\n\n// lib/l10n/app_ru.arb\n{\n  \"unreadMessages\": \"{count, plural, =0{Нет новых сообщений} =1{{count} сообщение} few{{count} сообщения} other{{count} сообщений}}\"\n}\nText(l10n.unreadMessages(count))\n\n// Use NumberFormat for currency\nText(NumberFormat.currency(locale: 'en_US').format(amount))\n\n// Use DateFormat for dates\nText(DateFormat.yMd(l10n.localeName).format(date))\n\n// Use EdgeInsetsDirectional for RTL\nPadding(\n  padding: EdgeInsetsDirectional.only(start: 16),\n  child: Text('Hello'),\n)\n```\n\nOne source of truth for each language. Plurals, gender, word order—all handled by ICU.\n\n**The Problem:** Agents flatten complexity uniformly—collapsing load-bearing logic while over-abstracting trivial code.\n\n**Over-simplified (dangerous):**\n\n``` js\ntry {\n  await api.charge(order);\n} catch (_) {\n  setState(() => error = 'Payment failed');\n}\n```\n\nThis swallows three completely different failures:\n\nAll three are now identical. Retry logic is broken.\n\n**Over-abstracted (waste):**\n\n```\n// 14-parameter widget for 5 button styles\nclass AppButton extends StatelessWidget {\n  final String label;\n  final VoidCallback onPressed;\n  final Color? backgroundColor;\n  final Color? foregroundColor;\n  final EdgeInsets? padding;\n  final double? borderRadius;\n  final double? elevation;\n  final bool isLoading;\n  final bool isEnabled;\n  // ... 5 more parameters\n\n  const AppButton({\n    required this.label,\n    required this.onPressed,\n    this.backgroundColor,\n    // ...\n  });\n}\n\n// vs. five concrete widgets\nclass PrimaryButton extends StatelessWidget { ... }\nclass SecondaryButton extends StatelessWidget { ... }\nclass TertiaryButton extends StatelessWidget { ... }\n```\n\nThe generic version is harder to use and reason about.\n\n**The Fix:** Complexity is a budget. Spend it on genuinely hard domains:\n\n**Load-bearing (explicit, verbose):**\n\n**Trivial (collapse aggressively):**\n\n```\n// Explicit payment error handling\nsealed class PaymentError {}\nfinal class NetworkTimeout extends PaymentError {}\nfinal class CardDeclined extends PaymentError { final String reason; }\nfinal class IdempotencyConflict extends PaymentError {}\n\ntry {\n  await api.charge(order, idempotencyKey: uuid);\n} on PaymentError catch (e) {\n  switch (e) {\n    case NetworkTimeout() => _retryPayment();\n    case CardDeclined(:final reason) => _showCardError(reason);\n    case IdempotencyConflict() => _confirmChargeAlready();\n  }\n}\n\n// Trivial UI: no abstraction needed\nListView(\n  children: [\n    SettingsTile('Notifications', isEnabled: notificationsOn, onChange: ...),\n    SettingsTile('Dark Mode', isEnabled: darkModeOn, onChange: ...),\n    SettingsTile('Analytics', isEnabled: analyticsOn, onChange: ...),\n  ],\n)\n```\n\n💡\n\nThe core principle:Complexity is a budget. Spend it on genuinely hard domains — payments, offline sync, auth, idempotency. Collapse aggressively everywhere else.\n\nFour reasons why Flutter makes these problems louder than in other frameworks:\n\n`Theme.of(context)`\n\nhere.\" Hardcoded colors feel fine until day 50.`RaisedButton`\n\n, `WillPopScope`\n\n, `MaterialStateProperty`\n\n).Every one of these failures shows up in every language.\n\nFlutter just makes them louder.\n\nThe difference is orchestration:\n\nWhen 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*.\n\nFamiliar 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.\n\n`ThemeData`\n\nonce, 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.\n\n**Want the full deep-dive with more examples?** Read the complete article on [nerdy.pro](https://nerdy.pro/blog/ai-agents-struggle-with-flutter).", "url": "https://wpnews.pro/news/how-to-review-ai-generated-flutter-code-before-it-breaks-production", "canonical_source": "https://dev.to/nixan/how-to-review-ai-generated-flutter-code-before-it-breaks-production-486f", "published_at": "2026-07-28 06:30:00+00:00", "updated_at": "2026-07-28 06:32:48.457082+00:00", "lang": "en", "topics": ["artificial-intelligence", "developer-tools", "large-language-models"], "entities": ["Flutter", "Dart"], "alternates": {"html": "https://wpnews.pro/news/how-to-review-ai-generated-flutter-code-before-it-breaks-production", "markdown": "https://wpnews.pro/news/how-to-review-ai-generated-flutter-code-before-it-breaks-production.md", "text": "https://wpnews.pro/news/how-to-review-ai-generated-flutter-code-before-it-breaks-production.txt", "jsonld": "https://wpnews.pro/news/how-to-review-ai-generated-flutter-code-before-it-breaks-production.jsonld"}}