Flutter FAQ
Practical questions & answers with real code examples
100 QuestionsFlutter builds a widget tree that describes the UI declaratively. Unlike a DOM, widgets are immutable — when state changes, Flutter rebuilds the subtree and diffs against the previous element tree. The rendering engine (Skia/Impeller) draws directly to a canvas, bypassing native UI components entirely.
// Every piece of UI is a widget composed into a tree
void main() {
runApp(
MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('My App')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Text('Hello', style: TextStyle(fontSize: 24)),
SizedBox(height: 16),
Icon(Icons.flutter_dash, size: 64, color: Colors.blue),
],
),
),
),
),
);
}
Dart distinguishes nullable (String?) from non-nullable (String) types at compile time. A non-nullable variable can never be null — the compiler rejects assignments without a guaranteed value. Nullability must be explicitly handled before use.
// Non-nullable: must be initialized
String name = 'Flutter'; // OK
// String name; // compile error — must initialize
// Nullable: must check before use
String? maybeNull;
print(maybeNull?.length); // null-aware access
print(maybeNull ?? 'default'); // null-coalescing
// Late initialization (initialized before first use)
late String config;
config = loadConfig(); // assigned before reading
// Null assertion (throws if null at runtime)
String val = maybeNull!; // avoid unless certain
Hot reload injects updated source code into the running Dart VM and rebuilds the widget tree while preserving app state. Hot restart fully restarts the app, resetting all state. Use hot restart when you change main(), global variables, or initState() logic that only runs on startup.
// Hot reload: change widget appearance safely
class MyWidget extends StatelessWidget {
const MyWidget({super.key});
@override
Widget build(BuildContext context) {
// Change color here → hot reload reflects it instantly
return Container(color: Colors.blue, height: 100);
}
}
// Hot RESTART needed: changing initState logic
class _MyPageState extends State<MyPage> {
late final DatabaseService _db;
@override
void initState() {
super.initState();
// This only runs on startup — hot reload won't re-run it
_db = DatabaseService.init();
}
}
Dart’s Future<T> is equivalent to JavaScript’s Promise<T>. Both use async/await syntax. The key difference: Dart runs on a single-threaded event loop like JS, but Dart also has Isolates for true parallel execution (unlike JS web workers, Isolates share no memory).
// Basic async function
Future<String> fetchUser(int id) async {
final response = await http.get(Uri.parse('/api/users/$id'));
if (response.statusCode != 200) throw Exception('Not found');
return response.body;
}
// Error handling
Future<void> loadData() async {
try {
final data = await fetchUser(1);
print(data);
} catch (e) {
print('Error: $e');
}
}
// Run multiple in parallel (like Promise.all)
Future<void> loadAll() async {
final results = await Future.wait([
fetchUser(1),
fetchUser(2),
fetchUser(3),
]);
print(results);
}
build() is called whenever Flutter marks the element dirty. Triggers: setState(), parent rebuild passing new props, InheritedWidget dependency change, or didUpdateWidget. Flutter optimizes by comparing the element tree and only re-rendering what changed.
class CounterWidget extends StatefulWidget {
const CounterWidget({super.key});
@override
State<CounterWidget> createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
int _count = 0;
void _increment() {
// setState marks this element dirty → build() is called
setState(() {
_count++;
});
// Avoid: don't put async work inside setState callback
}
@override
Widget build(BuildContext context) {
// Called every time setState fires
print('build called: $_count');
return ElevatedButton(
onPressed: _increment,
child: Text('Count: $_count'),
);
}
}
Flutter projects separate platform code from Dart code. lib/ is where all Dart lives. android/, ios/, web/, windows/ contain native platform shells. pubspec.yaml declares dependencies and assets.
// Recommended lib/ structure:
// lib/
// main.dart — entry point
// app.dart — MaterialApp setup
// features/
// auth/
// auth_page.dart
// auth_controller.dart
// auth_service.dart
// home/
// home_page.dart
// shared/
// widgets/
// models/
// services/
// pubspec.yaml — declare assets and dependencies
// dependencies:
// flutter:
// sdk: flutter
// http: ^1.2.0
// go_router: ^13.0.0
//
// flutter:
// assets:
// - assets/images/
// - assets/fonts/
Extension methods attach new methods to any type — including built-ins like String, int, or third-party classes — without modifying or subclassing them. They’re resolved at compile time, so they work with existing instances transparently.
// Extend String with useful helpers
extension StringHelpers on String {
bool get isEmail => contains('@') && contains('.');
String get capitalize =>
isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}';
String truncate(int max) =>
length > max ? '${substring(0, max)}...' : this;
}
// Extend BuildContext for easy theme access
extension ContextX on BuildContext {
ThemeData get theme => Theme.of(this);
TextTheme get textTheme => Theme.of(this).textTheme;
ColorScheme get colors => Theme.of(this).colorScheme;
}
// Usage
void main() {
print('flutter@example.com'.isEmail); // true
print('hello world'.capitalize); // Hello world
print('A very long string'.truncate(10)); // A very lon...
}
Flutter owns its rendering pipeline end-to-end: Dart code → Flutter framework → Skia/Impeller → GPU. It draws every pixel itself. React Native bridges to native widgets (OEM components), meaning UI looks native but has bridge overhead. WebView apps render HTML — slowest and least native-feeling.
// Flutter draws everything via its own renderer
// This button looks identical on Android, iOS, web, desktop
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF7B2FBE),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: () {},
child: const Text('Custom Pixel-Perfect Button'),
)
// No platform bridge — the GPU renders this directly
// Consistent appearance and 60/120fps performance
// on all supported platforms
A StatelessWidget is immutable — its output depends only on its constructor arguments. Use it for pure presentation. A StatefulWidget owns mutable state in a companion State object that persists across rebuilds. Choose stateful when the widget needs to react to user input, animations, or async data.
// Stateless: pure function of its inputs
class UserCard extends StatelessWidget {
const UserCard({super.key, required this.name, required this.email});
final String name;
final String email;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
title: Text(name),
subtitle: Text(email),
leading: const CircleAvatar(child: Icon(Icons.person)),
),
);
}
}
// Stateful: manages its own mutable state
class LikeButton extends StatefulWidget {
const LikeButton({super.key});
@override
State<LikeButton> createState() => _LikeButtonState();
}
class _LikeButtonState extends State<LikeButton> {
bool _liked = false;
@override
Widget build(BuildContext context) {
return IconButton(
icon: Icon(_liked ? Icons.favorite : Icons.favorite_border,
color: _liked ? Colors.red : null),
onPressed: () => setState(() => _liked = !_liked),
);
}
}
setState() marks the element dirty and schedules a synchronous rebuild on the next frame. The callback should only mutate state — no async calls, no heavy computation, no side effects. Do the work first, then call setState with the result.
// ❌ Wrong: async work inside setState
void _load() {
setState(() async { // setState callback must be sync
_data = await fetch(); // this won't work correctly
});
}
// ✅ Correct: do async work first, then setState
Future<void> _load() async {
final data = await fetchData();
if (mounted) { // guard against disposed widget
setState(() {
_data = data; // only mutation here
});
}
}
// ✅ Also correct: minimal setState
void _toggle() {
final next = !_isOpen; // compute outside
setState(() => _isOpen = next); // just the assignment
}
initState runs once when the widget is inserted into the tree — use it for one-time setup. didUpdateWidget fires when the parent rebuilds with new props. dispose runs on removal — always cancel subscriptions and dispose controllers here.
class _ProfilePageState extends State<ProfilePage> {
late final StreamSubscription _sub;
late final TextEditingController _ctrl;
@override
void initState() {
super.initState(); // always call super first
_ctrl = TextEditingController();
_sub = userStream.listen(_onUser);
_loadProfile(widget.userId); // widget is accessible here
}
@override
void didUpdateWidget(ProfilePage old) {
super.didUpdateWidget(old);
if (old.userId != widget.userId) {
_loadProfile(widget.userId); // respond to prop change
}
}
@override
void dispose() {
_sub.cancel(); // cancel streams
_ctrl.dispose(); // dispose controllers
super.dispose(); // always call super last
}
@override
Widget build(BuildContext context) => Text(_ctrl.text);
}
Flutter uses constructor parameters exclusively for parent→child data flow (no JSX-style props spread). Mark required fields with required. In stateful widgets, access them inside State via widget.fieldName.
// Parent widget
class ProductList extends StatelessWidget {
const ProductList({super.key});
@override
Widget build(BuildContext context) {
return ListView(
children: products.map((p) => ProductCard(
product: p,
onTap: () => openDetail(p.id),
isFeatured: p.featured,
)).toList(),
);
}
}
// Child widget with typed parameters
class ProductCard extends StatelessWidget {
const ProductCard({
super.key,
required this.product,
required this.onTap,
this.isFeatured = false, // optional with default
});
final Product product;
final VoidCallback onTap;
final bool isFeatured;
@override
Widget build(BuildContext context) {
return Card(
color: isFeatured ? Colors.amber.shade50 : null,
child: ListTile(title: Text(product.name), onTap: onTap),
);
}
}
Flutter matches elements across rebuilds by widget type and position. Keys let you explicitly tie an element’s identity to a value. Without a key, reordering a list of stateful widgets causes their state to be reassigned incorrectly. Use ValueKey, ObjectKey, or UniqueKey based on your identifier type.
// ❌ Without keys: swapping items swaps their state incorrectly
ListView(children: items.map((i) => TodoItem(item: i)).toList());
// ✅ With ValueKey: state follows the item's identity
ListView(
children: items.map((i) => TodoItem(
key: ValueKey(i.id), // ties element to item.id
item: i,
)).toList(),
);
// GlobalKey: access state from outside the widget tree
final _formKey = GlobalKey<FormState>();
Form(
key: _formKey,
child: Column(children: [...]),
);
// Validate from a button elsewhere
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
}
},
child: const Text('Submit'),
)
Composition is the Flutter way: build complex UI by nesting small, focused widgets. Extract repeated subtrees into named widgets with typed parameters. Prefer composition over subclassing existing widgets.
// Reusable stat card widget
class StatCard extends StatelessWidget {
const StatCard({
super.key,
required this.label,
required this.value,
required this.icon,
this.color = Colors.purple,
});
final String label;
final String value;
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: color),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(color: Colors.grey)),
Text(value, style: const TextStyle(
fontSize: 20, fontWeight: FontWeight.bold)),
],
),
],
),
),
);
}
}
BuildContext is a handle to the widget’s location in the tree. After an await, the widget may have been disposed. Accessing context across async gaps triggers the “use_build_context_synchronously” lint. Guard with mounted before using context post-await.
// ❌ Unsafe: context may be invalid after await
Future<void> _save() async {
await saveData();
ScaffoldMessenger.of(context).showSnackBar(...); // warning!
}
// ✅ Safe: check mounted before using context
Future<void> _save() async {
await saveData();
if (!mounted) return; // widget was disposed during await
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Saved!')),
);
}
// ✅ Alternative: capture messenger before await
Future<void> _save() async {
final messenger = ScaffoldMessenger.of(context); // capture
await saveData();
messenger.showSnackBar(const SnackBar(content: Text('Saved!')));
}
A const widget instance is canonicalized — Flutter reuses the same object across builds, skipping the build, layout, and paint phases entirely. Mark widgets const whenever their subtree doesn’t depend on runtime state.
// ❌ Rebuilt on every parent rebuild (even though it never changes)
Text('Static label', style: TextStyle(fontSize: 16));
// ✅ Const: created once, skipped on subsequent rebuilds
const Text('Static label', style: TextStyle(fontSize: 16));
// Only the dynamic part rebuilds
class _HomeState extends State<Home> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
const Text('Counter'), // const — never rebuilt
const Divider(), // const — never rebuilt
Text('$_count'), // rebuilt on setState
const SizedBox(height: 16), // const — never rebuilt
ElevatedButton(
onPressed: () => setState(() => _count++),
child: const Text('Increment'), // const child
),
],
);
}
}
Row lays children horizontally; Column lays them vertically. The main axis is the direction of layout; cross axis is perpendicular. mainAxisAlignment distributes free space along the main axis; crossAxisAlignment aligns along the cross axis.
// Spread items across full width with space between
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: const [
Icon(Icons.menu),
Text('Title', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
Icon(Icons.more_vert),
],
)
// Column centered with even spacing
Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.stretch, // children fill width
children: [
ElevatedButton(onPressed: () {}, child: const Text('Option A')),
ElevatedButton(onPressed: () {}, child: const Text('Option B')),
ElevatedButton(onPressed: () {}, child: const Text('Option C')),
],
)
Expanded forces the child to fill all remaining space (equivalent to Flexible(fit: FlexFit.tight)). Flexible with FlexFit.loose lets the child be at most the allocated space but can be smaller. The flex factor sets the proportion.
// Row with proportional widths: 2:1 split
Row(
children: [
Expanded(
flex: 2, // takes 2/3 of available width
child: Container(color: Colors.blue, height: 60),
),
Expanded(
flex: 1, // takes 1/3 of available width
child: Container(color: Colors.red, height: 60),
),
],
)
// Flexible: child can be smaller than allocated space
Row(
children: [
Flexible(
child: Text('Short'), // only as wide as text needs
),
Flexible(
child: Text('A much longer text that wraps if needed'),
),
const Icon(Icons.info), // fixed size, not flexible
],
)
Stack layers children in Z order (last child on top). Non-positioned children fill the stack’s size. Positioned places a child at exact coordinates relative to the stack’s boundaries.
// Profile image with online indicator badge
Stack(
clipBehavior: Clip.none,
children: [
// Base: profile image
CircleAvatar(
radius: 32,
backgroundImage: NetworkImage(user.avatarUrl),
),
// Overlay: online dot at bottom-right
Positioned(
bottom: 0,
right: 0,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
),
],
)
// Hero image with gradient text overlay
Stack(
children: [
Image.network(imageUrl, fit: BoxFit.cover),
Positioned(
bottom: 0, left: 0, right: 0,
child: Container(
padding: const EdgeInsets.all(12),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black87, Colors.transparent],
),
),
child: Text(title, style: const TextStyle(color: Colors.white)),
),
),
],
)
Container is a convenience widget that merges many layout and painting properties. The rendering order is: margin → border → padding → child. When you need only one property, prefer the specific widget (Padding, SizedBox, DecoratedBox) for clarity.
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
width: double.infinity,
constraints: const BoxConstraints(minHeight: 80, maxHeight: 200),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(color: Colors.black12, blurRadius: 8, offset: Offset(0, 2)),
],
border: Border.all(color: Colors.purple.shade100),
),
child: const Text('Card content'),
)
// Prefer specific widgets for single properties:
Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(width: 120, height: 40, child: myWidget),
)
MediaQuery.of(context) returns the MediaQueryData with screen size, pixel ratio, padding (safe areas), brightness, and text scale. Access specific values with MediaQuery.sizeOf(context) to avoid rebuilding on unrelated changes.
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final padding = MediaQuery.paddingOf(context); // safe area
final isWide = size.width > 600;
return Scaffold(
body: Padding(
// Respect notch/home indicator
padding: EdgeInsets.only(
top: padding.top,
bottom: padding.bottom,
),
child: isWide
// Two-column layout on tablets/desktop
? Row(children: [
SizedBox(width: size.width * 0.35, child: SidePanel()),
Expanded(child: MainContent()),
])
// Single column on phone
: MainContent(),
),
);
}
LayoutBuilder provides the parent’s constraints (not the screen size) to the builder callback, enabling widgets that adapt to their container rather than the global screen width. This is more reusable than MediaQuery for library components.
class AdaptiveCard extends StatelessWidget {
const AdaptiveCard({super.key, required this.items});
final List<String> items;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 500) {
// Wide container: horizontal chip list
return Wrap(
spacing: 8,
children: items.map((i) => Chip(label: Text(i))).toList(),
);
}
// Narrow container: vertical list
return Column(
children: items.map((i) => ListTile(title: Text(i))).toList(),
);
},
);
}
}
SizedBox creates a fixed-size gap (great for separating list items). Spacer expands to fill all remaining free space in a Row/Column (like margin:auto in CSS). Padding wraps a child with inner space.
// SizedBox: fixed gap between items
Column(
children: [
const Text('Username'),
const SizedBox(height: 8), // 8px gap
const TextField(),
const SizedBox(height: 24), // 24px gap
ElevatedButton(onPressed: () {}, child: const Text('Login')),
],
)
// Spacer: push items to opposite ends of a Row
Row(
children: [
const Text('Logo'),
const Spacer(), // fills all remaining space
IconButton(icon: const Icon(Icons.search), onPressed: () {}),
IconButton(icon: const Icon(Icons.menu), onPressed: () {}),
],
)
// Padding: add space around a child
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: ElevatedButton(onPressed: () {}, child: const Text('Submit')),
)
Wrap places children in a line and starts a new line when the current one is full — like CSS flexbox with flex-wrap: wrap. Control gaps with spacing (main axis) and runSpacing (between lines).
// Tag/chip cloud
Wrap(
spacing: 8, // horizontal gap between chips
runSpacing: 8, // vertical gap between rows
children: [
'Flutter', 'Dart', 'Firebase', 'REST API',
'Provider', 'Riverpod', 'BLoC',
].map((tag) => Chip(
label: Text(tag),
backgroundColor: Colors.purple.shade50,
side: BorderSide(color: Colors.purple.shade200),
)).toList(),
)
// Responsive button bar that wraps on small screens
Wrap(
spacing: 12,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
ElevatedButton(onPressed: () {}, child: const Text('Save Draft')),
OutlinedButton(onPressed: () {}, child: const Text('Preview')),
FilledButton(onPressed: () {}, child: const Text('Publish')),
],
)
The Navigator maintains a stack of Route objects. push adds a route; pop removes the top route and optionally returns a result to the caller. MaterialPageRoute provides a platform-appropriate slide transition.
// Push a new page
Navigator.push(
context,
MaterialPageRoute(builder: (_) => DetailPage(id: item.id)),
);
// Push and wait for a result
final result = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (_) => const EditPage()),
);
if (result != null) showSnackBar('Saved: $result');
// Return result from pushed page
// Inside EditPage:
ElevatedButton(
onPressed: () => Navigator.pop(context, 'My Result'),
child: const Text('Done'),
);
// Pop to root (clear back stack)
Navigator.popUntil(context, (route) => route.isFirst);
// Replace current route (no back button)
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const HomePage()),
);
Register routes in MaterialApp.routes as a map of path strings to builder functions. Use Navigator.pushNamed to navigate and ModalRoute.of(context)!.settings.arguments to retrieve typed arguments.
// Route setup in MaterialApp
MaterialApp(
initialRoute: '/',
routes: {
'/': (_) => const HomePage(),
'/product': (_) => const ProductPage(),
'/cart': (_) => const CartPage(),
},
// For dynamic/parameterized routes:
onGenerateRoute: (settings) {
if (settings.name == '/product') {
final id = settings.arguments as int;
return MaterialPageRoute(builder: (_) => ProductPage(id: id));
}
return null;
},
)
// Navigate with arguments
Navigator.pushNamed(context, '/product', arguments: 42);
// Retrieve in destination
class ProductPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final id = ModalRoute.of(context)!.settings.arguments as int;
return Text('Product $id');
}
}
go_router is the recommended routing package for Flutter. It uses URL-like paths, supports deep links on all platforms, handles shell routes (persistent bottom nav), and provides typed parameters without boilerplate.
final _router = GoRouter(
initialLocation: '/home',
routes: [
GoRoute(
path: '/home',
builder: (_, __) => const HomePage(),
),
GoRoute(
path: '/products',
builder: (_, __) => const ProductListPage(),
routes: [
GoRoute(
path: ':id', // nested: /products/42
builder: (_, state) {
final id = int.parse(state.pathParameters['id']!);
return ProductDetailPage(id: id);
},
),
],
),
],
);
// Usage
MaterialApp.router(routerConfig: _router)
// Navigate
context.go('/products/42');
context.push('/products/42'); // add to back stack
Use IndexedStack to keep all tab widgets alive regardless of which is visible. Each tab maintains its own Navigator if it needs internal navigation. With go_router, use ShellRoute to achieve the same.
class _ShellState extends State<ShellPage> {
int _tab = 0;
final _pages = const [
HomePage(),
SearchPage(),
ProfilePage(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
// IndexedStack keeps all tabs mounted — state preserved
body: IndexedStack(
index: _tab,
children: _pages,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _tab,
onDestinationSelected: (i) => setState(() => _tab = i),
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
}
In Flutter 3.12+, wrap your widget with PopScope and set canPop: false. The onPopInvokedWithResult callback fires on every pop attempt (hardware back, gesture swipe, or programmatic). Handle the confirmation there.
class EditFormPage extends StatelessWidget {
const EditFormPage({super.key});
Future<bool> _confirmExit(BuildContext context) async {
return await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Discard changes?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Keep editing'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Discard'),
),
],
),
) ?? false;
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
final ok = await _confirmExit(context);
if (ok && context.mounted) Navigator.pop(context);
},
child: const Scaffold(body: FormContent()),
);
}
}
Both showModalBottomSheet and showDialog return a Future<T?>. Call Navigator.pop(context, value) inside the sheet/dialog to resolve the future with a result.
// Caller — receives selection
Future<void> _pickColor() async {
final color = await showModalBottomSheet<Color>(
context: context,
builder: (_) => const ColorPickerSheet(),
);
if (color != null) setState(() => _selected = color);
}
// Sheet — sends result back
class ColorPickerSheet extends StatelessWidget {
const ColorPickerSheet({super.key});
@override
Widget build(BuildContext context) {
final colors = [Colors.red, Colors.green, Colors.blue, Colors.purple];
return Wrap(
children: colors.map((c) => GestureDetector(
onTap: () => Navigator.pop(context, c), // return color
child: Container(
width: 60, height: 60,
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(color: c, shape: BoxShape.circle),
),
)).toList(),
);
}
}
In go_router, the redirect callback on GoRouter (or per-route) checks state and returns a path to redirect to, or null to allow navigation. This is the equivalent of React Router’s ProtectedRoute.
final _router = GoRouter(
redirect: (context, state) {
final isLoggedIn = AuthService.instance.isAuthenticated;
final isOnLogin = state.matchedLocation == '/login';
// Not logged in and not on login page → redirect to login
if (!isLoggedIn && !isOnLogin) return '/login';
// Logged in but on login page → redirect to home
if (isLoggedIn && isOnLogin) return '/home';
// No redirect needed
return null;
},
routes: [
GoRoute(path: '/login', builder: (_, __) => const LoginPage()),
GoRoute(path: '/home', builder: (_, __) => const HomePage()),
GoRoute(path: '/profile',builder: (_, __) => const ProfilePage()),
],
);
Insert a semi-transparent overlay on top of the widget tree using Stack and conditional rendering. Wrap it around the Scaffold body, not the navigator, to block interaction while allowing the app bar to remain visible.
class _LoginPageState extends State<LoginPage> {
bool _loading = false;
Future<void> _submit() async {
setState(() => _loading = true);
try {
await AuthService.login(_email, _password);
if (mounted) context.go('/home');
} catch (e) {
if (mounted) showErrorSnackBar(context, e.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
LoginForm(onSubmit: _submit),
if (_loading)
const ColoredBox(
color: Color(0x88000000), // semi-transparent
child: Center(child: CircularProgressIndicator()),
),
],
),
);
}
}
InheritedWidget is the low-level mechanism Flutter uses for its own context APIs (Theme.of, MediaQuery.of). Descendant widgets call context.dependOnInheritedWidgetOfExactType<T>() and are automatically rebuilt when the inherited widget changes.
class AppTheme extends InheritedWidget {
const AppTheme({
super.key,
required this.isDark,
required super.child,
});
final bool isDark;
// Convenience accessor
static AppTheme of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<AppTheme>()!;
}
@override
bool updateShouldNotify(AppTheme old) => isDark != old.isDark;
}
// Any descendant can access it:
Widget build(BuildContext context) {
final isDark = AppTheme.of(context).isDark;
return Text(isDark ? 'Dark mode' : 'Light mode');
}
Provider wraps InheritedWidget with a simpler API. ChangeNotifierProvider creates a ChangeNotifier and disposes it automatically. Consumer or context.watch() subscribe to updates; context.read() accesses without subscribing.
// Model
class CartModel extends ChangeNotifier {
final List<Item> _items = [];
List<Item> get items => List.unmodifiable(_items);
int get count => _items.length;
void add(Item item) {
_items.add(item);
notifyListeners(); // triggers rebuild of listeners
}
}
// Provide at top of tree
ChangeNotifierProvider(
create: (_) => CartModel(),
child: const MyApp(),
)
// Read (no rebuild) vs Watch (rebuilds on change)
class CartButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final count = context.watch<CartModel>().count; // rebuilds
return Badge(label: Text('$count'), child: const Icon(Icons.cart));
}
}
// Trigger action without subscribing
TextButton(
onPressed: () => context.read<CartModel>().add(item),
child: const Text('Add to cart'),
)
Riverpod has no BuildContext dependency — providers live globally and are accessed via a WidgetRef. This makes them easy to test in isolation. Providers are compile-time safe (no runtime “provider not found” errors) and support fine-grained rebuilds via select.
// Define providers at file level (not in widget tree)
final userProvider = AsyncNotifierProvider<UserNotifier, User>(
UserNotifier.new,
);
class UserNotifier extends AsyncNotifier<User> {
@override
Future<User> build() => ref.watch(authServiceProvider).getUser();
Future<void> updateName(String name) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => api.updateName(name));
}
}
// Consumer widget
class ProfileWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userProvider);
return user.when(
data: (u) => Text(u.name),
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('Error: $e'),
);
}
}
BLoC (Business Logic Component) takes a stream of events as input and emits a stream of states. The UI sends events and renders states — no business logic in widgets. The flutter_bloc package provides Bloc/Cubit classes and BlocBuilder/BlocListener widgets.
// Events
sealed class AuthEvent {}
class LoginRequested extends AuthEvent {
LoginRequested(this.email, this.password);
final String email, password;
}
class LogoutRequested extends AuthEvent {}
// States
sealed class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthSuccess extends AuthState { AuthSuccess(this.user); final User user; }
class AuthFailure extends AuthState { AuthFailure(this.error); final String error; }
// BLoC
class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc() : super(AuthInitial()) {
on<LoginRequested>((event, emit) async {
emit(AuthLoading());
try {
final user = await AuthService.login(event.email, event.password);
emit(AuthSuccess(user));
} catch (e) {
emit(AuthFailure(e.toString()));
}
});
}
}
// UI
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
return switch (state) {
AuthLoading() => const CircularProgressIndicator(),
AuthSuccess(:final user) => Text('Welcome ${user.name}'),
AuthFailure(:final error) => Text(error, style: const TextStyle(color: Colors.red)),
_ => const LoginForm(),
};
},
)
StreamBuilder listens to a Stream<T> and rebuilds with a new AsyncSnapshot<T> on each emission. It handles all connection states: waiting, active, done, and error. Use it to connect Firebase real-time streams, WebSockets, or Dart streams directly to UI.
// Real-time chat messages from Firebase
StreamBuilder<List<Message>>(
stream: FirebaseFirestore.instance
.collection('messages')
.orderBy('timestamp', descending: true)
.snapshots()
.map((snap) => snap.docs.map(Message.fromDoc).toList()),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
final messages = snapshot.data ?? [];
if (messages.isEmpty) return const Center(child: Text('No messages'));
return ListView.builder(
reverse: true,
itemCount: messages.length,
itemBuilder: (_, i) => MessageBubble(message: messages[i]),
);
},
)
ValueNotifier<T> is a ChangeNotifier that holds a single value. When value changes, only the ValueListenableBuilder widget rebuilds — not its parent or siblings. Ideal for localized UI state like toggles, counters, or selected indices.
class _FeedState extends State<FeedPage> {
// Defined once, persists across builds
final _selectedFilter = ValueNotifier<String>('all');
@override
void dispose() {
_selectedFilter.dispose(); // always dispose
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// Only this widget rebuilds when filter changes
ValueListenableBuilder<String>(
valueListenable: _selectedFilter,
builder: (_, filter, __) => FilterBar(
selected: filter,
onSelect: (f) => _selectedFilter.value = f,
),
),
// This expensive list does NOT rebuild on filter change
const Expanded(child: ExpensivePostList()),
],
);
}
}
FutureBuilder<T> subscribes to a Future and rebuilds with an AsyncSnapshot. Keep the future in a field — reassigning in build() creates a new future every rebuild, restarting the loading cycle.
class ProductPage extends StatefulWidget {
const ProductPage({super.key, required this.id});
final int id;
@override
State<ProductPage> createState() => _ProductPageState();
}
class _ProductPageState extends State<ProductPage> {
// Store future in state — not created inside build()
late final Future<Product> _future;
@override
void initState() {
super.initState();
_future = ProductService.fetch(widget.id);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Product>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
final product = snapshot.data!;
return ProductDetail(product: product);
},
);
}
}
Match the tool to the scope and complexity of the state: setState for local widget state, Provider/Riverpod for app-wide reactive state, BLoC for complex event-driven business logic with testable separation.
// setState — local, simple, no sharing needed
// e.g., toggle button, form field focus, tab index
setState(() => _isOpen = !_isOpen);
// Provider / Riverpod — cross-widget, reactive, testable
// e.g., user session, cart, theme, settings
final cartProvider = NotifierProvider<Cart, List<Item>>(Cart.new);
// BLoC — complex flows with many events/states
// e.g., authentication flow, checkout process, search with debounce
class SearchBloc extends Bloc<SearchEvent, SearchState> { ... }
// Decision guide:
// Single widget state → setState
// 2-3 widget sharing → Provider (ChangeNotifier)
// Large app, testability key → Riverpod
// Complex event-driven logic → BLoC
TextEditingController exposes .text to read the current value, .value to read/set with cursor position, and addListener to react to every keystroke. Always dispose it in dispose().
class _SearchBarState extends State<SearchBar> {
final _ctrl = TextEditingController();
@override
void initState() {
super.initState();
// Pre-fill existing value
_ctrl.text = widget.initialQuery;
// Listen to every change (debounce in real apps)
_ctrl.addListener(() {
widget.onQueryChanged(_ctrl.text);
});
}
@override
void dispose() {
_ctrl.dispose(); // must dispose to free resources
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _ctrl,
onSubmitted: widget.onSearch,
decoration: InputDecoration(
hintText: 'Search...',
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _ctrl.clear(),
),
),
);
}
}
Wrap fields in a Form widget with a GlobalKey<FormState>. Use TextFormField with a validator callback. Call formKey.currentState!.validate() to trigger all validators simultaneously.
class RegisterForm extends StatefulWidget {
const RegisterForm({super.key});
@override
State<RegisterForm> createState() => _RegisterFormState();
}
class _RegisterFormState extends State<RegisterForm> {
final _formKey = GlobalKey<FormState>();
String _email = '', _password = '';
void _submit() {
if (!_formKey.currentState!.validate()) return;
_formKey.currentState!.save(); // triggers onSaved callbacks
AuthService.register(_email, _password);
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(children: [
TextFormField(
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
validator: (v) {
if (v == null || v.isEmpty) return 'Required';
if (!v.contains('@')) return 'Invalid email';
return null; // null = valid
},
onSaved: (v) => _email = v!,
),
const SizedBox(height: 16),
TextFormField(
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
validator: (v) => (v?.length ?? 0) < 8 ? 'Min 8 chars' : null,
onSaved: (v) => _password = v!,
),
const SizedBox(height: 24),
ElevatedButton(onPressed: _submit, child: const Text('Register')),
]),
);
}
}
FocusNode programmatically moves focus between fields. Call FocusScope.of(context).requestFocus(nextFocusNode) to jump to the next field when the user presses “Next” on the keyboard.
class _LoginFormState extends State<LoginForm> {
final _emailFocus = FocusNode();
final _passwordFocus = FocusNode();
@override
void dispose() {
_emailFocus.dispose();
_passwordFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(children: [
TextFormField(
focusNode: _emailFocus,
textInputAction: TextInputAction.next, // shows "Next" key
onFieldSubmitted: (_) =>
FocusScope.of(context).requestFocus(_passwordFocus),
decoration: const InputDecoration(labelText: 'Email'),
),
TextFormField(
focusNode: _passwordFocus,
textInputAction: TextInputAction.done, // shows "Done" key
onFieldSubmitted: (_) => _submit(),
obscureText: true,
decoration: const InputDecoration(labelText: 'Password'),
),
]);
}
}
GestureDetector wraps any widget with gesture recognition callbacks. For simple tap/ripple effects on Material, prefer InkWell. Use GestureDetector for custom gestures or non-Material components.
// Custom swipe-to-dismiss card
GestureDetector(
onTap: () => openDetail(item),
onLongPress: () => showContextMenu(item),
onHorizontalDragEnd: (details) {
if (details.velocity.pixelsPerSecond.dx > 300) {
dismiss(item); // fast swipe right
}
},
child: Card(child: Text(item.title)),
)
// InkWell for Material ripple
InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => navigate(context, item),
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(item.title),
),
)
// Double tap to zoom image
GestureDetector(
onDoubleTap: () => setState(() => _zoomed = !_zoomed),
child: AnimatedScale(
scale: _zoomed ? 2.0 : 1.0,
duration: const Duration(milliseconds: 300),
child: Image.network(imageUrl),
),
)
All toggle inputs in Flutter are controlled: they require an explicit state variable. CheckboxListTile, RadioListTile, and SwitchListTile provide pre-built layout with labels.
class _PrefsState extends State<PrefsPage> {
bool _notifications = true;
bool _darkMode = false;
String _theme = 'system';
@override
Widget build(BuildContext context) {
return Column(children: [
// Switch
SwitchListTile(
title: const Text('Notifications'),
value: _notifications,
onChanged: (v) => setState(() => _notifications = v),
),
// Checkbox
CheckboxListTile(
title: const Text('Dark Mode'),
value: _darkMode,
onChanged: (v) => setState(() => _darkMode = v ?? false),
),
// Radio buttons
for (final opt in ['system', 'light', 'dark'])
RadioListTile<String>(
title: Text(opt),
value: opt,
groupValue: _theme,
onChanged: (v) => setState(() => _theme = v!),
),
]);
}
}
DropdownButton<T> requires a value matching one of the items. The onChanged callback receives the selected value. Use DropdownButtonFormField inside a Form to get built-in validation.
class _CountryPickerState extends State<CountryPicker> {
String? _selected;
final _countries = ['USA', 'UK', 'Canada', 'Australia', 'Israel'];
@override
Widget build(BuildContext context) {
return DropdownButtonFormField<String>(
value: _selected,
hint: const Text('Select country'),
decoration: const InputDecoration(
labelText: 'Country',
border: OutlineInputBorder(),
),
items: _countries
.map((c) => DropdownMenuItem(value: c, child: Text(c)))
.toList(),
onChanged: (v) => setState(() => _selected = v),
validator: (v) => v == null ? 'Please select a country' : null,
);
}
}
showDatePicker and showTimePicker are Material dialog futures that return a nullable DateTime/TimeOfDay. Combine them for a full datetime picker. Both support firstDate/lastDate bounds.
class _EventFormState extends State<EventForm> {
DateTime? _eventDate;
TimeOfDay? _eventTime;
Future<void> _pickDateTime() async {
// Step 1: pick date
final date = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (date == null || !mounted) return;
// Step 2: pick time
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
if (time == null) return;
setState(() {
_eventDate = date;
_eventTime = time;
});
}
@override
Widget build(BuildContext context) {
final label = _eventDate == null
? 'Pick date & time'
: '${_eventDate!.toLocal()} ${_eventTime?.format(context)}';
return ListTile(
leading: const Icon(Icons.calendar_today),
title: Text(label),
onTap: _pickDateTime,
);
}
}
Use a Timer that resets on every keystroke. When the timer fires (after the delay passes without a new keystroke), execute the search. Cancel the timer in dispose to prevent leaks.
class _SearchBarState extends State<SearchBar> {
final _ctrl = TextEditingController();
Timer? _debounce;
void _onChanged(String query) {
_debounce?.cancel(); // cancel previous timer
_debounce = Timer(const Duration(milliseconds: 400), () {
// Only fires if 400ms pass without another keystroke
widget.onSearch(query);
});
}
@override
void dispose() {
_debounce?.cancel();
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _ctrl,
onChanged: _onChanged,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search),
hintText: 'Search products...',
border: OutlineInputBorder(),
),
);
}
}
The http package provides simple top-level functions: http.get, http.post, etc. Always check response.statusCode — a 200 doesn’t mean the data is valid. Decode the body with json.decode.
import 'dart:convert';
import 'package:http/http.dart' as http;
// GET request
Future<User> fetchUser(int id) async {
final uri = Uri.parse('https://api.example.com/users/$id');
final response = await http.get(uri, headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
});
if (response.statusCode != 200) {
throw Exception('Failed: ${response.statusCode}');
}
return User.fromJson(json.decode(response.body));
}
// POST request
Future<User> createUser(String name, String email) async {
final response = await http.post(
Uri.parse('https://api.example.com/users'),
headers: {'Content-Type': 'application/json'},
body: json.encode({'name': name, 'email': email}),
);
if (response.statusCode != 201) throw Exception('Create failed');
return User.fromJson(json.decode(response.body));
}
Define a model class with a fromJson factory constructor that reads from a Map<String, dynamic>. For production apps, use json_serializable or freezed to generate the boilerplate automatically.
// Manual model class
class Product {
const Product({
required this.id,
required this.name,
required this.price,
required this.tags,
this.imageUrl,
});
final int id;
final String name;
final double price;
final List<String> tags;
final String? imageUrl;
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'] as int,
name: json['name'] as String,
price: (json['price'] as num).toDouble(),
tags: List<String>.from(json['tags'] as List),
imageUrl: json['image_url'] as String?,
);
}
// Parse list response
static List<Product> fromJsonList(List<dynamic> list) =>
list.map((j) => Product.fromJson(j as Map<String, dynamic>)).toList();
}
Dio is a more powerful HTTP client than http. It supports interceptors (for auth headers, logging, token refresh), configurable timeouts, built-in JSON decoding, FormData uploads, and typed error handling via DioException.
final dio = Dio(BaseOptions(
baseUrl: 'https://api.example.com',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
));
// Auth interceptor
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
options.headers['Authorization'] = 'Bearer ${Storage.token}';
handler.next(options);
},
onError: (DioException e, handler) async {
if (e.response?.statusCode == 401) {
// Token expired — refresh and retry
await AuthService.refreshToken();
return handler.resolve(await dio.fetch(e.requestOptions));
}
handler.next(e);
},
));
// Usage: Dio auto-decodes JSON
final response = await dio.get<Map<String, dynamic>>('/users/1');
final user = User.fromJson(response.data!);
Use MultipartRequest (http package) or FormData (Dio) to send files as multipart form data. Pick the file with image_picker, then attach it to the request.
import 'package:image_picker/image_picker.dart';
Future<void> uploadAvatar() async {
// 1. Pick image
final picked = await ImagePicker().pickImage(
source: ImageSource.gallery,
maxWidth: 800,
imageQuality: 80,
);
if (picked == null) return;
// 2. Upload with Dio
final formData = FormData.fromMap({
'avatar': await MultipartFile.fromFile(
picked.path,
filename: 'avatar.jpg',
),
'user_id': currentUser.id,
});
final response = await dio.post('/users/avatar', data: formData,
onSendProgress: (sent, total) {
setState(() => _progress = sent / total);
},
);
setState(() => _avatarUrl = response.data['url']);
}
Attach a ScrollController to the list and listen for when the user scrolls near the bottom. When the threshold is hit, fetch the next page and append to the existing list.
class _FeedState extends State<FeedPage> {
final _scrollCtrl = ScrollController();
final _items = <Post>[];
int _page = 1;
bool _loading = false;
bool _hasMore = true;
@override
void initState() {
super.initState();
_loadPage();
_scrollCtrl.addListener(_onScroll);
}
void _onScroll() {
final pos = _scrollCtrl.position;
if (pos.pixels >= pos.maxScrollExtent - 200 && !_loading && _hasMore) {
_loadPage();
}
}
Future<void> _loadPage() async {
setState(() => _loading = true);
final newPosts = await PostService.fetch(page: _page);
setState(() {
_items.addAll(newPosts);
_page++;
_hasMore = newPosts.length == 20; // 20 per page
_loading = false;
});
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _scrollCtrl,
itemCount: _items.length + (_loading ? 1 : 0),
itemBuilder: (_, i) {
if (i == _items.length) return const Center(child: CircularProgressIndicator());
return PostCard(post: _items[i]);
},
);
}
}
sqflite provides a SQLite database on Android and iOS. Open a database with a migration-aware onCreate/onUpgrade callback. Use insert, query, update, delete for CRUD.
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
class AppDatabase {
static Database? _db;
static Future<Database> get db async {
_db ??= await openDatabase(
join(await getDatabasesPath(), 'app.db'),
version: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT,
created_at INTEGER
)
''');
},
);
return _db!;
}
static Future<int> insertNote(Map<String, dynamic> note) async {
return (await db).insert('notes', note,
conflictAlgorithm: ConflictAlgorithm.replace);
}
static Future<List<Map<String, dynamic>>> getNotes() async {
return (await db).query('notes', orderBy: 'created_at DESC');
}
}
shared_preferences stores primitive values (bool, int, double, String, List<String>) on disk via platform-native key-value stores. Get an instance once and reuse it; the data persists between app launches.
import 'package:shared_preferences/shared_preferences.dart';
class PrefsService {
static late SharedPreferences _prefs;
static Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
}
// Theme
static bool get isDark => _prefs.getBool('dark_mode') ?? false;
static Future<void> setDark(bool v) => _prefs.setBool('dark_mode', v);
// Auth token
static String? get token => _prefs.getString('auth_token');
static Future<void> setToken(String t) => _prefs.setString('auth_token', t);
static Future<void> clearToken() => _prefs.remove('auth_token');
// First launch
static bool get isFirstRun => _prefs.getBool('first_run') ?? true;
static Future<void> markLaunched() => _prefs.setBool('first_run', false);
}
// Initialize once in main:
// await PrefsService.init();
The connectivity_plus package streams connectivity status changes. Subscribe in initState and cancel in dispose. Note: connectivity status doesn’t guarantee internet access — use a test request to confirm.
import 'package:connectivity_plus/connectivity_plus.dart';
class _AppState extends State<App> {
late final StreamSubscription<List<ConnectivityResult>> _sub;
bool _isOnline = true;
@override
void initState() {
super.initState();
_sub = Connectivity().onConnectivityChanged.listen((results) {
setState(() {
_isOnline = results.any((r) => r != ConnectivityResult.none);
});
});
}
@override
void dispose() {
_sub.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(children: [
if (!_isOnline)
MaterialBanner(
content: const Text('No internet connection'),
backgroundColor: Colors.red.shade100,
actions: [TextButton(onPressed: () {}, child: const Text('Retry'))],
),
Expanded(child: const MainContent()),
]);
}
}
ListView.builder creates items on demand — only those visible (plus a small buffer) are built. It’s a virtual list. Never use ListView(children: [...]) with large collections, as it builds all children at once.
// ❌ Eager: builds all 10,000 items at once
ListView(
children: products.map((p) => ProductCard(product: p)).toList(),
)
// ✅ Lazy: builds only visible items
ListView.builder(
itemCount: products.length,
itemExtent: 80, // fixed height enables more optimizations
itemBuilder: (context, index) {
final product = products[index];
return ProductCard(
key: ValueKey(product.id),
product: product,
);
},
)
// Separated: adds a divider between items
ListView.separated(
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) => ListTile(title: Text(items[i].name)),
)
GridView.builder with SliverGridDelegateWithFixedCrossAxisCount creates an N-column grid. SliverGridDelegateWithMaxCrossAxisExtent creates a responsive grid where each cell has a maximum width.
// Fixed 2-column grid
GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 0.75, // height = width / 0.75
),
itemCount: products.length,
itemBuilder: (_, i) => ProductCard(product: products[i]),
)
// Responsive: each cell max 200px wide
GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 1.0, // square cells
),
itemCount: photos.length,
itemBuilder: (_, i) => Image.network(photos[i].url, fit: BoxFit.cover),
)
Wrap a scrollable widget with RefreshIndicator and provide an async onRefresh callback. The indicator remains visible until the future completes. The scrollable must be scrollable even when content is shorter than the screen — set physics: const AlwaysScrollableScrollPhysics().
class _PostListState extends State<PostList> {
List<Post> _posts = [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final posts = await PostService.fetchLatest();
if (mounted) setState(() => _posts = posts);
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: _load, // called when user pulls down
color: Theme.of(context).colorScheme.primary,
child: ListView.builder(
// Required: makes short lists still pullable
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _posts.length,
itemBuilder: (_, i) => PostCard(post: _posts[i]),
),
);
}
}
CustomScrollView composes multiple scrollable sections (slivers) into one unified scroll. Mix SliverAppBar (collapsing header), SliverList, SliverGrid, and SliverToBoxAdapter (any widget) in the same scroll view.
CustomScrollView(
slivers: [
// Collapsing hero image header
SliverAppBar(
expandedHeight: 280,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: Text(product.name),
background: Image.network(product.imageUrl, fit: BoxFit.cover),
),
),
// Static info section
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: ProductInfo(product: product),
),
),
// Section header
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text('Related Products', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
// Grid of related items
SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, mainAxisSpacing: 8, crossAxisSpacing: 8,
childAspectRatio: 0.8,
),
delegate: SliverChildBuilderDelegate(
(_, i) => ProductCard(product: related[i]),
childCount: related.length,
),
),
],
)
ReorderableListView.builder shows a drag handle on each item and calls onReorder with the old and new indices. Update the list manually in onReorder — Flutter doesn’t mutate it for you.
class _TaskListState extends State<TaskList> {
final _tasks = ['Write tests', 'Review PR', 'Deploy', 'Update docs'];
@override
Widget build(BuildContext context) {
return ReorderableListView.builder(
itemCount: _tasks.length,
onReorder: (oldIndex, newIndex) {
setState(() {
// Required correction per Flutter docs
if (newIndex > oldIndex) newIndex--;
final item = _tasks.removeAt(oldIndex);
_tasks.insert(newIndex, item);
});
},
itemBuilder: (_, i) => ListTile(
key: ValueKey(_tasks[i]), // required for reorderable
title: Text(_tasks[i]),
leading: const Icon(Icons.drag_handle),
),
);
}
}
Wrap each list item in Dismissible. The onDismissed callback fires after the swipe animation completes. Remove the item from the underlying list; otherwise Flutter throws a layout error when it tries to rebuild the dismissed item.
ListView.builder(
itemCount: _emails.length,
itemBuilder: (_, i) {
final email = _emails[i];
return Dismissible(
key: ValueKey(email.id),
direction: DismissDirection.endToStart, // swipe left only
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete, color: Colors.white),
),
confirmDismiss: (_) async {
return await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Delete email?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Delete')),
],
),
);
},
onDismissed: (_) {
setState(() => _emails.removeAt(i));
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Email deleted')));
},
child: EmailTile(email: email),
);
},
)
PageView is a scrollable list of full-screen pages. A PageController lets you animate to a specific page programmatically. Use PageView.builder for large or dynamic page sets.
class _OnboardingState extends State<OnboardingScreen> {
final _ctrl = PageController();
int _page = 0;
final _pages = const [
OnboardPage(title: 'Discover', icon: Icons.explore),
OnboardPage(title: 'Connect', icon: Icons.people),
OnboardPage(title: 'Achieve', icon: Icons.emoji_events),
];
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: PageView(
controller: _ctrl,
onPageChanged: (p) => setState(() => _page = p),
children: _pages,
),
bottomNavigationBar: Padding(
padding: const EdgeInsets.all(24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Dot indicators
Row(children: List.generate(_pages.length, (i) => AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(right: 6),
width: _page == i ? 20 : 8, height: 8,
decoration: BoxDecoration(
color: _page == i ? Colors.purple : Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
))),
FilledButton(
onPressed: _page == _pages.length - 1
? () => context.go('/home')
: () => _ctrl.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut),
child: Text(_page == _pages.length - 1 ? 'Start' : 'Next'),
),
],
),
),
);
}
}
AnimatedList wraps a GlobalKey<AnimatedListState>. Call insertItem/removeItem on the key’s current state to trigger transitions. The itemBuilder receives an Animation for the insert; removeItem takes a builder for the exit animation.
class _NotifListState extends State<NotifList> {
final _listKey = GlobalKey<AnimatedListState>();
final _items = <Notification>[];
void _add(Notification n) {
_items.insert(0, n);
_listKey.currentState!.insertItem(0,
duration: const Duration(milliseconds: 300));
}
void _remove(int i) {
final removed = _items.removeAt(i);
_listKey.currentState!.removeItem(i,
(_, animation) => FadeTransition(
opacity: animation,
child: NotifCard(notif: removed),
),
duration: const Duration(milliseconds: 200),
);
}
@override
Widget build(BuildContext context) {
return AnimatedList(
key: _listKey,
initialItemCount: _items.length,
itemBuilder: (_, i, animation) => SlideTransition(
position: animation.drive(Tween(
begin: const Offset(1, 0), end: Offset.zero)),
child: NotifCard(notif: _items[i]),
),
);
}
}
Change any property on AnimatedContainer inside setState and it smoothly interpolates to the new value. No animation controller needed — this is Flutter’s “implicit animation” family (AnimatedOpacity, AnimatedPadding, AnimatedPositioned, etc.).
class _ExpandingCardState extends State<ExpandingCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: AnimatedContainer(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
// All these properties animate on setState:
height: _expanded ? 200 : 80,
decoration: BoxDecoration(
color: _expanded ? Colors.purple : Colors.purple.shade100,
borderRadius: BorderRadius.circular(_expanded ? 16 : 40),
boxShadow: _expanded
? [const BoxShadow(blurRadius: 20, color: Colors.black26)]
: [],
),
child: Center(
child: Text(
_expanded ? 'Tap to collapse' : 'Tap to expand',
style: TextStyle(
color: _expanded ? Colors.white : Colors.purple,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
}
AnimationController is a value that runs from 0 to 1 over a duration. A Tween maps that 0–1 to any range (color, size, offset). Add a CurvedAnimation for easing. Use with AnimatedBuilder to rebuild only the animated subtree.
class _PulseState extends State<PulseButton>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<double> _scale;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
)..repeat(reverse: true); // ping-pong loop
_scale = Tween(begin: 1.0, end: 1.15).animate(
CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _scale,
builder: (_, child) => Transform.scale(
scale: _scale.value,
child: child,
),
child: FloatingActionButton(
onPressed: widget.onPressed,
child: const Icon(Icons.add),
),
);
}
}
Wrap the same widget on both the source and destination screens with Hero, giving both the same tag. Flutter automatically animates the shared element flying between the two pages during navigation.
// List screen — source
class ProductListItem extends StatelessWidget {
const ProductListItem({super.key, required this.product});
final Product product;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => ProductDetail(product: product))),
child: Hero(
tag: 'product-image-${product.id}', // unique tag
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(product.imageUrl, width: 80, height: 80, fit: BoxFit.cover),
),
),
);
}
}
// Detail screen — destination
class ProductDetail extends StatelessWidget {
const ProductDetail({super.key, required this.product});
final Product product;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(children: [
Hero(
tag: 'product-image-${product.id}', // same tag!
child: Image.network(product.imageUrl, width: double.infinity,
height: 300, fit: BoxFit.cover),
),
Text(product.name, style: const TextStyle(fontSize: 24)),
]),
);
}
}
TweenAnimationBuilder animates to a new target value whenever it changes — like AnimatedContainer but for arbitrary types. It’s a one-way animation (not looping). Use it for progress indicators, counter animations, or color transitions.
// Animated score counter
TweenAnimationBuilder<int>(
tween: IntTween(begin: 0, end: score), // animates to new score
duration: const Duration(milliseconds: 600),
curve: Curves.easeOut,
builder: (_, value, __) => Text(
'$value',
style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
),
)
// Animated color fill (e.g., loading bar)
TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: progress), // 0.0 to 1.0
duration: const Duration(milliseconds: 800),
builder: (_, value, __) => LinearProgressIndicator(value: value),
)
// Animated color theme switch
TweenAnimationBuilder<Color?>(
tween: ColorTween(begin: Colors.blue, end: targetColor),
duration: const Duration(milliseconds: 500),
builder: (_, color, child) => Container(color: color, child: child),
child: const Text('Content'),
)
Use the shimmer package (or implement manually with an AnimationController driving a gradient). Show the skeleton while the real data loads, then replace it with the actual content.
import 'package:shimmer/shimmer.dart';
class ProductCardSkeleton extends StatelessWidget {
const ProductCardSkeleton({super.key});
@override
Widget build(BuildContext context) {
return Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image placeholder
Container(height: 160, color: Colors.white),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(height: 16, width: 140, color: Colors.white),
const SizedBox(height: 6),
Container(height: 12, width: 80, color: Colors.white),
],
),
),
],
),
),
);
}
}
// Toggle between skeleton and real content:
_loading ? const ProductCardSkeleton() : ProductCard(product: product)
AnimatedSwitcher fades (or transitions) between old and new child widgets when the child changes. The child must have a different key to trigger the animation. Use it for toggling icons, loading states, or slide-show content.
// Toggle heart icon with fade+scale
AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
transitionBuilder: (child, animation) => ScaleTransition(
scale: animation,
child: FadeTransition(opacity: animation, child: child),
),
child: Icon(
_liked ? Icons.favorite : Icons.favorite_border,
key: ValueKey(_liked), // key change triggers animation!
color: _liked ? Colors.red : Colors.grey,
size: 28,
),
)
// Swap between loading spinner and content
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _loading
? const CircularProgressIndicator(key: ValueKey('loading'))
: UserCard(user: _user!, key: const ValueKey('content')),
)
The lottie package renders Bodymovin/Lottie JSON animations exported from After Effects or LottieFiles. Load from assets, network, or bytes. Use a controller to play, pause, loop, or seek.
import 'package:lottie/lottie.dart';
// Simple loop from assets (add to pubspec.yaml assets)
Lottie.asset(
'assets/animations/loading.json',
width: 120,
height: 120,
fit: BoxFit.contain,
)
// Controlled: play once then stop
class _SuccessAnimState extends State<SuccessAnim>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Lottie.asset(
'assets/animations/success.json',
controller: _ctrl,
onLoaded: (comp) {
_ctrl.duration = comp.duration;
_ctrl.forward(); // play once
},
);
}
}
Use a single AnimationController and multiple Interval-based CurvedAnimations to sequence different property animations. Each Interval(begin, end) activates only in that fraction of the total duration.
class _StaggeredEntryState extends State<StaggeredEntry>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<double> _fade;
late final Animation<Offset> _slide;
late final Animation<double> _scale;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
_fade = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(
parent: _ctrl, curve: const Interval(0.0, 0.4, curve: Curves.easeIn)));
_slide = Tween(begin: const Offset(0, 0.3), end: Offset.zero)
.animate(CurvedAnimation(
parent: _ctrl, curve: const Interval(0.2, 0.7, curve: Curves.easeOut)));
_scale = Tween(begin: 0.8, end: 1.0).animate(CurvedAnimation(
parent: _ctrl, curve: const Interval(0.5, 1.0, curve: Curves.elasticOut)));
_ctrl.forward();
}
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _fade,
child: SlideTransition(
position: _slide,
child: ScaleTransition(scale: _scale, child: widget.child),
),
);
}
}
ColorScheme.fromSeed generates a full Material 3 color palette from a single seed color. Pass the scheme to ThemeData with useMaterial3: true. All Material 3 components automatically use the correct tonal surface and container colors.
ThemeData buildTheme(Brightness brightness) {
final scheme = ColorScheme.fromSeed(
seedColor: const Color(0xFF7B2FBE),
brightness: brightness,
);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
fontFamily: 'Inter',
// Component-level overrides
appBarTheme: AppBarTheme(
centerTitle: true,
backgroundColor: scheme.surfaceContainerHighest,
foregroundColor: scheme.onSurface,
elevation: 0,
),
cardTheme: CardTheme(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
color: scheme.surfaceContainerLow,
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
),
);
}
Store the ThemeMode in state (or a provider) and pass it to MaterialApp.themeMode. Set theme for light and darkTheme for dark; Flutter picks the right one automatically.
// Provider
class ThemeNotifier extends ChangeNotifier {
ThemeMode _mode = ThemeMode.system;
ThemeMode get mode => _mode;
void toggle() {
_mode = _mode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
notifyListeners();
PrefsService.setDark(_mode == ThemeMode.dark);
}
}
// MaterialApp
MaterialApp(
themeMode: context.watch<ThemeNotifier>().mode,
theme: buildTheme(Brightness.light),
darkTheme: buildTheme(Brightness.dark),
home: const HomePage(),
)
// Toggle button in settings
IconButton(
icon: Icon(themeMode == ThemeMode.dark ? Icons.light_mode : Icons.dark_mode),
onPressed: () => context.read<ThemeNotifier>().toggle(),
)
Use Theme.of(context).colorScheme for semantic colors and Theme.of(context).textTheme for typography. Never hardcode hex colors in widget code — always reference the theme so dark mode works automatically.
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final text = Theme.of(context).textTheme;
return Card(
color: scheme.surfaceContainerLow,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Heading', style: text.titleLarge),
Text('Body text', style: text.bodyMedium),
Text('Caption', style: text.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
)),
Container(
color: scheme.primaryContainer,
padding: const EdgeInsets.all(8),
child: Text('Highlighted',
style: text.bodySmall?.copyWith(color: scheme.onPrimaryContainer)),
),
],
),
);
}
Flutter’s ThemeData has a dedicated theme for every Material component. Override at the app level so every instance inherits the style — no need to set style on each individual widget.
ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple),
// Every ElevatedButton uses this shape and padding
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
textStyle: const TextStyle(fontWeight: FontWeight.w600),
),
),
// Chip styling
chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
side: BorderSide.none,
),
// All text fields look the same
inputDecorationTheme: InputDecorationTheme(
filled: true,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
)
Use the google_fonts package to load fonts from Google Fonts CDN at runtime, or bundle fonts in assets/fonts/ and declare them in pubspec.yaml for offline use.
// Option 1: google_fonts package
import 'package:google_fonts/google_fonts.dart';
ThemeData(
textTheme: GoogleFonts.interTextTheme(), // applies Inter to all text
)
// One-off on a widget
Text('Hello', style: GoogleFonts.firaSans(fontWeight: FontWeight.bold));
// Option 2: local fonts (declare in pubspec.yaml)
// flutter:
// fonts:
// - family: Poppins
// fonts:
// - asset: assets/fonts/Poppins-Regular.ttf
// - asset: assets/fonts/Poppins-Bold.ttf weight: 700
ThemeData(
fontFamily: 'Poppins', // applies globally
)
// Override for specific text
const Text('Display', style: TextStyle(
fontFamily: 'Poppins',
fontWeight: FontWeight.w700,
fontSize: 32,
))
Wrap a sub-tree with Theme(data: Theme.of(context).copyWith(...), child: ...) to override the theme for that widget and all its descendants without affecting the rest of the app.
// Override button color just for the danger zone section
Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: Colors.red,
onPrimary: Colors.white,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
),
),
child: Column(
children: [
const Text('Danger Zone', style: TextStyle(color: Colors.red)),
ElevatedButton(
onPressed: _deleteAccount,
child: const Text('Delete Account'),
),
],
),
)
Declare assets in pubspec.yaml, then use Image.asset or AssetImage. For SVGs, use the flutter_svg package. For icons beyond Material, use flutter_launcher_icons or icon packages.
// pubspec.yaml:
// flutter:
// assets:
// - assets/images/
// - assets/icons/
// Raster image (PNG/JPG)
Image.asset(
'assets/images/logo.png',
width: 120,
height: 120,
fit: BoxFit.contain,
)
// CircleAvatar with local fallback
CircleAvatar(
backgroundImage: user.avatar != null
? NetworkImage(user.avatar!) as ImageProvider
: const AssetImage('assets/images/default_avatar.png'),
radius: 24,
)
// SVG (flutter_svg package)
import 'package:flutter_svg/flutter_svg.dart';
SvgPicture.asset(
'assets/icons/logo.svg',
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary, BlendMode.srcIn),
width: 40,
)
Flutter’s adaptive approach uses breakpoints (typically 600dp for tablet) combined with LayoutBuilder or MediaQuery. Use Flutter’s adaptive constructors (Switch.adaptive, AlertDialog.adaptive) for platform-native feel.
// Adaptive scaffold: drawer on tablet, bottom nav on phone
class AdaptiveScaffold extends StatelessWidget {
const AdaptiveScaffold({super.key, required this.body});
final Widget body;
@override
Widget build(BuildContext context) {
final isWide = MediaQuery.sizeOf(context).width >= 600;
if (isWide) {
return Scaffold(
body: Row(children: [
NavigationRail(
destinations: destinations.map((d) =>
NavigationRailDestination(
icon: Icon(d.icon), label: Text(d.label))).toList(),
selectedIndex: 0,
onDestinationSelected: navigate,
),
const VerticalDivider(thickness: 1, width: 1),
Expanded(child: body),
]),
);
}
return Scaffold(
body: body,
bottomNavigationBar: NavigationBar(
destinations: destinations.map((d) =>
NavigationDestination(icon: Icon(d.icon), label: d.label)).toList(),
onDestinationSelected: navigate,
),
);
}
}
The image_picker package requests the camera/gallery and returns an XFile with the file path. On iOS, add NSCameraUsageDescription and NSPhotoLibraryUsageDescription to Info.plist; on Android, add relevant permissions.
import 'package:image_picker/image_picker.dart';
class _ProfilePhotoState extends State<ProfilePhoto> {
XFile? _photo;
final _picker = ImagePicker();
Future<void> _pickSource(ImageSource source) async {
final picked = await _picker.pickImage(
source: source,
maxWidth: 800,
maxHeight: 800,
imageQuality: 85,
);
if (picked != null) setState(() => _photo = picked);
}
void _showPicker() {
showModalBottomSheet(context: context, builder: (_) => Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text('Take photo'),
onTap: () { Navigator.pop(context); _pickSource(ImageSource.camera); },
),
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('Choose from gallery'),
onTap: () { Navigator.pop(context); _pickSource(ImageSource.gallery); },
),
],
));
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _showPicker,
child: _photo != null
? CircleAvatar(backgroundImage: FileImage(File(_photo!.path)), radius: 48)
: const CircleAvatar(radius: 48, child: Icon(Icons.camera_alt, size: 32)),
);
}
}
permission_handler provides a unified API for requesting permissions on Android and iOS. Always check the current status before requesting — re-requesting a permanently denied permission goes to the app settings page.
import 'package:permission_handler/permission_handler.dart';
Future<bool> requestLocationPermission() async {
var status = await Permission.locationWhenInUse.status;
if (status.isGranted) return true;
if (status.isDenied) {
status = await Permission.locationWhenInUse.request();
return status.isGranted;
}
if (status.isPermanentlyDenied) {
// Direct user to system settings
await openAppSettings();
return false;
}
return false;
}
// Request multiple permissions at once
Future<void> requestAll() async {
final statuses = await [
Permission.camera,
Permission.microphone,
Permission.storage,
].request();
if (statuses[Permission.camera]!.isGranted) startCamera();
}
geolocator wraps platform location APIs. Check service enabled and permission before requesting position. Use getPositionStream for continuous tracking with a low-battery-impact LocationSettings configuration.
import 'package:geolocator/geolocator.dart';
Future<Position> determinePosition() async {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) throw Exception('Location services disabled');
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
throw Exception('Location permission denied');
}
}
if (permission == LocationPermission.deniedForever) {
throw Exception('Location permanently denied');
}
return Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
}
// Continuous stream
StreamSubscription<Position>? _positionSub;
void startTracking() {
_positionSub = Geolocator.getPositionStream(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.medium,
distanceFilter: 10, // update every 10 meters
),
).listen((pos) => setState(() => _position = pos));
}
The url_launcher package calls the platform’s default handler for HTTP URLs, tel:, mailto:, and other URI schemes. Always call canLaunchUrl before launching to handle unsupported schemes gracefully.
import 'package:url_launcher/url_launcher.dart';
Future<void> openLink(String url) async {
final uri = Uri.parse(url);
if (!await canLaunchUrl(uri)) throw Exception('Cannot open $url');
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
// Email with pre-filled fields
Future<void> sendEmail() async {
final uri = Uri(
scheme: 'mailto',
path: 'support@example.com',
queryParameters: {
'subject': 'App Feedback',
'body': 'Hi team,\n\n',
},
);
await launchUrl(uri);
}
// Phone call
Future<void> callPhone(String number) async {
await launchUrl(Uri.parse('tel:$number'));
}
// In-app browser
Future<void> openInApp(String url) async {
await launchUrl(Uri.parse(url), mode: LaunchMode.inAppWebView);
}
firebase_messaging handles FCM push notifications. Request permission on iOS. Listen to foreground messages with onMessage; handle background taps with onMessageOpenedApp. Use flutter_local_notifications to show foreground banners.
import 'package:firebase_messaging/firebase_messaging.dart';
// Background message handler (must be top-level function)
@pragma('vm:entry-point')
Future<void> _bgHandler(RemoteMessage message) async {
print('Background: ${message.notification?.title}');
}
Future<void> initNotifications() async {
FirebaseMessaging.onBackgroundMessage(_bgHandler);
// Request permission (iOS)
await FirebaseMessaging.instance.requestPermission(
alert: true, badge: true, sound: true,
);
// Get FCM token (send to your server)
final token = await FirebaseMessaging.instance.getToken();
await api.saveToken(token!);
// Foreground messages
FirebaseMessaging.onMessage.listen((RemoteMessage msg) {
showLocalNotification(
title: msg.notification?.title ?? '',
body: msg.notification?.body ?? '',
);
});
// User tapped notification while app was in background
FirebaseMessaging.onMessageOpenedApp.listen((msg) {
navigateFromNotification(msg.data);
});
}
MethodChannel creates a named channel between Dart and the native platform. Call methods from Dart; implement them in Kotlin (Android) or Swift (iOS). Use EventChannel for streams.
// Dart side
const _channel = MethodChannel('com.example.app/battery');
Future<int> getBatteryLevel() async {
try {
final level = await _channel.invokeMethod<int>('getBatteryLevel');
return level ?? -1;
} on PlatformException catch (e) {
throw Exception('Failed: ${e.message}');
}
}
// Android side (MainActivity.kt)
// val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger,
// "com.example.app/battery")
// channel.setMethodCallHandler { call, result ->
// if (call.method == "getBatteryLevel") {
// val mgr = getSystemService(BATTERY_SERVICE) as BatteryManager
// result.success(mgr.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY))
// } else result.notImplemented()
// }
// iOS side (AppDelegate.swift)
// let channel = FlutterMethodChannel(name: "com.example.app/battery",
// binaryMessenger: controller.binaryMessenger)
// channel.setMethodCallHandler { call, result in
// if call.method == "getBatteryLevel" {
// UIDevice.current.isBatteryMonitoringEnabled = true
// result(Int(UIDevice.current.batteryLevel * 100))
// }
// }
The local_auth package wraps Android BiometricPrompt and iOS LocalAuthentication. Check which biometrics are enrolled, then authenticate. Fall back to PIN/passcode when biometrics aren’t available.
import 'package:local_auth/local_auth.dart';
final _auth = LocalAuthentication();
Future<bool> authenticateWithBiometrics() async {
// Check device support
final canCheck = await _auth.canCheckBiometrics;
final isSupported = await _auth.isDeviceSupported();
if (!canCheck || !isSupported) return false;
// Check what's available
final available = await _auth.getAvailableBiometrics();
if (available.isEmpty) return false;
try {
return await _auth.authenticate(
localizedReason: 'Authenticate to access your account',
options: const AuthenticationOptions(
biometricOnly: false, // allow PIN fallback
stickyAuth: true, // don't cancel on app switch
),
);
} on PlatformException {
return false;
}
}
Dart Isolates are independent threads with no shared memory. Use Isolate.run (Dart 3) for a simple one-shot computation, or compute (Flutter) which wraps the same pattern. For long-lived background work, use isolate_manager or service workers.
// Heavy computation that would freeze the UI
List<Product> parseProducts(String json) {
final list = jsonDecode(json) as List;
return list.map((e) => Product.fromJson(e)).toList(); // CPU-heavy
}
// Run in isolate with compute() — Flutter's helper
Future<void> loadProducts() async {
final jsonString = await http.read(Uri.parse('/api/products'));
// Offload to background isolate, then get result back
final products = await compute(parseProducts, jsonString);
setState(() => _products = products);
}
// Dart 3: Isolate.run for even simpler syntax
Future<void> processImage(File file) async {
final compressed = await Isolate.run(() {
final bytes = file.readAsBytesSync();
return compressImage(bytes, quality: 80); // CPU-heavy
});
await uploadBytes(compressed);
}
Unit tests in Flutter use the test package (auto-included in flutter_test). Group related tests with group(); use setUp/tearDown for shared setup. No widget rendering — pure Dart logic only.
// test/cart_model_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/models/cart_model.dart';
void main() {
late CartModel cart;
setUp(() => cart = CartModel());
group('CartModel', () {
test('starts empty', () {
expect(cart.items, isEmpty);
expect(cart.total, 0.0);
});
test('adds item and updates total', () {
cart.add(Item(id: 1, name: 'Book', price: 29.99));
expect(cart.items.length, 1);
expect(cart.total, closeTo(29.99, 0.001));
});
test('removes item', () {
cart.add(Item(id: 1, name: 'Book', price: 29.99));
cart.remove(1);
expect(cart.items, isEmpty);
});
test('applying discount code reduces total', () {
cart.add(Item(id: 1, name: 'Book', price: 100.0));
cart.applyDiscount('SAVE20');
expect(cart.total, 80.0);
});
});
}
Widget tests render widgets in a virtual environment without a physical device. Use tester.pumpWidget to mount, find to locate widgets, and tester.tap/tester.enterText to simulate user interactions. Call tester.pump() after interactions to rebuild.
import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/widgets/counter_widget.dart';
void main() {
testWidgets('Counter increments on button tap', (tester) async {
// Mount the widget
await tester.pumpWidget(
const MaterialApp(home: Scaffold(body: CounterWidget())),
);
// Verify initial state
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Simulate user tap
await tester.tap(find.byIcon(Icons.add));
await tester.pump(); // trigger rebuild
// Verify updated state
expect(find.text('1'), findsOneWidget);
expect(find.text('0'), findsNothing);
});
testWidgets('Login form validates empty fields', (tester) async {
await tester.pumpWidget(const MaterialApp(home: LoginPage()));
await tester.tap(find.byType(ElevatedButton));
await tester.pump();
expect(find.text('Required'), findsWidgets);
});
}
mocktail (or mockito) generates mock implementations of classes. Stub method return values with when(() => ...).thenAnswer. Verify calls with verify(() => ...).called(1).
import 'package:mocktail/mocktail.dart';
import 'package:flutter_test/flutter_test.dart';
// Create a mock
class MockUserRepo extends Mock implements UserRepository {}
void main() {
late MockUserRepo repo;
late UserBloc bloc;
setUp(() {
repo = MockUserRepo();
bloc = UserBloc(repository: repo);
});
test('loads user on init', () async {
// Stub the mock
when(() => repo.getUser(any())).thenAnswer(
(_) async => const User(id: 1, name: 'Alice'),
);
await bloc.loadUser(1);
// Verify state
expect(bloc.state, isA<UserLoaded>());
expect((bloc.state as UserLoaded).user.name, 'Alice');
// Verify interaction
verify(() => repo.getUser(1)).called(1);
});
test('emits error on failure', () async {
when(() => repo.getUser(any())).thenThrow(Exception('Not found'));
await bloc.loadUser(999);
expect(bloc.state, isA<UserError>());
});
}
Integration tests live in integration_test/ and use IntegrationTestWidgetsFlutterBinding. They run the full app on a real device or emulator and can automate entire user flows end-to-end.
// integration_test/login_flow_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:myapp/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('Full login flow', (tester) async {
app.main(); // start the real app
await tester.pumpAndSettle(); // wait for all animations
// Find and fill email field
await tester.enterText(find.byKey(const Key('email-field')), 'user@example.com');
await tester.enterText(find.byKey(const Key('password-field')), 'password123');
await tester.tap(find.byKey(const Key('login-button')));
await tester.pumpAndSettle(); // wait for navigation
// Verify we're on the home page
expect(find.text('Welcome back!'), findsOneWidget);
});
}
// Run: flutter test integration_test/login_flow_test.dart
Run in profile mode (flutter run --profile) to enable DevTools. The Performance view shows frame build times, identifies jank (frames >16ms), and shows which widget subtrees are rebuilt. The CPU profiler identifies slow Dart code.
# Run in profile mode
flutter run --profile
# Open DevTools in browser
dart devtools
# From terminal — opens and connects automatically
flutter run --profile --devtools
// Add custom timeline events to trace slow code
import 'dart:developer';
Future<void> heavyOperation() async {
Timeline.startSync('MyHeavyOp');
// ...work...
Timeline.finishSync();
}
// Find rebuild hot spots — add to slow widgets:
class ExpensiveWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
debugPrint('ExpensiveWidget rebuilt'); // watch in DevTools
return const SomethingExpensive();
}
}
Sign the app with a keystore configured in android/app/build.gradle. Build an App Bundle (AAB) for Play Store — it’s smaller than an APK because Google generates device-specific APKs. Build an APK for direct distribution.
# Generate keystore (once)
keytool -genkey -v -keystore upload-keystore.jks \
-keyalg RSA -keysize 2048 -validity 10000 \
-alias upload
# Build App Bundle (Play Store preferred)
flutter build appbundle --release
# Build APK (direct install)
flutter build apk --release
# Build split APKs by ABI (smaller downloads)
flutter build apk --split-per-abi --release
# Output locations:
# AAB: build/app/outputs/bundle/release/app-release.aab
# APK: build/app/outputs/flutter-apk/app-release.apk
Flutter flavors map to Android build variants and iOS schemes. Each flavor has a separate main_*.dart entry point with environment-specific config (API URLs, Firebase projects, feature flags).
// lib/config/environment.dart
enum Environment { dev, staging, prod }
class Env {
static late Environment _current;
static void init(Environment env) => _current = env;
static String get apiUrl => switch (_current) {
Environment.dev => 'https://dev.api.example.com',
Environment.staging => 'https://staging.api.example.com',
Environment.prod => 'https://api.example.com',
};
static bool get showDevTools => _current != Environment.prod;
}
// lib/main_dev.dart
void main() {
Env.init(Environment.dev);
runApp(const MyApp());
}
// lib/main_prod.dart
void main() {
Env.init(Environment.prod);
runApp(const MyApp());
}
// Run: flutter run -t lib/main_dev.dart --flavor dev
// Build: flutter build apk -t lib/main_prod.dart --flavor prod
Pass --obfuscate and --split-debug-info to the build command. The debug symbols file lets you de-obfuscate crash stack traces locally. Store the symbols file — you’ll need it to read crash reports.
# Build with obfuscation
flutter build apk --release \
--obfuscate \
--split-debug-info=./debug-info/
# AAB for Play Store
flutter build appbundle --release \
--obfuscate \
--split-debug-info=./debug-info/
# De-obfuscate a crash stack trace
flutter symbolize \
--input=crash_stack.txt \
--debug-info=./debug-info/app.android-arm64.symbols
# Keep the debug-info/ directory — commit it to a private repo
# or store in CI artifacts. Without it, crash traces are unreadable.
Run flutter build web to produce a static site in build/web/. Use the canvaskit renderer for pixel-perfect rendering (larger, slower initial load) or html renderer for faster startup. Deploy to Firebase Hosting, Netlify, Vercel, or any static host.
# Build for web with canvaskit renderer
flutter build web --release --web-renderer canvaskit
# Build with html renderer (faster initial load, less fidelity)
flutter build web --release --web-renderer html
# Auto-select based on device (default)
flutter build web --release --web-renderer auto
# Deploy to Firebase Hosting
firebase deploy --only hosting
# Or serve locally to test the build
cd build/web && python3 -m http.server 8000
# Set base href for sub-path deployment
# flutter build web --base-href /myapp/
Create a GitHub Actions workflow that installs Flutter, runs analysis and tests, then builds the app. Cache the Flutter installation and pub packages to speed up subsequent runs.
# .github/workflows/ci.yml
name: Flutter CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.22.0'
channel: 'stable'
cache: true # cache Flutter SDK
- name: Install dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze --fatal-infos
- name: Test
run: flutter test --coverage
- name: Build APK
run: flutter build apk --release
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
Add firebase_crashlytics to your project. Set it as Flutter’s error handler and Dart’s uncaught exception zone. In production, all uncaught errors are automatically uploaded with device info, app version, and stack traces.
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
// Catch Flutter framework errors
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
// Catch async errors outside Flutter (e.g., isolates)
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};
runApp(const MyApp());
}
// Log non-fatal errors manually
try {
await riskyOperation();
} catch (e, stack) {
await FirebaseCrashlytics.instance.recordError(e, stack, fatal: false);
}
// Add custom context to crash reports
FirebaseCrashlytics.instance.setCustomKey('user_id', user.id);
FirebaseCrashlytics.instance.log('User navigated to checkout');
A production Flutter app layers clean architecture: presentation (widgets, pages), domain (use cases, models), and data (repositories, API clients). Use feature-based folder structure, Riverpod for state, go_router for navigation, and Dio for networking.
// Architecture layers:
// lib/
// core/
// network/ — Dio client, interceptors
// storage/ — SharedPrefs, SQLite
// errors/ — AppException, Failure
// features/
// auth/
// data/
// auth_repository_impl.dart
// auth_api.dart
// domain/
// auth_repository.dart (abstract)
// login_usecase.dart
// presentation/
// auth_providers.dart (Riverpod)
// login_page.dart
// login_form.dart
// home/
// ...
// Example: clean use case
class LoginUseCase {
const LoginUseCase(this._repository);
final AuthRepository _repository;
Future<User> call(String email, String password) async {
if (!email.contains('@')) throw const AppException('Invalid email');
return _repository.login(email, password);
}
}
// Riverpod provider wires it together
final loginProvider = AsyncNotifierProvider<LoginNotifier, void>(() {
return LoginNotifier(
loginUseCase: LoginUseCase(ref.watch(authRepositoryProvider)),
);
});
// Router with auth guard
final routerProvider = Provider((ref) {
final isAuth = ref.watch(authStateProvider);
return GoRouter(
redirect: (_, state) {
if (!isAuth && state.matchedLocation != '/login') return '/login';
if (isAuth && state.matchedLocation == '/login') return '/home';
return null;
},
routes: [ /* ... */ ],
);
});