Beginner
Group 1: Application Shell
Example 1: MaterialApp and runApp
Every Flutter application starts with runApp, which inflates the root widget and attaches it to the screen. MaterialApp provides Material Design scaffolding including theme, navigation, and localization support for the entire application.
graph TD
A["runApp#40;#41;"] --> B["MaterialApp"]
B --> C["ThemeData"]
B --> D["Navigator"]
B --> E["home: widget"]
style A fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
style B fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
style C fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
style D fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
style E fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px
// lib/main.dart - Entry point for every Flutter application
import 'package:flutter/material.dart'; // => Imports Material Design widgets
void main() {
// runApp takes any Widget and makes it the root of the widget tree
// Flutter calls this once when the app launches
runApp(const MyApp()); // => Starts the Flutter engine and widget tree
// => const means the widget is compile-time constant
}
// MyApp is a StatelessWidget - no mutable state needed at the root level
class MyApp extends StatelessWidget {
const MyApp({super.key}); // => super.key passes key to parent class
// => Keys help Flutter identify widgets in the tree
@override
Widget build(BuildContext context) { // => context locates this widget in the tree
return MaterialApp( // => Provides Material Design wrapping
title: 'Flutter Web Demo', // => Title shown in browser tab / OS task switcher
debugShowCheckedModeBanner: false, // => Removes the red DEBUG banner
theme: ThemeData(
colorScheme: ColorScheme.fromSeed( // => Generates a full color scheme from one seed
seedColor: Colors.blue, // => Blue generates complementary palette
),
useMaterial3: true, // => Use Material 3 design system (default in Flutter 3.16+)
),
home: const MyHomePage(), // => The first screen shown when app starts
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold( // => Scaffold provides page-level structure
body: Center(
child: Text('Hello, Flutter Web!'), // => Rendered text widget
),
);
}
}
// => Output: Browser shows "Hello, Flutter Web!" centered on screenKey Takeaway: runApp boots Flutter with a root widget; MaterialApp wraps your app with Material Design infrastructure including theming, navigation, and localization.
Why It Matters: Every Flutter Web project starts with this pattern. Understanding MaterialApp configuration - especially theme, debugShowCheckedModeBanner, and home - lets you control the global appearance and behavior of your application. In production, you will set debugShowCheckedModeBanner: false and configure a proper ThemeData with brand colors. Getting this foundation right prevents refactoring later.
Example 2: Scaffold and AppBar
Scaffold implements the basic Material Design visual layout structure - a page with optional top bar, floating button, bottom bar, side drawer, and body content. It handles safe area insets, floating action button positioning, and snackbar overlays automatically.
// lib/main.dart
import 'package:flutter/material.dart';
void main() => runApp(const MyApp()); // => Single-expression main with =>
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: PageWithScaffold(), // => Navigate directly to Scaffold demo
);
}
}
class PageWithScaffold extends StatelessWidget {
const PageWithScaffold({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
// AppBar renders the top navigation bar with title and actions
appBar: AppBar(
title: const Text('My Page'), // => Text shown in the center/left
backgroundColor: Colors.blue, // => Override bar background color
foregroundColor: Colors.white, // => Color for title and icon buttons
elevation: 2, // => Shadow depth (0 = flat, Material 3 default)
actions: [ // => Widget list placed at right of AppBar
IconButton(
icon: const Icon(Icons.search), // => Material icon widget
onPressed: () {}, // => Called when icon is tapped
// => Empty callback means no action yet
tooltip: 'Search', // => Accessibility tooltip on long press
),
],
),
// body is the main content area below the AppBar
body: const Center(
child: Text('Page content here'), // => Text centered in available space
),
// floatingActionButton hovers above the body in the bottom-right
floatingActionButton: FloatingActionButton(
onPressed: () {}, // => Tap callback
child: const Icon(Icons.add), // => Plus icon in the button
),
);
}
}
// => Output: Blue AppBar with title "My Page", search icon, body text, FAB in cornerKey Takeaway: Scaffold provides the complete Material page skeleton; use appBar, body, floatingActionButton, drawer, and bottomNavigationBar slots to compose full page layouts.
Why It Matters: Scaffold is the standard container for every screen in a production Flutter app. It handles platform edge cases like notch insets, keyboard overlap, and snackbar positioning automatically. Using Scaffold correctly means you never manually calculate safe area padding or worry about overlapping widgets - the framework handles it for you.
Example 3: Text Widget and Typography
Text renders a string with configurable style. TextStyle controls font size, weight, color, decoration, letter spacing, and more. Flutter’s Theme provides TextTheme with pre-defined styles that scale with accessibility settings.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: TextDemo()));
class TextDemo extends StatelessWidget {
const TextDemo({super.key});
@override
Widget build(BuildContext context) {
// Theme.of(context) accesses the nearest MaterialApp theme
final textTheme = Theme.of(context).textTheme; // => Retrieves theme's text styles
return Scaffold(
appBar: AppBar(title: const Text('Text Examples')),
body: Padding(
padding: const EdgeInsets.all(16), // => 16 logical pixels on all sides
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, // => Left-align children
children: [
// displayLarge is the biggest Material 3 text role (~57sp)
Text('Display Large', style: textTheme.displayLarge), // => Huge headline text
// headlineMedium is for section headings (~28sp)
Text('Headline Medium', style: textTheme.headlineMedium), // => Section heading
// bodyLarge is for readable body content (~16sp)
Text('Body Large', style: textTheme.bodyLarge), // => Standard body text
const SizedBox(height: 16), // => Vertical spacer
// Custom TextStyle overrides individual properties
const Text(
'Custom Bold Red Text',
style: TextStyle(
fontSize: 24, // => Font size in logical pixels
fontWeight: FontWeight.bold, // => Bold weight (700)
color: Colors.red, // => Text color (avoid in production - use theme)
letterSpacing: 1.5, // => Spacing between characters
decoration: TextDecoration.underline,// => Underline decoration
),
),
const SizedBox(height: 8),
// Text.rich allows mixed styles in one text widget
const Text.rich(
TextSpan(
text: 'Normal ', // => First span: regular weight
children: [
TextSpan(
text: 'Bold', // => Second span: bold
style: TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: ' and italic', // => Third span
style: TextStyle(fontStyle: FontStyle.italic),
),
],
),
),
// => Output: "Normal Bold and italic" with mixed styles in one line
],
),
),
);
}
}Key Takeaway: Use textTheme from Theme.of(context) for consistent typography that respects accessibility font scaling; use custom TextStyle only for unique one-off cases.
Why It Matters: Consistent typography is critical in production apps. Hard-coding font sizes ignores user accessibility preferences for larger text. Using textTheme roles ensures your app scales correctly when users set larger fonts in browser or OS accessibility settings, meeting WCAG 1.4.4 (Resize Text) requirements automatically.
Group 2: Layout Widgets
Example 4: Container Widget
Container is Flutter’s most versatile single-child layout widget. It combines sizing, padding, margin, decoration, alignment, and transformation into one widget. When you need to apply multiple visual properties to a child, Container is usually the right tool.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: ContainerDemo()));
class ContainerDemo extends StatelessWidget {
const ContainerDemo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Container')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center, // => Center children vertically
children: [
// Basic Container with fixed size and color
Container(
width: 100, // => 100 logical pixels wide
height: 100, // => 100 logical pixels tall
color: Colors.blue, // => Solid blue background
// Note: cannot use both color and decoration simultaneously
),
const SizedBox(height: 16),
// Container with decoration (border, borderRadius, gradient)
Container(
width: 200,
height: 80,
decoration: BoxDecoration(
color: Colors.orange, // => Background color via decoration
borderRadius: BorderRadius.circular(12), // => Rounded corners (12px radius)
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2), // => Semi-transparent shadow
blurRadius: 8, // => Shadow spread
offset: const Offset(0, 4), // => Shift shadow down by 4px
),
],
),
alignment: Alignment.center, // => Center the child within Container
child: const Text(
'Styled Box',
style: TextStyle(color: Colors.white), // => White text on orange
),
),
const SizedBox(height: 16),
// Container with margin and padding
Container(
margin: const EdgeInsets.symmetric(horizontal: 24), // => 24px left/right margin
padding: const EdgeInsets.all(16), // => 16px inner padding
color: Colors.teal.shade100, // => Light teal background
child: const Text('Margin and padding demo'), // => Text inside padding
),
// => Outer margin pushes away from edges, inner padding pushes text inward
],
),
),
);
}
}Key Takeaway: Container wraps a child with sizing, decoration, alignment, padding, and margin - use it as a versatile box model element; avoid nesting multiple Containers when one suffices.
Why It Matters: Container is one of the most-used widgets in production Flutter. Knowing when to use color vs decoration (they are mutually exclusive), and understanding the difference between margin (outer space) and padding (inner space), prevents layout bugs. Efficient use of Container also keeps your widget tree shallow, which improves rendering performance.
Example 5: Column and Row
Column arranges children vertically; Row arranges them horizontally. Both share the same axis alignment properties: mainAxisAlignment controls spacing along the layout axis, and crossAxisAlignment controls alignment on the perpendicular axis.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: ColumnRowDemo()));
class ColumnRowDemo extends StatelessWidget {
const ColumnRowDemo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Column and Row')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
// mainAxisAlignment controls vertical spacing for Column
mainAxisAlignment: MainAxisAlignment.start, // => Pack children to the top
// crossAxisAlignment controls horizontal alignment for Column
crossAxisAlignment: CrossAxisAlignment.stretch, // => Stretch children to full width
children: [
// Header Row using Row widget
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, // => Space between children
children: [
const Text('Left Item'), // => Pushed to left
const Text('Center Item'), // => Centered in space between
ElevatedButton(
onPressed: () {},
child: const Text('Right Button'), // => Pushed to right
),
],
),
// => Row children laid out horizontally with equal space between them
const SizedBox(height: 16),
// Nested Column showing vertical card layout
Container(
padding: const EdgeInsets.all(12),
color: Colors.blue.shade50,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, // => Left-align children
children: const [
Text('Card Title',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
SizedBox(height: 4), // => Small vertical gap
Text('Card subtitle text here'), // => Secondary text below title
SizedBox(height: 8),
Text('Card body content goes here and wraps automatically '
'when it reaches the column width.'), // => Multi-line body
],
),
),
// => Column arranges title, subtitle, body in vertical stack
const SizedBox(height: 16),
// Row with evenly spaced icon+label pairs
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, // => Equal space around children
children: const [
Column(children: [Icon(Icons.home), Text('Home')]),
Column(children: [Icon(Icons.search), Text('Search')]),
Column(children: [Icon(Icons.person), Text('Profile')]),
],
),
// => Three equally spaced icon+label columns within a Row
],
),
),
);
}
}Key Takeaway: Column stacks vertically, Row stacks horizontally; use mainAxisAlignment to control spacing along the axis and crossAxisAlignment to control perpendicular alignment.
Why It Matters: Column and Row are the fundamental layout primitives in Flutter. Every production screen uses them. Misunderstanding mainAxisAlignment vs crossAxisAlignment causes the most common Flutter layout bugs. Practice these properties until they are second nature - correct usage prevents overflow errors and unexpected alignment issues in responsive web layouts.
Example 6: Stack and Positioned
Stack overlays children on top of each other, with later children drawn on top. Positioned inside a Stack anchors a child to specific edges. This combination enables floating labels, badges, overlay patterns, and complex UI compositions.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: StackDemo()));
class StackDemo extends StatelessWidget {
const StackDemo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Stack and Positioned')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Stack with an image-like background and overlaid text
SizedBox(
width: 300,
height: 200,
child: Stack(
// fit: expand makes non-positioned children fill the Stack
fit: StackFit.expand, // => Non-positioned children stretch to fill
children: [
// First child (bottom): background
Container(
color: Colors.blue.shade200, // => Background layer
),
// Second child: center icon
const Center(
child: Icon(Icons.image, size: 80, color: Colors.white), // => Centered icon
),
// Positioned children anchor to Stack edges
Positioned(
top: 8, // => 8px from top of Stack
right: 8, // => 8px from right of Stack
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
color: Colors.red, // => Red badge background
child: const Text(
'NEW',
style: TextStyle(color: Colors.white, fontSize: 12),
),
),
),
// => "NEW" badge positioned at top-right corner
Positioned(
bottom: 0, // => Flush with bottom edge
left: 0, // => Flush with left edge
right: 0, // => Stretch full width
child: Container(
color: Colors.black54, // => Semi-transparent black overlay
padding: const EdgeInsets.all(8),
child: const Text(
'Image Caption',
style: TextStyle(color: Colors.white),
),
),
),
// => Caption bar at bottom of Stack
],
),
),
],
),
),
);
}
}Key Takeaway: Stack overlays widgets in Z-order; Positioned anchors children to specific Stack edges; use StackFit.expand to make the Stack fill its parent.
Why It Matters: Stack is essential for any UI requiring layers - hero images with captions, notification badges, floating tooltips, and video player controls all use Stack. Understanding StackFit prevents unexpected sizing bugs where the Stack collapses to zero size. In web applications, overlaid elements are common in cards, modals, and media components.
Example 7: Expanded and Flexible
Expanded and Flexible control how children in a Column or Row share available space. Expanded forces a child to fill all remaining space; Flexible allows a child to use up to its available share but can be smaller if content is smaller.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: ExpandedDemo()));
class ExpandedDemo extends StatelessWidget {
const ExpandedDemo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Expanded and Flexible')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Row with Expanded children dividing space by flex factor
SizedBox(
height: 60,
child: Row(
children: [
Expanded(
flex: 2, // => Takes 2 parts of available space
child: Container(
color: Colors.blue,
alignment: Alignment.center,
child: const Text('flex: 2', style: TextStyle(color: Colors.white)),
),
),
Expanded(
flex: 1, // => Takes 1 part of available space
child: Container(
color: Colors.orange,
alignment: Alignment.center,
child: const Text('flex: 1', style: TextStyle(color: Colors.white)),
),
),
],
),
),
// => Blue takes 2/3 of width, orange takes 1/3
const SizedBox(height: 16),
// Flexible vs Expanded: Flexible shrinks to content
SizedBox(
height: 60,
child: Row(
children: [
Flexible(
child: Container(
color: Colors.teal,
padding: const EdgeInsets.all(8),
child: const Text('Flexible - wraps content'),
// => Flexible shrinks to fit its content (unlike Expanded)
),
),
const SizedBox(width: 8),
Expanded(
child: Container(
color: Colors.purple,
alignment: Alignment.center,
child: const Text(
'Expanded fills rest',
style: TextStyle(color: Colors.white),
),
// => Expanded takes all remaining space after Flexible
),
),
],
),
),
// => Teal is content-sized, purple fills the rest
const SizedBox(height: 16),
// Full-height Expanded Column child
Expanded(
child: Container(
color: Colors.grey.shade200,
alignment: Alignment.center,
child: const Text('This Expanded fills all remaining height'),
// => This Container fills all vertical space not used by siblings above
),
),
],
),
),
);
}
}Key Takeaway: Expanded forces a child to fill all remaining space (overrides content size); Flexible allows a child to use up to its share but respects smaller content sizes.
Why It Matters: Expanded is how you build fluid layouts that adapt to screen size in Flutter Web. Without Expanded, Column and Row children only take their intrinsic content size, leaving empty space or causing overflow. Understanding flex ratios lets you create proportional layouts that work across different browser window sizes without hardcoding pixel values.
Example 8: Padding and SizedBox
Padding adds space inside a parent around its child. SizedBox creates a fixed-size invisible box - useful for spacing between widgets or constraining a child’s dimensions. Both are simpler and more semantic than using Container for pure spacing.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: PaddingSizedBoxDemo()));
class PaddingSizedBoxDemo extends StatelessWidget {
const PaddingSizedBoxDemo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Padding and SizedBox')),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Padding with symmetric insets
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 24, // => 24px left and right
vertical: 12, // => 12px top and bottom
),
child: Container(
color: Colors.blue.shade100,
child: const Text('Symmetric padding: 24h / 12v'),
),
),
// EdgeInsets.only for specific sides
Padding(
padding: const EdgeInsets.only(left: 48, top: 8), // => Left indent + top gap
child: const Text('Left indent 48, top 8'),
),
// EdgeInsets.fromLTRB for all four sides individually
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 32, 4), // => left, top, right, bottom
child: Container(color: Colors.orange.shade100, child: const Text('LTRB padding')),
),
// SizedBox as a gap between widgets (most common use)
const SizedBox(height: 24), // => 24px vertical gap
const Text('After 24px gap'),
const SizedBox(height: 8),
// SizedBox to constrain child to specific dimensions
SizedBox(
width: 150, // => Constrains width to exactly 150px
height: 50, // => Constrains height to exactly 50px
child: ElevatedButton(
onPressed: () {},
child: const Text('Fixed Size'), // => Button fills the SizedBox constraint
),
),
// => Button is exactly 150x50 regardless of content
// SizedBox.expand fills available space
const SizedBox(height: 16),
const SizedBox(
width: double.infinity, // => Fill all available width
child: Text('Full width text container'),
),
],
),
);
}
}Key Takeaway: Use Padding to add space around a child; use SizedBox for fixed gaps between siblings or to constrain child dimensions; both are more semantic than Container for spacing-only purposes.
Why It Matters: Consistent spacing is essential for professional UI. Using SizedBox instead of Container for gaps communicates intent clearly to other developers and tools like the Flutter inspector. Using EdgeInsets.symmetric with a design system’s spacing tokens (8, 16, 24, 32…) produces visually consistent layouts across the entire application.
Group 3: StatelessWidget and StatefulWidget
Example 9: StatelessWidget Composition
StatelessWidget is immutable - it rebuilds from scratch every time its parent rebuilds. Extract UI into custom StatelessWidget classes to make code readable, reusable, and testable. Flutter encourages deep widget trees over large build methods.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: StatelessDemo()));
// Custom StatelessWidget: a reusable product card
class ProductCard extends StatelessWidget {
// All fields are final - StatelessWidget is immutable
final String name; // => Product name to display
final double price; // => Price in currency units
final String imageUrl; // => URL for product image
final VoidCallback onTap; // => VoidCallback = void Function()
const ProductCard({
super.key,
required this.name, // => required = must be provided by caller
required this.price,
required this.imageUrl,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap, // => Calls parent-provided callback on tap
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Placeholder for image
Container(
width: 60,
height: 60,
color: Colors.grey.shade200,
child: const Icon(Icons.image), // => Image placeholder icon
),
const SizedBox(width: 12),
// Text content in a Column
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, // => Uses the immutable name field
style: const TextStyle(fontWeight: FontWeight.bold)),
Text('\$${price.toStringAsFixed(2)}', // => Formats price as "$10.00"
style: const TextStyle(color: Colors.green)),
],
),
),
const Icon(Icons.chevron_right), // => Right arrow indicating tappable
],
),
),
),
);
}
}
class StatelessDemo extends StatelessWidget {
const StatelessDemo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('StatelessWidget')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// Reuse ProductCard with different data - composition in action
ProductCard(
name: 'Laptop',
price: 999.99,
imageUrl: 'https://example.com/laptop.jpg',
onTap: () => ScaffoldMessenger.of(context) // => Shows snackbar
.showSnackBar(const SnackBar(content: Text('Laptop tapped'))),
),
ProductCard(
name: 'Mouse',
price: 29.95,
imageUrl: 'https://example.com/mouse.jpg',
onTap: () {},
),
],
),
);
}
}
// => Renders two identical-structure cards with different dataKey Takeaway: Extract reusable UI into StatelessWidget subclasses with required constructor parameters; immutability guarantees that the widget always renders the same output for the same inputs.
Why It Matters: Composing small StatelessWidgets is the Flutter way to achieve reusable, testable UI. Each component can be widget-tested in isolation. The required keyword on constructor parameters catches missing data at compile time rather than runtime. This pattern scales to large production codebases where dozens of developers build independent UI components.
Example 10: StatefulWidget and setState
StatefulWidget splits into two classes: the immutable StatefulWidget configuration and the mutable State object that persists across rebuilds. setState triggers a rebuild of the State subtree with new values.
graph TD
A["StatefulWidget<br/>immutable config"] --> B["createState#40;#41;"]
B --> C["State object<br/>persists across rebuilds"]
C --> D["initState#40;#41;<br/>called once"]
D --> E["build#40;#41;<br/>renders UI"]
E --> F["setState#40;#41;<br/>triggers rebuild"]
F --> E
style A fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
style B fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
style C fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
style D fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px
style E fill:#CA9161,stroke:#000000,color:#FFFFFF,stroke-width:2px
style F fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: CounterPage()));
// StatefulWidget is the immutable configuration part
class CounterPage extends StatefulWidget {
const CounterPage({super.key});
// createState creates the mutable State object
@override
State<CounterPage> createState() => _CounterPageState();
// => _CounterPageState is created once and persists as long as CounterPage is in the tree
}
// The State class holds mutable data and renders UI
class _CounterPageState extends State<CounterPage> {
int _count = 0; // => Mutable state variable (starts at 0)
String _message = 'Tap + to start'; // => Mutable text state
// initState is called once after the State is inserted into the tree
@override
void initState() {
super.initState(); // => Must call super first
// Initialization logic here - e.g. start timers, subscribe to streams
}
// dispose is called when the State is permanently removed from the tree
@override
void dispose() {
// Release resources here - e.g. cancel timers, close controllers
super.dispose(); // => Must call super last
}
void _increment() {
// setState schedules a rebuild by calling the callback synchronously
// then marking the widget as needing rebuild
setState(() {
_count++; // => Mutate state inside setState callback
_message = 'Count is $_count'; // => Update message with new count
// => String interpolation: $variable inserts value
});
// => After setState, build() is called again with updated _count and _message
}
void _decrement() {
setState(() {
if (_count > 0) _count--; // => Only decrement if above zero
_message = _count == 0 ? 'Tap + to start' : 'Count is $_count';
// => Ternary: condition ? valueIfTrue : valueIfFalse
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('StatefulWidget Counter')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_message, style: const TextStyle(fontSize: 20)), // => Uses current _message
const SizedBox(height: 16),
Text(
'$_count', // => Displays current _count value
style: const TextStyle(fontSize: 72, fontWeight: FontWeight.bold),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.remove, size: 32),
onPressed: _decrement, // => Reference to method (no parentheses)
),
const SizedBox(width: 32),
IconButton(
icon: const Icon(Icons.add, size: 32),
onPressed: _increment, // => Reference to _increment method
),
],
),
],
),
),
);
}
}
// => Renders counter that increments/decrements on button tapKey Takeaway: StatefulWidget + State separates immutable configuration from mutable state; always mutate state inside setState(() { ... }) to trigger rebuilds.
Why It Matters: Understanding the StatefulWidget lifecycle - initState, build, setState, dispose - is fundamental to all Flutter development. Every animation, form, list with scroll position, and tab selection uses this pattern. Failing to call super.dispose() or mutating state outside setState are the two most common bugs beginners encounter in production code.
Group 4: Buttons and User Input
Example 11: Button Variants
Flutter Material 3 provides four button styles with distinct visual hierarchy: ElevatedButton (primary filled), FilledButton (primary filled alternative), OutlinedButton (secondary), and TextButton (tertiary/low emphasis). Each maps to a specific use case in Material Design.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: ButtonDemo()));
class ButtonDemo extends StatelessWidget {
const ButtonDemo({super.key});
void _showMessage(BuildContext context, String label) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$label tapped')), // => Shows brief message at bottom
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Button Types')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// ElevatedButton - high emphasis, primary action
ElevatedButton(
onPressed: () => _showMessage(context, 'Elevated'),
// onPressed: null disables the button (shows disabled style)
child: const Text('Elevated Button'),
),
// => Filled/raised appearance, use for primary call-to-action
const SizedBox(height: 12),
// FilledButton - high emphasis alternative (Material 3)
FilledButton(
onPressed: () => _showMessage(context, 'Filled'),
child: const Text('Filled Button'),
),
// => Solid fill, slightly different from ElevatedButton
const SizedBox(height: 12),
// OutlinedButton - medium emphasis, secondary action
OutlinedButton(
onPressed: () => _showMessage(context, 'Outlined'),
child: const Text('Outlined Button'),
),
// => Border only, use for secondary options alongside primary
const SizedBox(height: 12),
// TextButton - low emphasis, tertiary action
TextButton(
onPressed: () => _showMessage(context, 'Text'),
child: const Text('Text Button'),
),
// => No border or fill, use for inline actions or dialogs
const SizedBox(height: 12),
// ElevatedButton.icon adds leading icon
ElevatedButton.icon(
onPressed: () => _showMessage(context, 'Icon'),
icon: const Icon(Icons.download), // => Icon placed before label
label: const Text('Download'), // => Button label text
),
// => Icon button pattern for action verbs
const SizedBox(height: 12),
// Disabled state: onPressed: null
const ElevatedButton(
onPressed: null, // => null disables the button
child: Text('Disabled'), // => Shows greyed-out style
),
],
),
),
);
}
}Key Takeaway: Choose button type by emphasis level - ElevatedButton/FilledButton for primary actions, OutlinedButton for secondary, TextButton for tertiary; set onPressed: null to disable.
Why It Matters: Correct button hierarchy communicates visual priority to users. Production apps use ElevatedButton or FilledButton for primary actions (Save, Submit, Purchase), OutlinedButton for secondary options (Cancel, Learn More), and TextButton for low-priority actions inside dialogs or lists. Using the wrong button type confuses users about which action is primary.
Example 12: TextField and TextEditingController
TextField is the basic text input widget. TextEditingController provides programmatic access to the field’s content - reading the value, setting it programmatically, and listening for changes. Always dispose controllers to prevent memory leaks.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: TextFieldDemo()));
class TextFieldDemo extends StatefulWidget {
const TextFieldDemo({super.key});
@override
State<TextFieldDemo> createState() => _TextFieldDemoState();
}
class _TextFieldDemoState extends State<TextFieldDemo> {
// TextEditingController manages the text field's state
final _nameController = TextEditingController(); // => Controls name input field
final _emailController = TextEditingController(); // => Controls email input field
String _submittedName = ''; // => Stores submitted name
@override
void dispose() {
// Always dispose controllers to release resources
_nameController.dispose(); // => Frees memory and removes listeners
_emailController.dispose(); // => Must dispose every controller created
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('TextField')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Basic TextField with label and hint
TextField(
controller: _nameController, // => Links controller to field
decoration: const InputDecoration(
labelText: 'Full Name', // => Floating label above field
hintText: 'Enter your full name', // => Placeholder when empty
border: OutlineInputBorder(), // => Outlined border style
prefixIcon: Icon(Icons.person), // => Icon inside left of field
),
textCapitalization: TextCapitalization.words, // => Auto-capitalize words
),
const SizedBox(height: 16),
// TextField with specific keyboard type and obscured input
TextField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'you@example.com',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email),
),
keyboardType: TextInputType.emailAddress, // => Shows email keyboard on mobile
autocorrect: false, // => Disable autocorrect for email
),
const SizedBox(height: 24),
// Read controller value on button press
ElevatedButton(
onPressed: () {
setState(() {
_submittedName = _nameController.text; // => .text reads current value
});
// Programmatically clear a field
_emailController.clear(); // => Sets field text to empty string
},
child: const Text('Submit'),
),
if (_submittedName.isNotEmpty) // => Conditionally show result
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text('Hello, $_submittedName!',
style: const TextStyle(fontSize: 18)),
),
],
),
),
);
}
}Key Takeaway: Create one TextEditingController per TextField; always dispose controllers in dispose(); read the value via .text, clear via .clear(), and set programmatically via .text = 'value'.
Why It Matters: Memory leaks from undisposed TextEditingController instances are a common production bug. Each controller allocates a ChangeNotifier with listeners - forgetting dispose() creates growing memory usage. The dispose() pattern applies to any Flutter object with a dispose() method: controllers, animation controllers, scroll controllers, and focus nodes.
Example 13: Form Validation
Form wraps TextFormField widgets and provides global validation via GlobalKey<FormState>. Calling _formKey.currentState!.validate() triggers all field validators simultaneously and returns true only if all pass. This is the standard Flutter pattern for input validation.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: FormDemo()));
class FormDemo extends StatefulWidget {
const FormDemo({super.key});
@override
State<FormDemo> createState() => _FormDemoState();
}
class _FormDemoState extends State<FormDemo> {
// GlobalKey identifies this Form and provides access to FormState
final _formKey = GlobalKey<FormState>(); // => Unique key for this Form instance
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isPasswordVisible = false; // => Tracks password visibility toggle
bool _isSubmitting = false; // => Tracks async submission state
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
// validate() calls every TextFormField's validator
// Returns true only if ALL validators return null (no error)
if (!_formKey.currentState!.validate()) return; // => Stop if any field invalid
setState(() => _isSubmitting = true); // => Show loading state
// Simulate network request
await Future.delayed(const Duration(seconds: 1)); // => Simulated async work
setState(() => _isSubmitting = false); // => Hide loading state
if (mounted) { // => Check widget still in tree
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Login successful!')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Form Validation')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey, // => Links Form to GlobalKey
child: Column(
children: [
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email),
),
keyboardType: TextInputType.emailAddress,
// validator returns null if valid, error string if invalid
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email is required'; // => Shows this text as error
}
if (!value.contains('@')) {
return 'Enter a valid email'; // => Simple email check
}
return null; // => null means field is valid
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: !_isPasswordVisible, // => Hides/shows password characters
decoration: InputDecoration(
labelText: 'Password',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(_isPasswordVisible
? Icons.visibility_off
: Icons.visibility), // => Toggle icon based on state
onPressed: () => setState(
() => _isPasswordVisible = !_isPasswordVisible), // => Toggle visibility
),
),
validator: (value) {
if (value == null || value.length < 8) {
return 'Password must be at least 8 characters'; // => Length validation
}
return null;
},
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isSubmitting ? null : _submit, // => Disable during submission
child: _isSubmitting
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Login'), // => Show spinner or text
),
),
],
),
),
),
);
}
}
// => Form validates email format and password length before submittingKey Takeaway: GlobalKey<FormState> connects Form and FormState; validate() triggers all TextFormField validators simultaneously; validators return null for valid input or an error string for invalid input.
Why It Matters: Form validation prevents invalid data from reaching your API. The mounted check before using context after an await prevents a common Flutter bug where a widget is disposed during async operations. In production login and registration screens, this exact pattern - validate, disable button during submit, check mounted after async - is the correct and safe approach.
Group 5: Images and Media
Example 14: Image Widget
Flutter provides Image.network for URLs, Image.asset for bundled files, and Image.memory for byte arrays. The fit property controls how the image scales within its bounds. Always provide error handling and loading placeholders for network images.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: ImageDemo()));
class ImageDemo extends StatelessWidget {
const ImageDemo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Image Widget')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Network image with loading and error handling
Image.network(
'https://picsum.photos/200/150', // => Random placeholder image
width: 200,
height: 150,
fit: BoxFit.cover, // => Scale to fill bounds, crop excess
// loadingBuilder shows during download
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child; // => Done loading, show image
return Container(
width: 200,
height: 150,
color: Colors.grey.shade200,
child: Center(
child: CircularProgressIndicator(
// Show download percentage if available
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null, // => null = indeterminate spinner
),
),
);
},
// errorBuilder shows when image fails to load
errorBuilder: (context, error, stackTrace) {
return Container(
width: 200,
height: 150,
color: Colors.red.shade50,
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.broken_image, color: Colors.red), // => Error icon
Text('Failed to load image'),
],
),
);
},
),
// => Shows spinner while loading, image when done, error widget if fails
const SizedBox(height: 16),
// BoxFit options comparison row
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// BoxFit.contain: scale to fit entirely within bounds
_FitDemo(label: 'contain', fit: BoxFit.contain),
// BoxFit.cover: scale to fill bounds, crops excess
_FitDemo(label: 'cover', fit: BoxFit.cover),
// BoxFit.fill: stretch to fill exactly (distorts)
_FitDemo(label: 'fill', fit: BoxFit.fill),
],
),
],
),
),
);
}
}
class _FitDemo extends StatelessWidget {
final String label;
final BoxFit fit;
const _FitDemo({required this.label, required this.fit});
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
width: 80,
height: 80,
color: Colors.grey.shade100,
child: Image.network('https://picsum.photos/120/60', fit: fit),
// => Each instance shows different BoxFit behavior
),
Text(label, style: const TextStyle(fontSize: 12)),
],
);
}
}Key Takeaway: Always provide loadingBuilder and errorBuilder for Image.network; use BoxFit.cover for hero images and BoxFit.contain for logos and icons.
Why It Matters: Production apps always need graceful image loading and error states. Users on slow connections see the loading state; users with broken CDN links see the error fallback. Missing error handling causes blank spaces or uncaught exceptions. BoxFit.cover is the standard choice for full-width banner images in web apps because it fills the space without distortion.
Group 6: Lists and Grids
Example 15: ListView and ListView.builder
ListView renders a scrollable list of children. For large or dynamic lists, ListView.builder lazily constructs only the visible items, which is critical for performance. Never use a plain ListView with hundreds of children - it builds all of them even if off-screen.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: ListViewDemo()));
// Simple data model
class Task {
final int id;
final String title;
final bool isDone;
const Task(this.id, this.title, this.isDone);
}
class ListViewDemo extends StatelessWidget {
// Generate a list of 50 tasks for demonstration
static final tasks = List.generate(
50,
(i) => Task(i, 'Task number ${i + 1}', i % 3 == 0), // => Every 3rd task is done
);
const ListViewDemo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('ListView (${tasks.length} items)')),
body: ListView.builder(
// itemCount tells ListView how many items exist
itemCount: tasks.length, // => 50 items total
// itemBuilder builds each item on demand as it scrolls into view
// Only visible items are built - efficient for large lists
itemBuilder: (context, index) {
final task = tasks[index]; // => Current task for this row
return ListTile(
// leading widget appears at left of tile
leading: CircleAvatar(
backgroundColor: task.isDone
? Colors.teal // => Green for completed tasks
: Colors.grey.shade300, // => Grey for pending tasks
child: Text('${task.id}',
style: const TextStyle(fontSize: 12)),
),
title: Text(task.title), // => Main text of the tile
subtitle: Text(task.isDone ? 'Completed' : 'Pending'), // => Secondary text
trailing: Icon( // => trailing widget appears at right
task.isDone ? Icons.check_circle : Icons.radio_button_unchecked,
color: task.isDone ? Colors.teal : Colors.grey,
),
// => Each row shows task id, name, status, and icon
);
},
// Add padding around the list content
padding: const EdgeInsets.symmetric(vertical: 8), // => 8px top/bottom padding
),
);
}
}
// => Scrollable list of 50 tasks, builds only visible items for performanceKey Takeaway: Use ListView.builder for all lists with more than a handful of items; itemBuilder is called lazily only for visible items, making it efficient even for thousands of rows.
Why It Matters: Using ListView with all children pre-built is a performance anti-pattern that causes jank and excessive memory use in production. ListView.builder is the production-safe pattern - it renders only what is visible, typically keeping 10-15 items in memory regardless of list size. In web apps with data tables or infinite scroll feeds, this pattern is non-negotiable.
Example 16: GridView
GridView.builder creates a scrollable 2D grid of items. SliverGridDelegateWithFixedCrossAxisCount specifies the number of columns, aspect ratio, and spacing. For responsive grids that adapt to browser width, SliverGridDelegateWithMaxCrossAxisExtent sets a maximum item width instead of a fixed count.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: GridViewDemo()));
class GridViewDemo extends StatelessWidget {
static final items = List.generate(
24,
(i) => {'id': i, 'color': Colors.primaries[i % Colors.primaries.length]},
);
const GridViewDemo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('GridView')),
body: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, // => 3 columns in the grid
crossAxisSpacing: 8, // => Horizontal gap between items
mainAxisSpacing: 8, // => Vertical gap between rows
childAspectRatio: 1.0, // => Width:height ratio (1.0 = square items)
),
itemCount: items.length, // => 24 total items
padding: const EdgeInsets.all(12),
itemBuilder: (context, index) {
final item = items[index];
final color = item['color'] as Color; // => Color for this card
return Container(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8), // => Rounded corners
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.star, color: Colors.white, size: 32), // => Star icon
const SizedBox(height: 4),
Text(
'Item ${(item['id'] as int) + 1}', // => Item label
style: const TextStyle(color: Colors.white),
),
],
),
// => Each cell is a colored rounded square with label
);
},
),
);
}
}
// => 3-column scrollable grid of 24 colored itemsKey Takeaway: GridView.builder with SliverGridDelegateWithFixedCrossAxisCount creates fixed-column grids; use SliverGridDelegateWithMaxCrossAxisExtent for responsive grids that adapt to available width.
Why It Matters: Product galleries, image grids, and card layouts are universal in web applications. GridView.builder provides the same lazy rendering benefits as ListView.builder - only visible cells are built. Choosing MaxCrossAxisExtent over FixedCrossAxisCount for product grids automatically adjusts columns from 2 on mobile to 4+ on wide desktop browsers without any breakpoint logic.
Group 7: Navigation
Example 17: Navigator Push and Pop
Flutter’s Navigator maintains a stack of routes. Navigator.push adds a route to the stack (navigates forward); Navigator.pop removes the current route (navigates back). MaterialPageRoute provides a platform-appropriate transition animation.
graph LR
A["Screen A<br/>Stack: #91;A#93;"] -->|"Navigator.push"| B["Screen B<br/>Stack: #91;A, B#93;"]
B -->|"Navigator.pop"| A
style A fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
style B fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: HomeScreen()));
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home Screen')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Navigate to DetailScreen, passing a product name
ElevatedButton(
onPressed: () {
// Navigator.push adds a route to the navigation stack
Navigator.push(
context,
MaterialPageRoute(
// builder receives the new context for the pushed route
builder: (context) => const DetailScreen(productName: 'Flutter Widget'),
// => Creates DetailScreen and adds it to the stack
),
);
},
child: const Text('View Product Detail'),
),
const SizedBox(height: 16),
// Navigate and wait for result
ElevatedButton(
onPressed: () async {
// push returns a Future that completes when the route is popped
final result = await Navigator.push<String>(
context,
MaterialPageRoute(
builder: (context) => const ResultScreen(),
),
);
// => result is whatever DetailScreen passed to Navigator.pop(context, value)
if (result != null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Returned: $result')),
);
}
},
child: const Text('Get Result from Screen'),
),
],
),
),
);
}
}
class DetailScreen extends StatelessWidget {
final String productName;
const DetailScreen({super.key, required this.productName});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(productName)),
// => AppBar shows back button automatically when there is a previous route
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Viewing: $productName'),
const SizedBox(height: 16),
ElevatedButton(
// Navigator.pop removes the current route from the stack
onPressed: () => Navigator.pop(context), // => Returns to HomeScreen
child: const Text('Go Back'),
),
],
),
),
);
}
}
class ResultScreen extends StatelessWidget {
const ResultScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Result Screen')),
body: Center(
child: ElevatedButton(
// Pop with a return value
onPressed: () => Navigator.pop(context, 'User approved!'), // => Returns value to caller
child: const Text('Approve and Return'),
),
),
);
}
}Key Takeaway: Navigator.push adds a route to the stack; Navigator.pop(context, [result]) removes it and optionally returns a value; always check context.mounted after await push.
Why It Matters: Navigator 1.0 is the foundation of all Flutter navigation. Even when using GoRouter in production, understanding push/pop is essential for modal dialogs, bottom sheets, and AlertDialogs which all use the Navigator stack internally. The context.mounted check prevents the “setState called after dispose” exception that crashes production apps after async navigation.
Example 18: AppBar with Navigation
AppBar adapts automatically to the navigation stack. When there is a previous route, it shows a back button automatically. You can customize leading, title, and actions to build complex navigation bars for web applications.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: AppBarDemo()));
class AppBarDemo extends StatelessWidget {
const AppBarDemo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// leading is the leftmost widget (usually back button or menu icon)
leading: IconButton(
icon: const Icon(Icons.menu), // => Hamburger menu icon
onPressed: () {}, // => Open drawer or side menu
tooltip: 'Open Navigation Menu', // => Screen reader label
),
title: const Text('My App'),
centerTitle: false, // => Left-aligned title (web convention)
// actions are icon buttons on the right side of AppBar
actions: [
IconButton(
icon: const Icon(Icons.notifications),
onPressed: () {},
badge: const Badge(label: Text('3')), // => Notification count badge
tooltip: 'Notifications',
),
IconButton(
icon: const Icon(Icons.search),
onPressed: () {},
tooltip: 'Search',
),
// PopupMenuButton for overflow actions
PopupMenuButton<String>(
onSelected: (value) {}, // => Called with selected menu item value
itemBuilder: (context) => [
const PopupMenuItem(value: 'settings', child: Text('Settings')),
const PopupMenuItem(value: 'help', child: Text('Help')),
const PopupMenuItem(value: 'logout', child: Text('Log Out')),
],
// => Three-dot menu expands with settings, help, logout options
),
],
// bottom adds a TabBar or other widget below the AppBar title
bottom: const PreferredSize(
preferredSize: Size.fromHeight(48), // => Height of the bottom area
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text('Subtitle or breadcrumb here',
style: TextStyle(color: Colors.white70)),
),
),
),
),
body: const Center(child: Text('Page content')),
);
}
}Key Takeaway: AppBar actions holds right-side icon buttons; leading overrides the auto-back-button; bottom adds a secondary row (TabBar, breadcrumbs); use PopupMenuButton for overflow menu items.
Why It Matters: The AppBar is the primary navigation chrome in most Flutter Web applications. Getting actions, tooltips, and the overflow menu right ensures accessibility compliance and intuitive UX. tooltip on every IconButton is required for screen reader support - WCAG 2.1 Success Criterion 4.1.2 requires all interactive controls to have accessible names.
Example 19: Drawer Navigation
Drawer is a side panel that slides in from the left edge. It is ideal for secondary navigation in web applications where you want persistent navigation links without using a tab bar. DrawerHeader provides the standard header area with user info.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: DrawerDemo()));
class DrawerDemo extends StatefulWidget {
const DrawerDemo({super.key});
@override
State<DrawerDemo> createState() => _DrawerDemoState();
}
class _DrawerDemoState extends State<DrawerDemo> {
int _selectedIndex = 0; // => Tracks active navigation item
final _pages = const ['Dashboard', 'Reports', 'Settings', 'Help'];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_pages[_selectedIndex]), // => Title shows current page name
// When a Drawer is present, AppBar auto-inserts a hamburger icon
),
// Attach drawer to Scaffold
drawer: Drawer(
child: ListView(
padding: EdgeInsets.zero, // => Remove default ListView padding
children: [
// DrawerHeader provides standard Material header with user info
DrawerHeader(
decoration: const BoxDecoration(color: Colors.blue), // => Blue header
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: const [
CircleAvatar(radius: 28, child: Icon(Icons.person, size: 28)),
SizedBox(height: 8),
Text('John Doe',
style: TextStyle(color: Colors.white, fontSize: 16,
fontWeight: FontWeight.bold)),
Text('john@example.com',
style: TextStyle(color: Colors.white70, fontSize: 13)),
],
),
),
// Navigation items with selected state highlighting
..._pages.asMap().entries.map((entry) {
final index = entry.key; // => Item index
final label = entry.value; // => Item label
return ListTile(
selected: index == _selectedIndex, // => Highlight active item
selectedTileColor: Colors.blue.shade50, // => Selected background
leading: Icon([Icons.dashboard, Icons.bar_chart,
Icons.settings, Icons.help][index]), // => Icon for each item
title: Text(label),
onTap: () {
setState(() => _selectedIndex = index); // => Update selected item
Navigator.pop(context); // => Close the drawer
},
);
}),
const Divider(),
ListTile(
leading: const Icon(Icons.logout),
title: const Text('Log Out'),
onTap: () {},
),
],
),
),
body: Center(
child: Text('${_pages[_selectedIndex]} content',
style: const TextStyle(fontSize: 24)),
),
);
}
}
// => Drawer slides in showing navigation items; tapping closes drawer and updates contentKey Takeaway: Scaffold with a drawer property automatically adds a hamburger button to the AppBar; close the drawer with Navigator.pop(context) after selecting a destination.
Why It Matters: Drawer navigation is common in Flutter Web admin dashboards and documentation sites. The key production pattern is calling Navigator.pop to close the drawer after selection - forgetting this leaves the drawer open on top of the new content. Tracking _selectedIndex and using ListTile.selected provides visual feedback about the current location.
Example 20: BottomNavigationBar
BottomNavigationBar provides persistent navigation across top-level sections of an application. In Flutter Web, this pattern is common in mobile-first web apps. IndexedStack keeps all page states alive while switching between tabs.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: BottomNavDemo()));
class BottomNavDemo extends StatefulWidget {
const BottomNavDemo({super.key});
@override
State<BottomNavDemo> createState() => _BottomNavDemoState();
}
class _BottomNavDemoState extends State<BottomNavDemo> {
int _currentIndex = 0; // => Active tab index
// Pages are created once and kept alive by IndexedStack
static const _pages = [
_HomePage(),
_SearchPage(),
_ProfilePage(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
// IndexedStack shows one child at a time but keeps others in memory
// This preserves scroll position, form state, etc. when switching tabs
body: IndexedStack(
index: _currentIndex, // => Which child is currently visible
children: _pages, // => All pages (only one is shown)
// => Unlike PageView, IndexedStack builds all children immediately
// => but only shows the one at index; the others remain alive in the tree
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex, // => Highlights active tab
onTap: (index) => setState(() => _currentIndex = index), // => Update active tab
// selectedItemColor: Color for active tab icon and label
selectedItemColor: Colors.blue, // => Active tab color
// unselectedItemColor: Color for inactive tabs
unselectedItemColor: Colors.grey, // => Inactive tab color
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home), // => Icon for inactive state
activeIcon: Icon(Icons.home_filled), // => Icon for active state
label: 'Home', // => Text label below icon
),
BottomNavigationBarItem(
icon: Icon(Icons.search),
label: 'Search',
),
BottomNavigationBarItem(
icon: Icon(Icons.person_outline),
activeIcon: Icon(Icons.person),
label: 'Profile',
),
],
),
);
}
}
class _HomePage extends StatelessWidget {
const _HomePage();
@override
Widget build(BuildContext context) =>
const Scaffold(appBar: AppBar(title: Text('Home')),
body: Center(child: Text('Home Screen')));
}
class _SearchPage extends StatelessWidget {
const _SearchPage();
@override
Widget build(BuildContext context) =>
const Scaffold(appBar: AppBar(title: Text('Search')),
body: Center(child: Text('Search Screen')));
}
class _ProfilePage extends StatelessWidget {
const _ProfilePage();
@override
Widget build(BuildContext context) =>
const Scaffold(appBar: AppBar(title: Text('Profile')),
body: Center(child: Text('Profile Screen')));
}
// => Three-tab app with persistent tab state via IndexedStackKey Takeaway: BottomNavigationBar provides tab navigation; use IndexedStack instead of rebuilding pages to preserve scroll positions and widget state across tab switches.
Why It Matters: Using IndexedStack vs recreating pages on tab switch is a critical production decision. Recreating pages loses scroll position, partially filled forms, and loaded data - users find this frustrating. IndexedStack preserves all widget state at the cost of memory. For most production apps, this trade-off is correct. For memory-constrained scenarios, consider PageStorageBucket with lazy rebuilding.
Group 8: Progress Indicators and Icons
Example 21: Progress Indicators
Flutter provides CircularProgressIndicator and LinearProgressIndicator for loading states. Both support determinate (known progress, 0.0-1.0) and indeterminate (unknown progress, spinning/animating) modes.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: ProgressDemo()));
class ProgressDemo extends StatefulWidget {
const ProgressDemo({super.key});
@override
State<ProgressDemo> createState() => _ProgressDemoState();
}
class _ProgressDemoState extends State<ProgressDemo> {
double _progress = 0.0; // => Download progress 0.0 to 1.0
bool _isLoading = false; // => Whether indeterminate loader is showing
Future<void> _simulateDownload() async {
setState(() => _isLoading = true); // => Show indeterminate loader first
await Future.delayed(const Duration(milliseconds: 500));
setState(() { _isLoading = false; _progress = 0.0; });
// Simulate incremental progress
for (int i = 1; i <= 10; i++) {
await Future.delayed(const Duration(milliseconds: 200));
setState(() => _progress = i / 10); // => Increment from 0.1 to 1.0
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Progress Indicators')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Indeterminate circular spinner
const Center(child: CircularProgressIndicator()), // => Spinning indefinitely
const SizedBox(height: 24),
// Determinate circular (shows percentage)
Center(
child: CircularProgressIndicator(
value: _progress, // => 0.0 to 1.0 (determinate)
backgroundColor: Colors.grey.shade200, // => Track color
color: Colors.blue, // => Progress arc color
strokeWidth: 8, // => Arc thickness
),
),
const SizedBox(height: 8),
Center(child: Text('${(_progress * 100).toInt()}%')), // => Percentage label
const SizedBox(height: 24),
// Indeterminate linear progress
const LinearProgressIndicator(), // => Animated bar scanning left-right
const SizedBox(height: 16),
// Determinate linear progress
LinearProgressIndicator(
value: _progress, // => Fill 0% to 100%
backgroundColor: Colors.grey.shade200,
valueColor: const AlwaysStoppedAnimation(Colors.teal), // => Fill color
minHeight: 8, // => Taller bar (default is ~4px)
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _simulateDownload,
child: const Text('Simulate Download'),
),
// Conditional loading overlay pattern
if (_isLoading)
Container(
color: Colors.black38, // => Semi-transparent overlay
child: const Center(child: CircularProgressIndicator()),
),
],
),
),
);
}
}Key Takeaway: Pass value: null (or omit) for indeterminate spinners; pass value: 0.0-1.0 for determinate progress bars; use AlwaysStoppedAnimation to set a custom color without an AnimationController.
Why It Matters: Progress indicators are essential for UX during any async operation - API calls, file uploads, page loads. Users need visual feedback that the app is working. Missing loading states are a top usability complaint in web apps. Using determinate progress for known operations (file uploads with progress callbacks) and indeterminate for unknown ones (API calls) sets correct user expectations.
Example 22: Icons
Flutter includes Material Symbols (via Icons class), Cupertino icons, and supports custom icon fonts. Icons scale with size and can be colored and shadowed. The Semantics widget wraps icons to provide screen reader labels.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: IconDemo()));
class IconDemo extends StatelessWidget {
const IconDemo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Icons')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Basic icon usage
const Icon(Icons.star, size: 32, color: Colors.amber), // => Gold star 32px
const SizedBox(height: 8),
// Row of themed icons
Row(
children: [
Icon(Icons.favorite, color: Theme.of(context).colorScheme.error), // => Theme error color
const SizedBox(width: 8),
Icon(Icons.check_circle, color: Theme.of(context).colorScheme.primary), // => Theme primary
const SizedBox(width: 8),
const Icon(Icons.warning, color: Colors.orange),
const SizedBox(width: 8),
const Icon(Icons.info, color: Colors.blue),
],
),
const SizedBox(height: 16),
// Icon with Semantics for accessibility
Semantics(
label: 'Verified user', // => Screen reader announces this
child: const Icon(Icons.verified_user, size: 24, color: Colors.teal),
// => Without Semantics, screen readers may say "icon" with no context
),
const SizedBox(height: 16),
// Icon inside common containers
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min, // => Shrink to content width
children: const [
Icon(Icons.info_outline, color: Colors.blue),
SizedBox(width: 8),
Text('Info message here'),
],
),
),
// => Blue info banner with icon + text
const SizedBox(height: 16),
// Size variations
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: const [
Icon(Icons.star, size: 16), // => Small icon (16px)
Icon(Icons.star, size: 24), // => Default size (24px)
Icon(Icons.star, size: 36), // => Large icon (36px)
Icon(Icons.star, size: 48), // => Extra large (48px)
],
),
],
),
),
);
}
}Key Takeaway: Use Icons.* for Material icons; wrap icons without descriptive labels in Semantics with a label for accessibility; use Theme.of(context).colorScheme colors for semantically correct icon coloring.
Why It Matters: Icon accessibility is frequently overlooked. Screen readers announce icon widgets as “icon” unless wrapped with Semantics. WCAG 2.1 requires all non-decorative images and icons to have text alternatives. Using Theme.of(context).colorScheme.error for error icons ensures they adapt automatically when the app switches between light and dark themes.
Group 9: Cards, Dialogs, and Snackbars
Example 23: Card Widget
Card provides a Material Design elevated surface with rounded corners and shadow. It is the standard container for content items - profile cards, product tiles, dashboard metrics. Use Card with InkWell to add tap ripple effects.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: CardDemo()));
class CardDemo extends StatelessWidget {
const CardDemo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Card Widget')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Basic Card with content
Card(
elevation: 2, // => Shadow depth (Material 3 default is 1)
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const CircleAvatar(
child: Icon(Icons.person), // => Person icon in avatar circle
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('Alice Johnson',
style: TextStyle(fontWeight: FontWeight.bold)),
Text('Product Manager',
style: TextStyle(color: Colors.grey)),
],
),
),
IconButton(icon: const Icon(Icons.more_vert), onPressed: () {}),
],
),
),
),
// => Card with avatar, name, role, and actions menu
const SizedBox(height: 12),
// Card with InkWell for tap ripple
Card(
child: InkWell(
// InkWell must be inside Card for ripple to be clipped by card shape
onTap: () {}, // => Shows ink ripple on tap
borderRadius: BorderRadius.circular(12), // => Match Card's corner radius
child: const Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.article, size: 40, color: Colors.blue),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Article Title',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
Text('Tap to read more...', style: TextStyle(color: Colors.grey)),
],
),
),
Icon(Icons.arrow_forward_ios, size: 16),
],
),
),
),
),
// => Tappable card with ripple effect
const SizedBox(height: 12),
// Outlined card (no shadow, has border)
Card.outlined(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
const Icon(Icons.bar_chart, size: 48, color: Colors.teal),
const SizedBox(height: 8),
const Text('42%', style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold)),
Text('Completion Rate', style: Theme.of(context).textTheme.bodyMedium),
],
),
),
),
// => Outlined metric card without shadow
],
),
),
);
}
}Key Takeaway: Card provides elevated surface; wrap content with InkWell inside Card for tap ripple effects; use Card.outlined() for flat bordered surfaces without elevation.
Why It Matters: Card is the most common content container in Material Design web apps - dashboards, lists, grids all use it. Placing InkWell inside Card (not outside) ensures the ripple effect is clipped to the card’s rounded corners. This is a common mistake that makes ripples bleed outside card boundaries, creating visually jarring interactions.
Example 24: AlertDialog and showDialog
showDialog displays a modal overlay that pauses user interaction with the rest of the screen. AlertDialog is the standard Material dialog with title, content, and action buttons. It is the correct pattern for confirmations, simple prompts, and error messages.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: DialogDemo()));
class DialogDemo extends StatelessWidget {
const DialogDemo({super.key});
// Show a confirmation dialog and return user choice
Future<bool> _showConfirmDialog(BuildContext context) async {
// showDialog returns the value passed to Navigator.pop inside the dialog
final result = await showDialog<bool>(
context: context,
// barrierDismissible: false prevents closing by tapping outside
barrierDismissible: false, // => User must tap a button to dismiss
builder: (context) => AlertDialog(
title: const Text('Delete Item?'), // => Dialog title
content: const Text( // => Dialog body content
'This action cannot be undone. The item will be permanently deleted.'),
actions: [
TextButton(
// Pop with false = user cancelled
onPressed: () => Navigator.pop(context, false), // => Return false to caller
child: const Text('Cancel'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red, // => Red confirm button
foregroundColor: Colors.white,
),
// Pop with true = user confirmed
onPressed: () => Navigator.pop(context, true), // => Return true to caller
child: const Text('Delete'),
),
],
),
);
return result ?? false; // => null (dismissed) treated as false
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Dialogs')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () async {
final confirmed = await _showConfirmDialog(context);
if (confirmed && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Item deleted')),
);
}
},
child: const Text('Delete Item'),
),
const SizedBox(height: 16),
// Simple info dialog
ElevatedButton(
onPressed: () => showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('About'),
content: const Text('Flutter Web Demo v1.0.0'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context), // => Close dialog
child: const Text('OK'),
),
],
),
),
child: const Text('Show Info Dialog'),
),
],
),
),
);
}
}
// => Confirmation dialog returns bool; caller handles the responseKey Takeaway: showDialog returns a Future<T?> that resolves to the value passed to Navigator.pop inside the dialog; always handle the null case (user dismissed without selection).
Why It Matters: Confirmation dialogs are required before destructive actions like deletion or logout. The barrierDismissible: false option forces explicit user choice for critical operations. Returning values from dialogs rather than using global state keeps the dialog logic self-contained and testable. Always handle the null case - a tapped-outside dismiss should be treated as cancellation.
Example 25: SnackBar and ScaffoldMessenger
SnackBar displays brief messages at the bottom of the screen. ScaffoldMessenger is the correct way to show snackbars in Flutter 2.0+ - it handles cases where the Scaffold is not directly in the widget tree. Always use ScaffoldMessenger.of(context) rather than Scaffold.of(context).
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: SnackBarDemo()));
class SnackBarDemo extends StatelessWidget {
const SnackBarDemo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('SnackBars')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Basic informational snackbar
ElevatedButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('File saved successfully'),
duration: Duration(seconds: 2), // => Auto-dismiss after 2 seconds
),
);
},
child: const Text('Save (basic)'),
),
const SizedBox(height: 12),
// Snackbar with action button (undo pattern)
ElevatedButton(
onPressed: () {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar() // => Dismiss any existing snackbar first
..showSnackBar(
SnackBar(
content: const Text('Item deleted'),
action: SnackBarAction(
label: 'UNDO', // => Action button label
onPressed: () {}, // => Called when UNDO is tapped
// => UNDO action restores the deleted item
),
duration: const Duration(seconds: 4), // => More time for undo action
behavior: SnackBarBehavior.floating, // => Float above bottom nav
),
);
},
child: const Text('Delete with Undo'),
),
const SizedBox(height: 12),
// Snackbar for errors (red background)
ElevatedButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Network error. Please try again.'),
backgroundColor: Colors.red.shade700, // => Error red background
action: SnackBarAction(
label: 'RETRY',
textColor: Colors.white, // => White button on red
onPressed: () {},
),
),
);
},
child: const Text('Show Error'),
),
],
),
),
);
}
}Key Takeaway: Always use ScaffoldMessenger.of(context) for snackbars; use hideCurrentSnackBar() before showSnackBar() when rapid successive messages could stack; use SnackBarAction for reversible operations.
Why It Matters: The SnackBar with Undo action is a UX best practice for reversible operations - Google’s Material Design guidelines recommend it for deletes and archives. Using SnackBarBehavior.floating prevents snackbars from obscuring the BottomNavigationBar. In production apps, dismissing the previous snackbar before showing a new one prevents a queue of messages that confuses users.
Group 10: Chips and Selection Widgets
Example 26: Chip Variants
Flutter provides several chip types for different purposes: Chip (display only), FilterChip (toggleable selection), ChoiceChip (single-select), and ActionChip (tappable action). Chips are ideal for tags, filters, and category selection in web UIs.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: ChipDemo()));
class ChipDemo extends StatefulWidget {
const ChipDemo({super.key});
@override
State<ChipDemo> createState() => _ChipDemoState();
}
class _ChipDemoState extends State<ChipDemo> {
// FilterChip multi-selection state
final Set<String> _selectedFilters = {}; // => Set of selected filter strings
// ChoiceChip single-selection state
int _selectedChoice = 0; // => Index of selected choice
final _filters = ['Flutter', 'Dart', 'Web', 'Mobile', 'Desktop'];
final _choices = ['Small', 'Medium', 'Large'];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Chips')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Display Chip with delete button (e.g. search tags)
Text('Tags:', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Wrap(
spacing: 8, // => Horizontal gap between chips
children: const [
Chip(label: Text('flutter')), // => Basic display chip
Chip(
label: Text('dart'),
avatar: Icon(Icons.code, size: 18), // => Leading icon in chip
),
Chip(
label: Text('web'),
deleteIcon: Icon(Icons.close, size: 18), // => Right-side delete icon
onDeleted: null, // => onDeleted: callback removes chip (null = shown but not functional)
),
],
),
const SizedBox(height: 16),
// FilterChip - multi-select toggle
Text('Filter by:', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: _filters.map((filter) => FilterChip(
label: Text(filter),
selected: _selectedFilters.contains(filter), // => Is this filter active?
onSelected: (selected) {
setState(() {
if (selected) {
_selectedFilters.add(filter); // => Add to selection set
} else {
_selectedFilters.remove(filter); // => Remove from selection set
}
});
},
)).toList(),
),
if (_selectedFilters.isNotEmpty)
Text('Selected: ${_selectedFilters.join(', ')}'), // => Show selection
const SizedBox(height: 16),
// ChoiceChip - single select (radio-like)
Text('Size:', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: _choices.asMap().entries.map((entry) => ChoiceChip(
label: Text(entry.value),
selected: _selectedChoice == entry.key, // => Is this the active choice?
onSelected: (_) => setState(() => _selectedChoice = entry.key),
// => Only one ChoiceChip can be selected at a time
)).toList(),
),
],
),
),
);
}
}Key Takeaway: Use FilterChip for multi-select filters, ChoiceChip for single-select options, and Chip with onDeleted for removable tags; Wrap is the ideal parent for chip groups that need to flow across lines.
Why It Matters: Chips are a space-efficient way to present multiple selection options in web UIs - search filters, category tags, size selectors. Using Wrap as the parent allows chips to reflow across multiple lines on narrow screens without overflow errors. This combination - Wrap + FilterChip/ChoiceChip - appears in nearly every production e-commerce and content-filtering interface built with Flutter Web.
Example 27: Switch, Checkbox, and Radio
Switch toggles a boolean setting. Checkbox also represents a boolean but provides a different visual pattern appropriate for forms and lists. Radio groups provide mutually exclusive selection among options. All three use onChanged callbacks that receive the new value.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: SelectionDemo()));
class SelectionDemo extends StatefulWidget {
const SelectionDemo({super.key});
@override
State<SelectionDemo> createState() => _SelectionDemoState();
}
class _SelectionDemoState extends State<SelectionDemo> {
bool _notificationsEnabled = true; // => Switch state
bool _termsAccepted = false; // => Checkbox state
String _selectedPlan = 'monthly'; // => Radio group selection
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Switch, Checkbox, Radio')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Switch: inline boolean toggle
SwitchListTile(
title: const Text('Push Notifications'), // => Setting name
subtitle: Text(_notificationsEnabled // => Dynamic subtitle
? 'You will receive push alerts'
: 'Notifications are disabled'),
value: _notificationsEnabled, // => Current switch state
onChanged: (value) => // => Called with new bool value
setState(() => _notificationsEnabled = value),
secondary: const Icon(Icons.notifications), // => Leading icon
),
// => Switch with label that reflects current state
const Divider(),
// Checkbox: in form contexts
CheckboxListTile(
title: const Text('I agree to the Terms of Service'),
value: _termsAccepted, // => Current checkbox state
onChanged: (value) =>
setState(() => _termsAccepted = value ?? false),
// => value is nullable because tristate is possible
controlAffinity: ListTileControlAffinity.leading, // => Checkbox on left
),
// => Checkbox with label for form agreement
const Divider(),
// Radio buttons: mutually exclusive selection
Text('Billing Plan:', style: Theme.of(context).textTheme.titleMedium),
RadioListTile<String>(
title: const Text('Monthly'),
subtitle: const Text('\$9.99/month'),
value: 'monthly', // => This radio's value
groupValue: _selectedPlan, // => Currently selected value
onChanged: (value) => // => Called when this is selected
setState(() => _selectedPlan = value ?? _selectedPlan),
),
RadioListTile<String>(
title: const Text('Annual'),
subtitle: const Text('\$7.99/month (20% off)'),
value: 'annual',
groupValue: _selectedPlan, // => Same groupValue links radios
onChanged: (value) =>
setState(() => _selectedPlan = value ?? _selectedPlan),
),
// => Only one Radio in the group can have groupValue == value
const Divider(),
Padding(
padding: const EdgeInsets.all(8),
child: Text(
'Plan: $_selectedPlan | Terms: $_termsAccepted',
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
],
),
),
);
}
}
// => Switch, Checkbox, and Radio show different boolean/selection patternsKey Takeaway: Switch suits inline boolean settings; Checkbox suits form agreements and multi-select lists; Radio groups enforce single selection through a shared groupValue; all use onChanged callbacks with the new value.
Why It Matters: These three selection widgets cover the majority of user preference and form input scenarios. SwitchListTile, CheckboxListTile, and RadioListTile are convenience widgets that compose the selection control with label and subtitle in a standard ListTile layout, saving significant boilerplate in settings and preference screens - which every production app has.