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.


1. Recomputing Derived State

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:

  • In the checkout page: (items.sum + tax) - discount
  • In the footer: items.sum - discount + tax
  • In the order summary: (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;
});

Enter fullscreen mode Exit fullscreen mode

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

Every recomputation is a sync bug waiting to happen.


2. Missing Tests and Benchmarks

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:

  • Buttons don't disable while submitting (no state test)
  • Error messages don't clear when the user retypes (no widget test)
  • Layout overflows at 320pt wide (no golden test)
  • Frame rate drops from 60fps to 12fps on a real device (no benchmark)

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 loading', (tester) async {
    await tester.pumpWidget(SearchPage(state: const SearchState.loading()));
    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);
  });
});

Enter fullscreen mode Exit fullscreen mode


3. Flag Pyramids Instead of State Machines

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

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

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

Enter fullscreen mode Exit fullscreen mode

The 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.

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

sealed class SearchState {}
final class Idle extends SearchState {}
final class Loading 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(),
  Loading() => AppSpinner(),
  Failed(:final error) => AppErrorView(error),
  Empty() => EmptyResultsPlaceholder(),
  Success(:final items) => SearchResultsList(items),
};

Enter fullscreen mode Exit fullscreen mode

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.


4. Design Tokens in constants.dart

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))

Enter fullscreen mode Exit fullscreen mode

This cascades into four problems:

  1. Dark mode requires 200+ ternaries: color: isDark ? darkGrey : lightGrey at every call site
  2. Design rebrands are massive diffs: Change primary color → change 300 files
  3. Accessibility breaks: Hardcoded 16pt breaks at 200% text scale
  4. Theme overrides stop working: Nothing asks Theme.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)

Enter fullscreen mode Exit fullscreen mode

Rebrand once. Every widget updates automatically.


5. Each Screen Has Its Own UX Vocabulary

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 loading animation.

Each is correct. Together, they're incoherent.

Same pattern with errors:

  • Home: SnackBar
  • Feed: inline error text
  • Profile: AlertDialog
  • Settings: modal bottom sheet

And destructive actions:

  • Delete account → AlertDialog
  • Remove item → bottom sheet confirmation
  • Leave group → inline text with undo button

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

  • Overflow at 320pt (narrow phone in landscape)
  • Tap targets under 44/48dp (fails accessibility)
  • Keyboard covering input fields (no onscreen detection)
  • Text truncation at 200% scale (readability)
  • Content under notches or behind home indicators (safe area)
  • Contrast failures in dark mode (WCAG A/AA)

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:

// Define once:
class AppLoadingOverlay extends StatelessWidget {
  const AppLoadingOverlay({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) {
  Loading() => AppLoadingOverlay(),
  Failed(:final error) => AppErrorView(error: error, onRetry: onRetry),
  // ...
};

Enter fullscreen mode Exit fullscreen mode


6. String Concatenation Instead of Localization

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: "﷼ ١٠"

Enter fullscreen mode Exit fullscreen mode

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

The Fix: Use ARB files with gen_l10n and intl package:

# l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart

Enter fullscreen mode Exit fullscreen mode

// 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} сообщений}}"
}

Enter fullscreen mode Exit fullscreen mode

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'),
)

Enter fullscreen mode Exit fullscreen mode

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


7. Uniform Simplification (in the Wrong Places)

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');
}

Enter fullscreen mode Exit fullscreen mode

This swallows three completely different failures:

  • Network timeout (retry is safe)
  • Declined card (user's problem, don't retry)
  • Idempotency conflict (retry will double-charge)

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 isLoading;
  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 { ... }

Enter fullscreen mode Exit fullscreen mode

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):

  • Payments and financial transactions
  • Offline sync and conflict resolution
  • Authentication and token refresh
  • Idempotency and deduplication
  • Error handling with recovery strategies

Trivial (collapse aggressively):

  • Five similar list tiles → one widget
  • Settings screen with 10 toggles → no abstraction
  • Button variants → concrete subclasses, not parameters
  • Utility functions → don't create BaseRepository
// 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: ...),
  ],
)

Enter fullscreen mode Exit fullscreen mode


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


Why Flutter Amplifies These Issues

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

  1. UI is code: No stylesheet layer to enforce consistency. No centralized theme your linter can verify.
  2. Everything compiles: No compiler error for "you should use Theme.of(context) here." Hardcoded colors feel fine until day 50.
  3. Agent cannot see frames: No visibility into whether the layout actually renders at 320pt, whether text is readable at 200% scale, or whether the keyboard covers the input.
  4. Outdated training data: Models confidently generate deprecated APIs (RaisedButton, WillPopScope, MaterialStateProperty).

The Core Insight: Orchestration vs. Unsupervised Agents

Every one of these failures shows up in every language.

Flutter just makes them louder.

The difference is orchestration:

  • Set invariants first: Theme, state model, localization, folder structure, error taxonomy—decide before anyone (human or agent) writes code.
  • Build feedback loops: Strict linting (no hardcoded colors), test coverage enforcement, frame-time budgets, CI checks.
  • Review decisions, not lines: Verify state consistency, theme usage, derivation sources, localization—not individual diffs.
  • Open the app: Test on real devices at 200% text scale, dark mode, throttled network, both languages.

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.


Key Takeaways

  1. Derive once, read everywhere — if it can be computed from state, it isn't state.
  2. Tests are the spec — write them first so they become a feedback loop, not an afterthought.
  3. Model states explicitly — sealed classes over boolean flags; let the compiler catch invalid combinations.
  4. Centralize the design systemThemeData once, not hardcoded values at 200 call sites.
  5. Pick one interaction vocabulary — loading, errors, confirmations should feel identical across every screen.
  6. Localize from day one — ARB files and ICU plurals, not string concatenation.
  7. Treat complexity as a budget — spend it on payments/auth/sync, collapse everything trivial.

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.