Week 3: Material Design, Theming & Responsive Layouts

Week 2 built screens from raw layout widgets. This week wraps that in Material 3 — the component set and theming system that makes a Flutter app look like a real, polished app instead of a wireframe — and adds the responsiveness needed to run the same screen sensibly on a phone, a tablet, and the web.

Phase 2 of 8 Week 3 of 20 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Build a screen with core Material 3 components — Scaffold, AppBar, Card, ListTile
  • Generate a full light/dark color scheme from one seed color with ColorScheme.fromSeed
  • Read screen size and constraints with MediaQuery and LayoutBuilder
  • Adapt a layout so it reflows sensibly between a phone-width and a tablet/web-width screen

1. Material 3 Components

Scaffold is the standard page skeleton in Flutter — it provides the structure (app bar, body, floating action button, drawer) that almost every screen needs, and handles a surprising amount of platform-correct behavior for free.

a screen built from Material 3 components
class ProductListScreen extends StatelessWidget {
  const ProductListScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Products')),
      body: ListView(
        children: [
          Card(
            child: ListTile(
              leading: const Icon(Icons.shopping_bag),
              title: const Text('Wireless Mouse'),
              subtitle: const Text('₹1,299'),
              trailing: const Icon(Icons.chevron_right),
              onTap: () {},
            ),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {},
        child: const Icon(Icons.add),
      ),
    );
  }
}

ListTile in particular is worth knowing well — it's the standard three-slot row (leading icon, title/subtitle, trailing widget) that shows up in settings screens, contact lists, and menus across almost every real Flutter app.

2. Light/Dark Theming with ColorScheme.fromSeed

Rather than picking a dozen colors by hand, Material 3's ColorScheme.fromSeed generates an entire, accessible, harmonious color scheme — primary, secondary, surface, error, and their "on" (text/icon) pairs — from a single brand color.

main.dart — wiring up light and dark themes
MaterialApp(
  theme: ThemeData(
    colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4)),
    useMaterial3: true,
  ),
  darkTheme: ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: const Color(0xFF6750A4),
      brightness: Brightness.dark,
    ),
    useMaterial3: true,
  ),
  themeMode: ThemeMode.system, // follows the device's setting automatically
  home: const ProductListScreen(),
)

Widgets that read colors from Theme.of(context).colorScheme (which every Material widget does by default) automatically pick up whichever theme is active — there's no per-widget dark-mode branching to write.

3. MediaQuery, LayoutBuilder & Adaptive Layouts

MediaQuery reports facts about the whole screen (size, padding, text scale); LayoutBuilder reports the constraints of just the widget's own parent — the right tool depends on whether "adapt to" means the device or a specific part of the layout.

reading screen width
final screenWidth = MediaQuery.of(context).size.width;
final isTablet = screenWidth >= 600;
LayoutBuilder — reacting to a parent's actual constraints
LayoutBuilder(
  builder: (context, constraints) {
    if (constraints.maxWidth >= 600) {
      return const GridView2Column();
    }
    return const ListViewSingleColumn();
  },
)

LayoutBuilder is usually the better choice inside a nested layout — a widget that's only ever shown in a 300px-wide sidebar should adapt to that 300px, not to the phone's full 400px screen width that MediaQuery would report.

4. Hands-on Exercise

Hands-on

Rebuild the product list with Material 3 and responsive layout

Take Week 2's raw-widget screen and give it real theming and adaptive behavior.

Requirements:

  1. Rebuild the screen with Scaffold, AppBar, and a list of Card/ListTile rows for at least 6 items.
  2. Generate a full color scheme with ColorScheme.fromSeed from a color of your choice, wired to both theme and darkTheme with themeMode: ThemeMode.system.
  3. Confirm the screen looks correct in both light and dark mode by toggling your device/simulator's system theme, with no manual color overrides needed anywhere.
  4. Using LayoutBuilder, show a single-column list under 600px width and a 2-column grid at 600px and above.
  5. Test the responsive behavior by resizing a browser window (Flutter web) or a resizable desktop/emulator window, not just guessing from the code.
Hint

If your dark theme looks correct except for one or two colors that stay stubbornly light, check whether those specific widgets have a hardcoded Color(0xFF...) instead of reading from Theme.of(context).colorScheme — a hardcoded color never responds to theme changes, which is exactly why Material widgets are built to read theme colors instead.

5. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What does ColorScheme.fromSeed actually generate, beyond just one color?

A complete, harmonious set of colors derived from the seed — primary, secondary, tertiary, surface, error, and each of their paired 'on' colors for text/icons — all designed to meet accessibility contrast guidelines together, not just one accent color repeated everywhere.

Q2

Why does a widget reading Theme.of(context).colorScheme automatically support dark mode with no extra code, while a widget with a hardcoded color does not?

Theme.of(context) resolves to whichever theme (light or dark) is currently active based on themeMode and the device setting — a widget reading colors through it always gets the correct theme's colors. A hardcoded Color(...) is a fixed value with no connection to the active theme at all, so it never changes regardless of mode.

Q3

When would LayoutBuilder give a more correct answer than MediaQuery for "should this switch to a compact layout"?

When the widget in question doesn't actually occupy the full screen — inside a sidebar, a dialog, a split-view pane — MediaQuery would report the full device width, which has nothing to do with the widget's actual available space. LayoutBuilder reports the real constraints handed down from that widget's immediate parent, which is what its layout decision should actually be based on.

Q4

Why is a 600px width a reasonable, common threshold for switching between a phone-style single column and a tablet-style multi-column layout?

600px roughly separates typical phone screen widths from tablet and larger form factors in Material Design's own breakpoint guidance — it's not a hard technical requirement, just a widely-adopted convention that gives a consistent, predictable point for a layout to reflow, which is why it shows up as a common threshold across many real Flutter apps rather than each app inventing its own arbitrary number.