Building the Core UI with Reusable Components
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,
);
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),
);
}
}
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.
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.