A professional interface is a system
An isolated screen can look polished and still fall apart as the product grows. Professional interfaces need shared rules for spacing, typography, color, states, and responsive behavior.
Material 3 supplies foundations and components, but it does not make product decisions for you. Keep those decisions in the theme:
ThemeData buildTheme(ColorScheme colors) => ThemeData(
useMaterial3: true,
colorScheme: colors,
textTheme: const TextTheme(
headlineMedium: TextStyle(fontWeight: FontWeight.w700),
bodyLarge: TextStyle(fontSize: 16, height: 1.5),
),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
),
);
Avoid arbitrary colors and sizes inside widgets. A theme enables consistency, dark mode, and safer brand changes.
Constraints before coordinates
Flutter layout is constraint-driven: the parent provides limits, the child chooses a size, and the parent positions it. When a row overflows, the real issue is usually an unresolved constraint.
Row(
children: [
Expanded(child: ProductSummary(product: product)),
const SizedBox(width: 16),
FilledButton(onPressed: onEdit, child: const Text('Edit')),
],
)
Use ListView.builder for potentially large collections and reserve Column for small, bounded groups.
Forms that help people recover
Validation should explain the problem next to the field and preserve the user’s work.
TextFormField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Product name'),
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().length < 3) {
return 'Enter at least 3 characters';
}
return null;
},
)
During submission, prevent duplicate actions, expose progress, and move focus toward the first error when practical.
Responsive composition
Do not create separate applications for each width. Change composition based on content-driven breakpoints:
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
if (width >= 1024) return const DesktopDashboard();
if (width >= 600) return const TabletDashboard();
return const MobileDashboard();
}
Share smaller components and test intermediate widths, orientation, large text, and translated copy.
Required visual states
Every data-driven region needs loading, data, empty, and failure states. Actions need idle, in-progress, success, and failure feedback. A blank region is not an error state.
Accessibility from the start
Use descriptive labels, comfortable targets, sufficient contrast, and a logical focus order. Never communicate state through color alone. Semantics, tooltips, keyboard navigation, and text scaling should be verified before release.
Guided practice: turn the feature into an experience
Continue in Module 3’s sales_management. Run flutter test before editing.
Checkpoint 1 — apply theme and composition at the root
Replace lib/main.dart:
import 'package:flutter/material.dart';
import 'app_dependencies.dart';
import 'features/products/presentation/pages/products_page.dart';
void main() {
final dependencies = AppDependencies();
runApp(SalesApp(dependencies: dependencies));
}
class SalesApp extends StatelessWidget {
const SalesApp({required this.dependencies, super.key});
final AppDependencies dependencies;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Sales Management',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
),
),
home: ProductsPage(controller: dependencies.productsController),
);
}
}
The project will not compile until ProductsPage exists. That intermediate error is expected.
Checkpoint 2 — render every state
Create presentation/pages/products_page.dart:
import 'package:flutter/material.dart';
import '../../domain/entities/product.dart';
import '../controllers/products_controller.dart';
class ProductsPage extends StatefulWidget {
const ProductsPage({required this.controller, super.key});
final ProductsController controller;
@override
State<ProductsPage> createState() => _ProductsPageState();
}
class _ProductsPageState extends State<ProductsPage> {
@override
void initState() {
super.initState();
widget.controller.load();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: AnimatedBuilder(
animation: widget.controller,
builder: (context, _) => switch (widget.controller.state) {
ProductsInitial() || ProductsLoading() =>
const Center(child: CircularProgressIndicator()),
ProductsLoaded(:final items) when items.isEmpty =>
const Center(child: Text('No products available.')),
ProductsLoaded(:final items) => _ProductsContent(items: items),
ProductsFailure(:final message) => Center(
child: FilledButton(
onPressed: widget.controller.load,
child: Text('$message — Try again'),
),
),
},
),
);
}
}
The exhaustive switch makes a new state a compile-time UI decision instead of a forgotten blank area.
Checkpoint 3 — respond to component constraints
Add _ProductsContent and _ProductCard in the same file:
class _ProductsContent extends StatelessWidget {
const _ProductsContent({required this.items});
final List<Product> items;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final columns = switch (constraints.maxWidth) {
>= 1100 => 4,
>= 700 => 2,
_ => 1,
};
return GridView.builder(
padding: const EdgeInsets.all(24),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: columns == 1 ? 3 : 1.6,
),
itemCount: items.length,
itemBuilder: (context, index) => _ProductCard(product: items[index]),
);
},
);
}
}
class _ProductCard extends StatelessWidget {
const _ProductCard({required this.product});
final Product product;
@override
Widget build(BuildContext context) => Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product.name, style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
Text('\$${product.price.toStringAsFixed(2)}'),
],
),
),
);
}
Test 699, 700, 1099, and 1100 pixels, not only three famous device sizes. No transition should overflow.
Checkpoint 4 — build a form that helps users recover
Create product_form_page.dart as a StatefulWidget. Add a Form, GlobalKey<FormState>, name and price controllers, and dispose both controllers. The essential validation is:
TextFormField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Name'),
validator: (value) => value == null || value.trim().length < 3
? 'Enter at least 3 characters'
: null,
),
TextFormField(
controller: priceController,
decoration: const InputDecoration(labelText: 'Price'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (value) {
final price = double.tryParse(value ?? '');
return price == null || price < 0 ? 'Enter a valid price' : null;
},
),
Use a ListView body so the keyboard cannot make fields unreachable. In _submit, stop when !formKey.currentState!.validate() and show a SnackBar after valid input. Open the form from a temporary AppBar action.
Checkpoint 5 — prove interaction and narrow layout
Write a widget test that submits empty fields, observes both messages, fills valid data, and confirms the success feedback. Test manually with 200% text scaling, keyboard navigation, light/dark themes, long product names, empty state, and failure state.
dart format lib test
flutter analyze
flutter test
Common errors
| Symptom | Likely cause | Fix |
|---|---|---|
| unbounded viewport | scrollable inside an unconstrained Column | use Expanded or make it the body |
| overflow with long text | card forces an unsuitable size | inspect constraints and wrapping |
| controllers stay alive | dispose is missing | dispose controllers and focus nodes |
| invalid form submits | validate() result was ignored | stop submission on false |
| assistive tech announces only “button” | intent is absent from semantics | use a clear label, tooltip, or Semantics |
Independent practice: extract the product card into a public component, add user-controlled dark mode, and record screenshots at 360, 768, and 1280 pixels.
Deeper understanding: constraints and semantics
In Flutter layout, the parent sends constraints, the child chooses an allowed size, and the parent positions the result. This explains many overflows: a child attempts to preserve an intrinsic width larger than the received limit. Before adding Expanded at random, inspect who imposes the constraint and which component should yield, wrap, or scroll.
Responsiveness is not selecting three screens by width. It includes density, orientation, enlarged text, keyboard, translated content, and safe areas. Test transitions between breakpoints because failures usually appear between “official” sizes.
The semantics tree is not identical to the widget tree. Use the inspector to confirm the name, role, state, and focus order perceived by assistive technology. A visually correct interface can remain unusable without that data.
Terms to review: constraints, intrinsic size, LayoutBuilder, breakpoint, semantics, focus order, and text scaling. See the course reference.