Module 11: Theming and Styling

Connected App Plumbing

An artist's palette filled with Flutter theme colors being applied to a mobile app canvas

🎯

Teach: How Flutter's ThemeData system lets you define your app's entire visual identity in one place. See: Light and dark themes switching in real time, styled cards with gradients and shadows. Feel: That theming is not decoration -- it is architecture. A well-themed app is easier to build and maintain.

Your App Has a Wardrobe

🎯

Teach: How ThemeData and ColorScheme.fromSeed create a coordinated visual identity from a single seed color. See: A MaterialApp with a theme that automatically generates a full color palette and makes it accessible anywhere via Theme.of(context). Feel: That theming is not tedious manual color-picking -- one seed color does most of the work.

🎙️

Imagine your app is a person getting dressed in the morning. Without theming, every widget picks its own outfit -- this button is blue, that text is 14 pixels, this card has a grey border. It works, but nothing matches. Theming is like giving your app a wardrobe with coordinated outfits. You define the colors, the fonts, and the styles once, and every widget in the app pulls from the same closet. Change the outfit, and the whole app changes with it.

In Flutter, that wardrobe is called ThemeData. You set it up once at the top of your widget tree in MaterialApp, and every Material widget in your entire app automatically respects it.

ThemeData and ColorScheme

The modern way to set up a theme starts with ColorScheme.fromSeed. You give it a single seed color, and it generates a full palette of harmonious colors:

MaterialApp(
  theme: ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: Colors.blue,
      brightness: Brightness.light,
    ),
    useMaterial3: true,
  ),
  home: MyHomePage(),
)

That single seedColor produces primary, secondary, tertiary, surface, background, error, and about 20 other coordinated colors. Material 3 does the color math for you.

Accessing Theme Values Anywhere

Here is where the magic lives. Anywhere in your widget tree, you can grab the current theme:

final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final textTheme = theme.textTheme;

Text(
  'Hello, Campbell',
  style: textTheme.headlineMedium?.copyWith(
    color: colorScheme.primary,
  ),
)

Theme.of(context) walks up the widget tree, finds the nearest Theme (usually the one in your MaterialApp), and hands you the entire ThemeData. From there, you can access colors, text styles, and component themes.

💡

Never hardcode colors or font sizes in individual widgets. Pull them from Theme.of(context) so your entire app stays consistent and changeable.


TextTheme: Your Typography System

🎯

Teach: How Material Design's named text styles (headlineLarge, bodyMedium, etc.) replace ad-hoc font sizing. See: A TextTheme configuration and widgets pulling consistent styles from Theme.of(context).textTheme. Feel: That typography becomes a system instead of a guessing game.

🎙️

Material Design defines a whole system of text styles with names like headlineLarge, bodyMedium, and labelSmall. Instead of remembering "was that heading 24 pixels bold or 22 pixels semibold?", you just say "use headlineLarge" and the theme handles the details. If you want to change every heading in your app, you change it in one place.

TextTheme provides named text styles that follow Material Design's type scale:

ThemeData(
  textTheme: TextTheme(
    headlineLarge: TextStyle(
      fontSize: 32,
      fontWeight: FontWeight.bold,
    ),
    headlineMedium: TextStyle(
      fontSize: 28,
      fontWeight: FontWeight.bold,
    ),
    bodyLarge: TextStyle(fontSize: 16),
    bodyMedium: TextStyle(fontSize: 14),
    labelMedium: TextStyle(
      fontSize: 14,
      letterSpacing: 1.2,
    ),
  ),
)

Use them everywhere instead of inline TextStyle:

Text('Welcome', style: Theme.of(context).textTheme.headlineLarge)
Text('Details here', style: Theme.of(context).textTheme.bodyMedium)

The key text styles you will use most often:

Style Name Typical Use
headlineLarge Page titles
headlineMedium Section headers
titleLarge Card titles, AppBar titles
bodyLarge Primary body text
bodyMedium Secondary body text
labelMedium Buttons, chips, tabs

Dark and Light Themes

🎯

Teach: How to define both light and dark themes and switch between them at runtime or follow the system setting. See: A ThemeToggleApp where a switch flips the entire app between light and dark mode instantly. Feel: Impressed that dark mode is nearly free when you build on a proper theme foundation.

Here is where theming really shines. You can define two complete themes and let the user switch between them -- or follow the system setting.

Defining Both Themes

MaterialApp(
  theme: ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: Colors.teal,
      brightness: Brightness.light,
    ),
    useMaterial3: true,
  ),
  darkTheme: ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: Colors.teal,
      brightness: Brightness.dark,
    ),
    useMaterial3: true,
  ),
  themeMode: ThemeMode.system,
)

ThemeMode.system follows the device setting. You can also use ThemeMode.light or ThemeMode.dark to force one.

Dynamic Theme Switching

To let the user toggle themes at runtime, make the themeMode a state variable:

class ThemeToggleApp extends StatefulWidget {
  const ThemeToggleApp({super.key});

  @override
  State<ThemeToggleApp> createState() => _ThemeToggleAppState();
}

class _ThemeToggleAppState extends State<ThemeToggleApp> {
  bool _isDarkMode = false;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.teal,
          brightness: Brightness.light,
        ),
        useMaterial3: true,
        textTheme: TextTheme(
          headlineMedium: TextStyle(fontWeight: FontWeight.bold),
        ),
      ),
      darkTheme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.teal,
          brightness: Brightness.dark,
        ),
        useMaterial3: true,
        textTheme: TextTheme(
          headlineMedium: TextStyle(fontWeight: FontWeight.bold),
        ),
      ),
      themeMode: _isDarkMode ? ThemeMode.dark : ThemeMode.light,
      home: HomeScreen(
        isDarkMode: _isDarkMode,
        onThemeToggle: (value) {
          setState(() => _isDarkMode = value);
        },
      ),
    );
  }
}
🎙️

Notice that the toggle state lives in the widget that owns the MaterialApp. That is because the theme is defined at the MaterialApp level, so the state that controls it must be at that level or above. The HomeScreen just reports the toggle back up through a callback. In the state management module, you will learn cleaner ways to do this with Provider.


BoxDecoration: Making Widgets Beautiful

🎯

Teach: How BoxDecoration lets you add gradients, borders, shadows, and rounded corners to any Container. See: Practical examples of styled cards, gradient headers, and outlined boxes. Feel: That polished UI comes from composing a few simple decoration properties.

BoxDecoration is the workhorse of visual styling in Flutter. You apply it to a Container's decoration property, and suddenly you have rounded corners, shadows, gradients, and borders.

Rounded Card with Shadow

Container(
  decoration: BoxDecoration(
    color: Theme.of(context).colorScheme.surface,
    borderRadius: BorderRadius.circular(12),
    boxShadow: [
      BoxShadow(
        color: Colors.black.withOpacity(0.1),
        blurRadius: 8,
        offset: Offset(0, 4),
      ),
    ],
  ),
  padding: EdgeInsets.all(16),
  child: Text('I look like a card!'),
)

Gradient Background

Container(
  decoration: BoxDecoration(
    gradient: LinearGradient(
      colors: [
        Theme.of(context).colorScheme.primary,
        Theme.of(context).colorScheme.tertiary,
      ],
      begin: Alignment.topLeft,
      end: Alignment.bottomRight,
    ),
    borderRadius: BorderRadius.circular(16),
  ),
  height: 150,
  child: Center(
    child: Text(
      'Welcome',
      style: TextStyle(color: Colors.white, fontSize: 24),
    ),
  ),
)

Outlined Box

Container(
  decoration: BoxDecoration(
    border: Border.all(
      color: Theme.of(context).colorScheme.outline,
      width: 2,
    ),
    borderRadius: BorderRadius.circular(8),
  ),
  padding: EdgeInsets.all(16),
  child: ListTile(
    leading: Icon(Icons.info),
    title: Text('Important Notice'),
    subtitle: Text('This box has a border but no fill'),
  ),
)

Combining Everything

The real power is that all these properties compose. You can have a container with a gradient, rounded corners, a border, AND a shadow:

Container(
  decoration: BoxDecoration(
    gradient: LinearGradient(
      colors: [Colors.blue.shade400, Colors.purple.shade400],
    ),
    borderRadius: BorderRadius.circular(20),
    border: Border.all(color: Colors.white, width: 2),
    boxShadow: [
      BoxShadow(
        color: Colors.purple.withOpacity(0.3),
        blurRadius: 12,
        offset: Offset(0, 6),
      ),
    ],
  ),
)
💡

BoxDecoration properties compose freely. Gradient + borderRadius + shadow + border = a polished component with zero external packages.

🎙️

BoxDecoration is the single widget that turns amateur-looking Flutter UIs into polished ones. New developers lay out widgets and then ask "why does this look so plain?" The answer is almost always: wrap it in a Container, add a BoxDecoration with rounded corners and a subtle shadow, and suddenly it looks like an app. Notice how all these examples pull colors from the theme — Theme.of(context).colorScheme.primary — not hardcoded values. That's the key to polish that travels. Your decorated container automatically looks right in light mode, dark mode, and any custom theme you throw at it.


There Are No Dumb Questions

🎯

Teach: Answers to common theming questions about color usage, BoxDecoration scope, custom fonts, and Card vs Container. See: Clear guidance on when to use theme colors vs hardcoded values, and how Card and BoxDecoration differ. Feel: That the edge cases are well-understood and easy to remember.

Q: Should I use Theme.of(context).colorScheme.primary or just Colors.blue?

A: Always use the theme colors. If you hardcode Colors.blue, your widget will look wrong in dark mode and will not update if you change your theme. Theme colors adapt automatically.

Q: What is the difference between colorScheme.surface and colorScheme.background?

A: In Material 3, background has been deprecated in favor of surface. Use surface for card backgrounds, dialogs, and sheets. Use surfaceVariant for differentiated surfaces like alternating list items.

Q: Can I use BoxDecoration on any widget?

A: Only on Container (via its decoration property) and DecoratedBox. You cannot apply it directly to a Text or Column. Wrap those in a Container first.

Q: What about the Card widget? Does it use BoxDecoration internally?

A: Card uses Material, which has its own elevation and shape system. If you want full control over gradients and complex decorations, use a Container with BoxDecoration instead of Card. If you just need a simple elevated surface, Card is simpler.

Q: How do I use a custom font with TextTheme?

A: Add the font files to your project, declare them in pubspec.yaml, then set fontFamily in your TextTheme or ThemeData. The google_fonts package makes this even easier by downloading fonts at runtime.

🎙️

The hardcoded-color question is one I want you to hear twice. The single biggest thing that separates a hobbyist Flutter app from a production one is that the hobbyist hardcodes Colors.blue everywhere, and the production developer uses Theme.of(context).colorScheme.primary everywhere. The cost is the same — a few more keystrokes. The payoff is enormous: dark mode works for free, rebranding takes minutes, and your widgets stay consistent across the whole app. Build the habit early. Every time you type Colors., stop and ask yourself if the theme has the color you want.


Sharpen Your Pencil: ThemeToggleApp

🎯

Teach: How to build an app with runtime theme switching using state management at the MaterialApp level. See: A working toggle that instantly flips between light and dark themes with styled components. Feel: That you can implement dark mode in your own apps right now.

Build an app that switches between light and dark themes at runtime.

  1. Create a StatefulWidget called ThemeToggleApp that returns a MaterialApp.
  2. Define light and dark ThemeData using ColorScheme.fromSeed with seedColor: Colors.teal. Add a custom TextTheme where headlineMedium is bold.
  3. Maintain a bool _isDarkMode state variable.
  4. The home screen displays:
  5. "Theme Demo" in headlineMedium style.
  6. "Current theme: Light" or "Current theme: Dark" in bodyLarge.
  7. A Card with sample content.
  8. A SwitchListTile labeled "Dark Mode" that toggles the theme.
  9. A FloatingActionButton to show themed component styling.
🎙️

This is the simplest possible theme toggle, and it's also the foundation for every dark-mode toggle in every Flutter app. Notice where the state lives — at the MaterialApp level. That matters. The theme has to be set on MaterialApp, so the widget that controls dark mode has to be at or above MaterialApp in the tree. When you tap the switch, setState flips the bool, MaterialApp rebuilds with the new theme, and everything underneath inherits the change. Every button, every card, every text style updates without a single line of per-widget code.


Sharpen Your Pencil: StyledComponents

🎯

Teach: How to compose BoxDecoration properties to build polished, theme-aware UI components. See: A gallery of styled widgets -- gradient headers, shadow cards, outlined boxes, and pill buttons. Feel: That professional-looking UI is achievable with just Container, BoxDecoration, and theme colors.

Build a scrollable screen showcasing custom-styled widgets.

  1. Create a StatelessWidget called StyledComponents inside a SingleChildScrollView.
  2. In a Column with 16px padding and spacing, build:
  3. Gradient Header Card: Container with LinearGradient from colorScheme.primary to colorScheme.tertiary, height 150, rounded corners, centered white text.
  4. Info Card Row: Three Expanded containers with shadows, each containing an icon and label.
  5. Outlined Info Box: Container with Border.all and a ListTile inside.
  6. Rounded Action Button: Container styled as a pill button with borderRadius: 30, wrapped in GestureDetector.
  7. Source ALL colors from Theme.of(context).
🎙️

The requirement that all colors come from the theme is not busywork — it's the whole point. Build this screen with theme colors, then toggle dark mode, and watch every component shift gracefully. Then imagine replacing seedColor: Colors.teal with seedColor: Colors.deepPurple — your entire gallery rebrands instantly. That's the theming discipline at work. A gradient header that pulls from colorScheme.primary to colorScheme.tertiary means whoever changes the theme later gets a thoughtfully coordinated gradient, not a clash.


Sharpen Your Pencil: ProfileCard

🎯

Teach: How to combine gradients, shadows, rounded corners, and theme-based typography into a real-world UI component. See: A polished profile card with a gradient header, avatar, contact details, and action button. Feel: Proud of building something that looks production-quality using only theming and BoxDecoration.

Build a polished profile card using theming and decorations.

  1. Center a Container (width 320) with rounded corners, shadow, and surface background.
  2. Top section: gradient background, rounded only on top, with a CircleAvatar containing a person icon.
  3. Bottom section: name in titleLarge, role in bodyMedium, a Divider, three icon+text contact rows, and a full-width ElevatedButton.
  4. Use colorScheme and textTheme throughout -- no hardcoded colors.
🎙️

This is the synthesis exercise for this module. Gradients, shadows, rounded corners, icon and text composition, and theme-based colors all in one component. If you finish it and your ProfileCard looks good on both light and dark backgrounds without any code changes when you toggle the theme, you've mastered what this module was about. One tip: the "rounded only on top" requirement is done with BorderRadius.only(topLeft: Radius.circular(...), topRight: ...). When you need asymmetric rounding, BorderRadius.only is your friend.


📝 Module Quiz

Test what you learned. 45 seconds per question. Passing score is set per module. Your best attempt is saved in your browser so you can track progress -- nothing is sent to a server.

The Theme Runs Deep

🎯

Teach: Why theming is an architectural decision, not just cosmetic, and how it pays off long-term. See: The full picture -- ThemeData, ColorScheme, TextTheme, BoxDecoration, and dark mode working together. Feel: Convinced that investing in a proper theme upfront saves hours of widget-level tweaking later.

🎙️

Theming might seem like cosmetic work, but it is really about architecture. When every widget in your app pulls its colors and text styles from the same ThemeData, you can change your entire app's look by editing one object. Dark mode comes almost free. Rebranding takes minutes instead of days. The few minutes you spend setting up a proper theme at the start of a project will save you hours of tweaking individual widgets later.

🔄

Where this fits: Theming builds on the widgets and layouts from earlier modules and gives them a consistent visual identity. Combined with navigation, you now have multi-screen apps that look polished. Next up: managing shared state across those screens with Provider.

1 / 1