DEV Community

Cristovão F.
Cristovão F.

Posted on

Responsive Flutter without a single package — and what building it with AI actually taught me

I built my portfolio in Flutter Web with one hard rule: no responsive package, no responsive_builder, no sizer, nothing. Just MediaQuery and LayoutBuilder, by hand. I also built most of it pairing with an AI coding agent (Claude Code). Those two choices ended up teaching me something neither would have taught me alone.

The theory, in two sentences

Responsive and adaptive aren't the same thing. Responsive means the UI adjusts its own positioning to the space it has. Adaptive means the UI picks a different layout or input method for that space — a tablet choosing a side rail over a bottom nav bar, for instance. A hand-rolled system needs to do both, and Flutter gives you exactly two primitives to work with: MediaQuery and LayoutBuilder.

They answer different questions. MediaQuery.sizeOf(context) tells you the size of the whole app window — global truth, cheap to read if you only need size (that's why sizeOf exists instead of the heavier MediaQuery.of, which subscribes to the entire MediaQueryData and rebuilds on every field, not just the one you read). LayoutBuilder tells you the constraints of the specific parent in the tree where you placed it — the true answer when a widget isn't full-window width, which in practice is most widgets past the root scaffold.

What actually got built

An enum instead of scattered breakpoint numbers:

enum ScreenSize {
  mobile, tablet, desktop, wide;

  bool get isDesktopUp => index >= ScreenSize.desktop.index;

  T pick<T>({required T mobile, T? tablet, T? desktop, T? wide}) {
    return switch (this) {
      ScreenSize.mobile => mobile,
      ScreenSize.tablet => tablet ?? mobile,
      ScreenSize.desktop => desktop ?? tablet ?? mobile,
      ScreenSize.wide => wide ?? desktop ?? tablet ?? mobile,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

pick is the whole trick. Only mobile is required — everything above inherits from the tier below unless overridden. That single method replaced every if (width > 768) ... else if (width > 1024) ... chain across the codebase with screen.pick(mobile: 16, desktop: 24).

The breakpoints themselves are just constants — tablet: 720, desktop: 1024, wide: 1440 — and a spacing scale (4, 8, 16, 24, 32, 48, 96) so no magic number ever gets typed twice.

The one non-obvious layout problem: a card grid where rows need equal height, but Wrap doesn't know what a "row" is — it just flows. The fix is building rows explicitly and wrapping each one in IntrinsicHeight with Row(crossAxisAlignment: stretch), then padding the last row with empty SizedBoxes so leftover cards don't stretch to fill space alone. It's a two-pass layout (IntrinsicHeight measures twice), which is expensive on a huge list — irrelevant here, since the content is static and rendered once.

Where the AI pairing actually helped — and where it didn't

Claude Code was fast at generating the pick-based breakpoint calls across a dozen sections once the pattern existed, and good at catching redundant const after a refactor moved code between const and non-const contexts. That's real, useful acceleration.

What it did not do reliably: notice that its own generated code was visually broken. At one point, a light-mode pass hardcoded a dark hex value straight into the hero section instead of pulling it from the palette. flutter analyze was clean. The types checked. The build succeeded. It looked completely fine — as a diff. It was only visibly wrong: dark text painted over a cream background, unreadable, in exactly the mode meant to be the polished "default" of the design system.

Static analysis has no opinion on contrast. It doesn't know what your background color is at runtime, doesn't render a frame, doesn't know "cream" and "ink" are supposed to be different things. The bug only exists as pixels — and pixels are the one artifact an AI reading a diff never sees unless someone forces the loop to include them.

The fix wasn't more prompting. It was a screenshot, taken at the actual breakpoint width, in the actual failing mode — a discipline copied straight from CI visual regression testing: cheap headless browser, fixed device metrics, before/after comparison, done automatically instead of trusted by eye. Once that step existed in the workflow, the category of bug stopped shipping.

The actual takeaway

AI is a legitimate accelerator for writing the mechanism of responsive layout — the enum, the pick calls, the constants, the boilerplate that used to be tedious to keep consistent across twelve files by hand. It is not a substitute for looking at the rendered result at the breakpoints that actually matter. flutter analyze passing means the code is valid Dart. It says nothing about whether the layout works — and neither, it turns out, does an AI's confidence that it does.

If you're pairing with an AI agent on UI work, the one habit worth keeping non-negotiable: verify with a real screenshot at real widths, every time, regardless of how clean the diff looks. That's not a limitation specific to AI-written code — it was always true of your own code too. AI just makes it easier to forget, because the diff reads so plausibly.

Top comments (0)