Capstone: Real-World Flutter Project

Building the Core UI with Reusable Components

16 min Lesson 7 of 10

Building the Core UI with Reusable Components

A production-quality Flutter application is not a collection of one-off screens — it is a system. By defining a centralized AppTheme, extracting UI patterns into purpose-built reusable widgets, and applying responsive layout techniques consistently, you achieve a UI that is coherent, maintainable, and easy to extend. This lesson walks through each of those layers for the capstone project.

Centralizing the Design System with AppTheme

All visual decisions — colors, typography, shape, spacing — belong in one place. Create a dedicated file lib/core/theme/app_theme.dart that exposes a single AppTheme class with static ThemeData factories for light and dark modes.

lib/core/theme/app_theme.dart

import 'package:flutter/material.dart';

class AppTheme {
  AppTheme._(); // prevent instantiation

  static const Color _primaryColor = Color(0xFF2563EB);
  static const Color _secondaryColor = Color(0xFF10B981);
  static const Color _errorColor = Color(0xFFEF4444);

  static ThemeData get light => ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: _primaryColor,
          secondary: _secondaryColor,
          error: _errorColor,
          brightness: Brightness.light,
        ),
        textTheme: _buildTextTheme(Brightness.light),
        elevatedButtonTheme: _buildButtonTheme(),
        cardTheme: const CardTheme(
          elevation: 2,
          margin: EdgeInsets.all(8),
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.all(Radius.circular(12)),
          ),
        ),
        appBarTheme: const AppBarTheme(
          centerTitle: true,
          elevation: 0,
        ),
      );

  static ThemeData get dark => light.copyWith(
        colorScheme: ColorScheme.fromSeed(
          seedColor: _primaryColor,
          secondary: _secondaryColor,
          error: _errorColor,
          brightness: Brightness.dark,
        ),
        textTheme: _buildTextTheme(Brightness.dark),
      );

  static TextTheme _buildTextTheme(Brightness brightness) {
    final baseColor =
        brightness == Brightness.light ? Colors.black87 : Colors.white;
    return TextTheme(
      displayLarge: TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: baseColor),
      titleLarge: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: baseColor),
      bodyMedium: TextStyle(fontSize: 14, color: baseColor),
      labelLarge: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
    );
  }

  static ElevatedButtonThemeData _buildButtonTheme() =>
      ElevatedButtonThemeData(
        style: ElevatedButton.styleFrom(
          minimumSize: const Size(double.infinity, 48),
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(10),
          ),
        ),
      );
}

Wire AppTheme into MaterialApp once in main.dart:

Wiring AppTheme in MaterialApp

MaterialApp.router(
  title: 'Capstone App',
  theme: AppTheme.light,
  darkTheme: AppTheme.dark,
  themeMode: ThemeMode.system,
  routerConfig: appRouter,
);
Note: Never hard-code colors or font sizes inside individual widgets. Always reference Theme.of(context).colorScheme or Theme.of(context).textTheme so the entire app responds instantly to theme switches.

Building Reusable Widget Components

Identify repeating visual patterns across your screens and extract each into a dedicated StatelessWidget. Every reusable widget should accept only the minimum data it needs via constructor parameters and delegate all callbacks to the parent. This keeps components pure, predictable, and easily testable.

Common candidates in a typical capstone project include:

  • AppButton — primary action button with loading state
  • AppTextField — validated text field with consistent decoration
  • SectionHeader — bold title with an optional trailing action
  • ItemCard — card template for list or grid items
  • EmptyState — illustration + message when a list has no data
  • LoadingOverlay — centered CircularProgressIndicator

lib/shared/widgets/app_button.dart

import 'package:flutter/material.dart';

class AppButton extends StatelessWidget {
  final String label;
  final VoidCallback? onPressed;
  final bool isLoading;
  final Color? backgroundColor;

  const AppButton({
    super.key,
    required this.label,
    this.onPressed,
    this.isLoading = false,
    this.backgroundColor,
  });

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return ElevatedButton(
      onPressed: isLoading ? null : onPressed,
      style: ElevatedButton.styleFrom(
        backgroundColor: backgroundColor ?? cs.primary,
        foregroundColor: cs.onPrimary,
      ),
      child: isLoading
          ? const SizedBox(
              width: 20,
              height: 20,
              child: CircularProgressIndicator(
                strokeWidth: 2,
                color: Colors.white,
              ),
            )
          : Text(label),
    );
  }
}
Tip: Keeping isLoading as a constructor parameter — rather than internal state — means the parent fully controls the button lifecycle. This pattern works seamlessly with Provider, Riverpod, or Bloc because the parent rebuilds the button whenever the async state changes.

Responsive Layout with LayoutBuilder and MediaQuery

Screens must adapt to phone, tablet, and desktop constraints. Two tools cover most scenarios:

  • MediaQuery.of(context).size — use at the top of a screen to branch between compact and expanded layouts.
  • LayoutBuilder — use inside a widget subtree to react to the available box size, not the screen size.

Responsive grid using LayoutBuilder

class ResponsiveItemGrid extends StatelessWidget {
  final List<Item> items;
  const ResponsiveItemGrid({super.key, required this.items});

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final columns = constraints.maxWidth < 600 ? 2 : 4;
        return GridView.builder(
          padding: const EdgeInsets.all(16),
          gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: columns,
            mainAxisSpacing: 12,
            crossAxisSpacing: 12,
            childAspectRatio: 3 / 4,
          ),
          itemCount: items.length,
          itemBuilder: (context, index) => ItemCard(item: items[index]),
        );
      },
    );
  }
}

Assembling the Main Screens

With the design system and reusable widgets ready, building each main screen becomes a composition exercise. Each screen file imports the shared components, reads its data from the state layer (Provider, Riverpod, etc.), and arranges the widgets using the theme's spacing and color tokens. The screens themselves remain thin — no business logic, no raw colors, no duplicated layout code.

Warning: Resist the urge to create "super-widgets" that try to handle multiple unrelated responsibilities. A widget that renders a card, also manages network calls, and handles navigation will be impossible to reuse or test. Keep each widget focused on a single concern.

Summary

In this lesson you established the foundations of a scalable Flutter UI:

  • A centralized AppTheme that provides consistent colors, typography, and component styles across the entire app.
  • A library of reusable, stateless widgets (AppButton, AppTextField, ItemCard, EmptyState) that accept only the data they need and delegate events upward.
  • Responsive layouts built with LayoutBuilder and MediaQuery that adapt gracefully to different screen sizes.
  • Screen files that stay thin by composing reusable components rather than duplicating UI code.