
Flutter Dev
- 8 installs
- 33 repo stars
- Updated April 26, 2026
- bighardperson/computer-science-skills-collection
This is a copy of flutter-dev by minimax-ai - installs and ranking accrue to the original listing.
flutter-dev is a skill that guides cross-platform Flutter and Dart development covering widgets, Riverpod/Bloc state, GoRouter navigation, and performance optimization.
About
flutter-dev is a Flutter cross-platform development guide covering widget patterns, Riverpod and Bloc state management, GoRouter navigation, performance optimization, and platform-specific implementations. A developer uses it when building Flutter apps to apply const optimization, responsive layouts, testing strategies, and DevTools profiling. It bundles topic-specific reference files for each area.
- Cross-platform Flutter 3 / Dart development guide
- Covers Riverpod and Bloc state, GoRouter navigation, and performance tuning
- Ships 13 reference files including testing, animations, and networking
Flutter Dev by the numbers
- 8 all-time installs (skills.sh)
- Data as of Jul 30, 2026 (Skillselion catalog sync)
flutter-dev capabilities & compatibility
Free; a reference guide needing only the Flutter SDK, no API keys.
- Capabilities
- flutter widgets · state management · navigation · performance tuning
- Use cases
- frontend · testing
- Pricing
- Free
What flutter-dev says it does
A practical guide for building cross-platform applications with Flutter 3 and Dart.
Riverpod for dependency injection and simple state
npx skills add https://github.com/bighardperson/computer-science-skills-collection --skill flutter-devAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 8 |
|---|---|
| repo stars | ★ 33 |
| Last updated | April 26, 2026 |
| Repository | bighardperson/computer-science-skills-collection ↗ |
What it does
Build cross-platform Flutter apps with Riverpod/Bloc state, GoRouter navigation, and performance-tuned widgets.
Who is it for?
Building and optimizing cross-platform Flutter apps with modern state and navigation patterns.
Skip if: Native-only iOS/Android work outside Flutter or non-Dart stacks.
When should I use this skill?
The user is building a Flutter app, setting up Riverpod/Bloc or GoRouter, or optimizing Flutter performance.
What you get
- Flutter widgets, state, and navigation following the guide's patterns
By the numbers
- 13 reference files across widgets, state, navigation, and testing
- responsive breakpoints: mobile <650, tablet 650-1100, desktop >1100
- 8pt spacing increments
Files
Flutter Development Guide
A practical guide for building cross-platform applications with Flutter 3 and Dart. Focuses on proven patterns, state management, and performance optimization.
Quick Reference
Widget Patterns
| Purpose | Component |
|---|---|
| State management (simple) | StateProvider + ConsumerWidget |
| State management (complex) | NotifierProvider / Bloc |
| Async data | FutureProvider / AsyncNotifierProvider |
| Real-time streams | StreamProvider |
| Navigation | GoRouter + context.go/push |
| Responsive layout | LayoutBuilder + breakpoints |
| List display | ListView.builder |
| Complex scrolling | CustomScrollView + Slivers |
| Hooks | HookWidget + useState/useEffect |
| Forms | Form + TextFormField + validation |
Performance Patterns
| Purpose | Solution |
|---|---|
| Prevent rebuilds | const constructors |
| Selective updates | ref.watch(provider.select(...)) |
| Isolate repaints | RepaintBoundary |
| Lazy lists | ListView.builder |
| Heavy computation | compute() isolate |
| Image caching | cached_network_image |
Core Principles
Widget Optimization
- Use
constconstructors wherever possible - Extract static widgets to separate const classes
- Use
Keyfor list items (ValueKey, ObjectKey) - Prefer
ConsumerWidgetoverStatefulWidgetfor state
State Management
- Riverpod for dependency injection and simple state
- Bloc/Cubit for event-driven workflows and complex logic
- Never mutate state directly (create new instances)
- Use
select()to minimize rebuilds
Layout
- 8pt spacing increments (8, 16, 24, 32, 48)
- Responsive breakpoints: mobile (<650), tablet (650-1100), desktop (>1100)
- Support all screen sizes with flexible layouts
- Follow Material 3 / Cupertino design guidelines
Performance
- Profile with DevTools before optimizing
- Target <16ms frame time for 60fps
- Use
RepaintBoundaryfor complex animations - Offload heavy work with
compute()
Checklist
Widget Best Practices
- [ ]
constconstructors on all static widgets - [ ] Proper
Keyon list items - [ ]
ConsumerWidgetfor state-dependent widgets - [ ] No widget building inside
build()method - [ ] Extract reusable widgets to separate files
State Management
- [ ] Immutable state objects
- [ ]
select()for granular rebuilds - [ ] Proper provider scoping
- [ ] Dispose controllers and subscriptions
- [ ] Handle loading/error states
Navigation
- [ ] GoRouter with typed routes
- [ ] Auth guards via redirect
- [ ] Deep linking support
- [ ] State preservation across routes
Performance
- [ ] Profile mode testing (
flutter run --profile) - [ ] <16ms frame rendering time
- [ ] No unnecessary rebuilds (DevTools check)
- [ ] Images cached and resized
- [ ] Heavy computation in isolates
Testing
- [ ] Widget tests for UI components
- [ ] Unit tests for business logic
- [ ] Integration tests for user flows
- [ ] Bloc tests with
blocTest()
References
| Topic | Reference |
|---|---|
| Widget patterns, const optimization, responsive layout | Widget Patterns |
| Riverpod providers, notifiers, async state | Riverpod State Management |
| Bloc, Cubit, event-driven state | Bloc State Management |
| GoRouter setup, routes, deep linking | GoRouter Navigation |
| Feature-based structure, dependencies | Project Structure |
| Profiling, const optimization, DevTools | Performance Optimization |
| Widget tests, integration tests, mocking | Testing Strategies |
| iOS/Android/Web specific implementations | Platform Integration |
| Implicit/explicit animations, Hero, transitions | Animations |
| Dio, interceptors, error handling, caching | Networking |
| Form validation, FormField, input formatters | Forms |
| i18n, flutter_localizations, intl | Localization |
---
Flutter, Dart, Material Design, and Cupertino are trademarks of Google LLC and Apple Inc. respectively. Riverpod, Bloc, and GoRouter are open-source packages by their respective maintainers.
{
"name": "Flutter 开发",
"installedAt": 1776152084729,
"source": "marketplace",
"iconSource": "flutter-dev"
}Animations
Flutter animation patterns covering implicit animations, explicit animations, Hero transitions, and page transitions.
Implicit Animations
Use implicit animations for simple property changes:
class ImplicitAnimationExample extends StatefulWidget {
const ImplicitAnimationExample({super.key});
@override
State<ImplicitAnimationExample> createState() => _ImplicitAnimationExampleState();
}
class _ImplicitAnimationExampleState extends State<ImplicitAnimationExample> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: _expanded ? 200 : 100,
height: _expanded ? 200 : 100,
decoration: BoxDecoration(
color: _expanded ? Colors.blue : Colors.red,
borderRadius: BorderRadius.circular(_expanded ? 16 : 8),
),
child: const Center(child: Text('Tap me')),
),
);
}
}Common Implicit Widgets
| Widget | Animates |
|---|---|
AnimatedContainer | Size, color, padding, decoration |
AnimatedOpacity | Opacity |
AnimatedPadding | Padding |
AnimatedPositioned | Position in Stack |
AnimatedAlign | Alignment |
AnimatedCrossFade | Cross-fade between two widgets |
AnimatedSwitcher | Transition between child widgets |
AnimatedDefaultTextStyle | Text style |
AnimatedScale | Scale transform |
AnimatedRotation | Rotation transform |
AnimatedSlide | Slide offset |
AnimatedSwitcher
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.1),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
child: _showFirst
? const Icon(Icons.check, key: ValueKey('check'))
: const Icon(Icons.close, key: ValueKey('close')),
)Explicit Animations
Use explicit animations for complex, custom, or controlled animations:
class ExplicitAnimationExample extends StatefulWidget {
const ExplicitAnimationExample({super.key});
@override
State<ExplicitAnimationExample> createState() => _ExplicitAnimationExampleState();
}
class _ExplicitAnimationExampleState extends State<ExplicitAnimationExample>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _scaleAnimation;
late final Animation<double> _rotationAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.2).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
_rotationAnimation = Tween<double>(begin: 0, end: 0.1).animate(
CurvedAnimation(parent: _controller, curve: Curves.elasticOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => _controller.forward(),
onTapUp: (_) => _controller.reverse(),
onTapCancel: () => _controller.reverse(),
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Transform.rotate(
angle: _rotationAnimation.value,
child: child,
),
);
},
child: const Card(child: Padding(padding: EdgeInsets.all(24), child: Text('Press me'))),
),
);
}
}Animation with Hooks
import 'package:flutter_hooks/flutter_hooks.dart';
class AnimatedButtonHook extends HookWidget {
const AnimatedButtonHook({super.key});
@override
Widget build(BuildContext context) {
final controller = useAnimationController(
duration: const Duration(milliseconds: 300),
);
final scale = useAnimation(
Tween<double>(begin: 1.0, end: 0.95).animate(
CurvedAnimation(parent: controller, curve: Curves.easeInOut),
),
);
return GestureDetector(
onTapDown: (_) => controller.forward(),
onTapUp: (_) => controller.reverse(),
onTapCancel: () => controller.reverse(),
child: Transform.scale(
scale: scale,
child: const Card(child: Text('Animated Button')),
),
);
}
}Staggered Animations
class StaggeredAnimation extends StatefulWidget {
const StaggeredAnimation({super.key});
@override
State<StaggeredAnimation> createState() => _StaggeredAnimationState();
}
class _StaggeredAnimationState extends State<StaggeredAnimation>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final List<Animation<double>> _itemAnimations;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
);
_itemAnimations = List.generate(5, (index) {
final start = index * 0.1;
final end = start + 0.4;
return Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(start, end.clamp(0, 1), curve: Curves.easeOut),
),
);
});
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: List.generate(5, (index) {
return AnimatedBuilder(
animation: _itemAnimations[index],
builder: (context, child) {
return Opacity(
opacity: _itemAnimations[index].value,
child: Transform.translate(
offset: Offset(0, 20 * (1 - _itemAnimations[index].value)),
child: child,
),
);
},
child: ListTile(title: Text('Item $index')),
);
}),
);
}
}Hero Animations
class HeroSourcePage extends StatelessWidget {
const HeroSourcePage({super.key});
@override
Widget build(BuildContext context) {
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return GestureDetector(
onTap: () => context.push('/detail/${item.id}'),
child: Hero(
tag: 'hero-${item.id}',
child: Image.network(item.imageUrl, fit: BoxFit.cover),
),
);
},
);
}
}
class HeroDetailPage extends StatelessWidget {
final String itemId;
const HeroDetailPage({super.key, required this.itemId});
@override
Widget build(BuildContext context) {
final item = getItem(itemId);
return Scaffold(
body: Column(
children: [
Hero(
tag: 'hero-${item.id}',
child: Image.network(item.imageUrl, fit: BoxFit.cover),
),
Padding(
padding: const EdgeInsets.all(16),
child: Text(item.title, style: Theme.of(context).textTheme.headlineMedium),
),
],
),
);
}
}Hero with Custom Flight
Hero(
tag: 'avatar-$userId',
flightShuttleBuilder: (
flightContext,
animation,
flightDirection,
fromHeroContext,
toHeroContext,
) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Material(
color: Colors.transparent,
child: CircleAvatar(
radius: lerpDouble(24, 48, animation.value),
backgroundImage: NetworkImage(avatarUrl),
),
);
},
);
},
child: CircleAvatar(radius: 24, backgroundImage: NetworkImage(avatarUrl)),
)Page Transitions
GoRouter Custom Transitions
GoRoute(
path: '/detail/:id',
pageBuilder: (context, state) {
return CustomTransitionPage(
key: state.pageKey,
child: DetailPage(id: state.pathParameters['id']!),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.05),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
)),
child: child,
),
);
},
);
},
)Common Transition Patterns
extension PageTransitions on CustomTransitionPage {
static CustomTransitionPage<T> fade<T>({
required LocalKey key,
required Widget child,
}) {
return CustomTransitionPage<T>(
key: key,
child: child,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
);
}
static CustomTransitionPage<T> slideUp<T>({
required LocalKey key,
required Widget child,
}) {
return CustomTransitionPage<T>(
key: key,
child: child,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
)),
child: child,
);
},
);
}
static CustomTransitionPage<T> scale<T>({
required LocalKey key,
required Widget child,
}) {
return CustomTransitionPage<T>(
key: key,
child: child,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return ScaleTransition(
scale: Tween<double>(begin: 0.9, end: 1).animate(
CurvedAnimation(parent: animation, curve: Curves.easeOut),
),
child: FadeTransition(opacity: animation, child: child),
);
},
);
}
}Shared Axis Transition
import 'package:animations/animations.dart';
GoRoute(
path: '/settings',
pageBuilder: (context, state) {
return CustomTransitionPage(
key: state.pageKey,
child: const SettingsPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SharedAxisTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: child,
);
},
);
},
)Common Curves
| Curve | Usage |
|---|---|
Curves.easeInOut | General purpose (default) |
Curves.easeOut | Deceleration (entering) |
Curves.easeIn | Acceleration (exiting) |
Curves.elasticOut | Bouncy effect |
Curves.bounceOut | Bounce at end |
Curves.fastOutSlowIn | Material standard |
Curves.easeOutCubic | Smooth deceleration |
Animation Performance
class PerformantAnimation extends StatelessWidget {
const PerformantAnimation({super.key});
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Transform.translate(
offset: Offset(animation.value * 100, 0),
child: child,
);
},
child: const ExpensiveWidget(),
),
);
}
}Performance Tips
| Tip | Implementation |
|---|---|
Use child parameter | Pass static content to child in AnimatedBuilder |
RepaintBoundary | Isolate animated widgets |
Avoid Opacity widget | Use FadeTransition instead |
| Prefer transforms | Transform is cheaper than layout changes |
| Pre-compute values | Calculate in initState, not build |
Animation Checklist
| Item | Implementation |
|---|---|
| Simple animations | Use implicit widgets |
| Complex sequences | Use AnimationController |
| Widget transitions | AnimatedSwitcher with key |
| Cross-page elements | Hero with unique tags |
| Page transitions | CustomTransitionPage |
| Performance | RepaintBoundary + child parameter |
---
Flutter and Material Design are trademarks of Google LLC.
Bloc State Management
Bloc state management guide covering events, states, Cubit, and widget integration for complex business logic.
When to Use Bloc
Use Bloc/Cubit when you need:
- Explicit event → state transitions
- Complex business logic with multiple events
- Predictable, testable state flows
- Clear separation between UI and logic
| Use Case | Recommended |
|---|---|
| Simple mutable state | Riverpod |
| Computed values | Riverpod |
| Event-driven workflows | Bloc |
| Forms, auth, wizards | Bloc |
| Feature modules with complex logic | Bloc |
Core Concepts
| Concept | Description |
|---|---|
| Event | User or system input that triggers state change |
| State | Immutable representation of UI state |
| Bloc | Maps events to new states |
| Cubit | Simplified Bloc without events |
Cubit (Recommended for Simpler Logic)
import 'package:flutter_bloc/flutter_bloc.dart';
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
void reset() => emit(0);
}Full Bloc Setup
Event Definition
sealed class CounterEvent {}
final class CounterIncremented extends CounterEvent {}
final class CounterDecremented extends CounterEvent {}
final class CounterReset extends CounterEvent {}State Definition
class CounterState {
final int value;
final bool isLoading;
const CounterState({
required this.value,
this.isLoading = false,
});
CounterState copyWith({int? value, bool? isLoading}) {
return CounterState(
value: value ?? this.value,
isLoading: isLoading ?? this.isLoading,
);
}
}Bloc Implementation
class CounterBloc extends Bloc<CounterEvent, CounterState> {
CounterBloc() : super(const CounterState(value: 0)) {
on<CounterIncremented>(_onIncremented);
on<CounterDecremented>(_onDecremented);
on<CounterReset>(_onReset);
}
void _onIncremented(CounterIncremented event, Emitter<CounterState> emit) {
emit(state.copyWith(value: state.value + 1));
}
void _onDecremented(CounterDecremented event, Emitter<CounterState> emit) {
emit(state.copyWith(value: state.value - 1));
}
void _onReset(CounterReset event, Emitter<CounterState> emit) {
emit(const CounterState(value: 0));
}
}Providing Bloc to Widget Tree
// Single bloc
BlocProvider(
create: (_) => CounterBloc(),
child: const CounterScreen(),
);
// Multiple blocs
MultiBlocProvider(
providers: [
BlocProvider(create: (_) => AuthBloc()),
BlocProvider(create: (_) => ProfileBloc()),
BlocProvider(create: (_) => SettingsBloc()),
],
child: const AppRoot(),
);Using Bloc in Widgets
BlocBuilder (UI Rebuilds)
class CounterScreen extends StatelessWidget {
const CounterScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<CounterBloc, CounterState>(
buildWhen: (prev, curr) => prev.value != curr.value,
builder: (context, state) {
return Text(
state.value.toString(),
style: Theme.of(context).textTheme.displayLarge,
);
},
);
}
}BlocListener (Side Effects)
BlocListener<AuthBloc, AuthState>(
listenWhen: (prev, curr) => prev.status != curr.status,
listener: (context, state) {
if (state.status == AuthStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.errorMessage ?? 'Error')),
);
}
if (state.status == AuthStatus.authenticated) {
context.go('/home');
}
},
child: const LoginForm(),
);BlocConsumer (Builder + Listener)
BlocConsumer<FormBloc, FormState>(
listenWhen: (prev, curr) => prev.status != curr.status,
listener: (context, state) {
if (state.status == FormStatus.success) {
context.pop();
}
},
buildWhen: (prev, curr) => prev.isValid != curr.isValid,
builder: (context, state) {
return ElevatedButton(
onPressed: state.isValid
? () => context.read<FormBloc>().add(FormSubmitted())
: null,
child: const Text('Submit'),
);
},
);BlocSelector (Granular Rebuilds)
BlocSelector<UserBloc, UserState, String>(
selector: (state) => state.user.name,
builder: (context, name) {
return Text('Hello, $name');
},
);Async Bloc Pattern
on<UserRequested>((event, emit) async {
emit(state.copyWith(status: UserStatus.loading));
try {
final user = await repository.fetchUser(event.userId);
emit(state.copyWith(status: UserStatus.success, user: user));
} catch (e) {
emit(state.copyWith(status: UserStatus.failure, error: e.toString()));
}
});Bloc + GoRouter Auth Guard
redirect: (context, state) {
final authState = context.read<AuthBloc>().state;
final isAuthRoute = state.matchedLocation.startsWith('/auth');
if (authState.status != AuthStatus.authenticated && !isAuthRoute) {
return '/auth/login';
}
if (authState.status == AuthStatus.authenticated && isAuthRoute) {
return '/';
}
return null;
}Testing Bloc
import 'package:bloc_test/bloc_test.dart';
blocTest<CounterBloc, CounterState>(
'emits incremented value when CounterIncremented added',
build: () => CounterBloc(),
act: (bloc) => bloc.add(CounterIncremented()),
expect: () => [const CounterState(value: 1)],
);
blocTest<CounterBloc, CounterState>(
'emits multiple states',
build: () => CounterBloc(),
act: (bloc) {
bloc.add(CounterIncremented());
bloc.add(CounterIncremented());
bloc.add(CounterDecremented());
},
expect: () => [
const CounterState(value: 1),
const CounterState(value: 2),
const CounterState(value: 1),
],
);Best Practices
| Do | Don't |
|---|---|
| Keep states immutable | Mutate state directly |
| Use small, focused blocs | Create "god blocs" with everything |
| One feature = one bloc | Share blocs across unrelated features |
| Use Cubit for simple cases | Overcomplicate with Bloc unnecessarily |
| Test all state transitions | Skip bloc testing |
Use buildWhen/listenWhen | Rebuild on every state change |
Widget Reference
| Widget | Purpose |
|---|---|
BlocBuilder | UI rebuilds based on state |
BlocListener | Side effects (navigation, snackbar) |
BlocConsumer | Both builder and listener |
BlocSelector | Granular state selection |
BlocProvider | Dependency injection |
MultiBlocProvider | Multiple bloc injection |
RepositoryProvider | Repository injection |
---
Bloc is an open-source state management library by Felix Angelov.
Forms
Form validation, FormField patterns, input formatting, and reusable form components for Flutter.
Basic Form Setup
class LoginForm extends StatefulWidget {
const LoginForm({super.key});
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
await authService.login(
email: _emailController.text.trim(),
password: _passwordController.text,
);
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autocorrect: false,
validator: Validators.email,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock_outlined),
),
obscureText: true,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
validator: Validators.password,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _submit,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Login'),
),
],
),
);
}
}Validators
class Validators {
static String? required(String? value) {
if (value == null || value.trim().isEmpty) {
return 'This field is required';
}
return null;
}
static String? email(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Email is required';
}
final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!regex.hasMatch(value.trim())) {
return 'Enter a valid email address';
}
return null;
}
static String? password(String? value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 8) {
return 'Password must be at least 8 characters';
}
return null;
}
static String? strongPassword(String? value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 8) {
return 'Password must be at least 8 characters';
}
if (!RegExp(r'[A-Z]').hasMatch(value)) {
return 'Password must contain an uppercase letter';
}
if (!RegExp(r'[a-z]').hasMatch(value)) {
return 'Password must contain a lowercase letter';
}
if (!RegExp(r'[0-9]').hasMatch(value)) {
return 'Password must contain a number';
}
return null;
}
static String? phone(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Phone number is required';
}
final digits = value.replaceAll(RegExp(r'\D'), '');
if (digits.length < 10 || digits.length > 15) {
return 'Enter a valid phone number';
}
return null;
}
static String? minLength(int min) {
return (String? value) {
if (value == null || value.length < min) {
return 'Must be at least $min characters';
}
return null;
};
}
static String? maxLength(int max) {
return (String? value) {
if (value != null && value.length > max) {
return 'Must be at most $max characters';
}
return null;
};
}
static String? Function(String?) combine(List<String? Function(String?)> validators) {
return (String? value) {
for (final validator in validators) {
final error = validator(value);
if (error != null) return error;
}
return null;
};
}
static String? match(String pattern, String message) {
return (String? value) {
if (value != null && !RegExp(pattern).hasMatch(value)) {
return message;
}
return null;
};
}
static String? confirmPassword(TextEditingController passwordController) {
return (String? value) {
if (value != passwordController.text) {
return 'Passwords do not match';
}
return null;
};
}
}Input Formatters
import 'package:flutter/services.dart';
class PhoneInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
final buffer = StringBuffer();
for (int i = 0; i < digits.length && i < 10; i++) {
if (i == 3 || i == 6) buffer.write('-');
buffer.write(digits[i]);
}
return TextEditingValue(
text: buffer.toString(),
selection: TextSelection.collapsed(offset: buffer.length),
);
}
}
class CreditCardFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
final buffer = StringBuffer();
for (int i = 0; i < digits.length && i < 16; i++) {
if (i > 0 && i % 4 == 0) buffer.write(' ');
buffer.write(digits[i]);
}
return TextEditingValue(
text: buffer.toString(),
selection: TextSelection.collapsed(offset: buffer.length),
);
}
}
class CurrencyInputFormatter extends TextInputFormatter {
final int decimalPlaces;
CurrencyInputFormatter({this.decimalPlaces = 2});
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
if (newValue.text.isEmpty) return newValue;
final digits = newValue.text.replaceAll(RegExp(r'[^\d]'), '');
if (digits.isEmpty) return const TextEditingValue(text: '');
final value = int.parse(digits) / 100;
final formatted = value.toStringAsFixed(decimalPlaces);
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
}
class UpperCaseFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
return newValue.copyWith(text: newValue.text.toUpperCase());
}
}Using Formatters
TextFormField(
decoration: const InputDecoration(labelText: 'Phone'),
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
PhoneInputFormatter(),
],
)
TextFormField(
decoration: const InputDecoration(labelText: 'Amount'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[\d.]')),
CurrencyInputFormatter(),
],
)Custom FormFields
Dropdown FormField
class DropdownFormField<T> extends FormField<T> {
DropdownFormField({
super.key,
required List<DropdownMenuItem<T>> items,
super.initialValue,
super.validator,
super.onSaved,
String? labelText,
String? hintText,
ValueChanged<T?>? onChanged,
}) : super(
builder: (state) {
return InputDecorator(
decoration: InputDecoration(
labelText: labelText,
errorText: state.errorText,
),
child: DropdownButtonHideUnderline(
child: DropdownButton<T>(
value: state.value,
hint: hintText != null ? Text(hintText) : null,
isExpanded: true,
items: items,
onChanged: (value) {
state.didChange(value);
onChanged?.call(value);
},
),
),
);
},
);
}Checkbox FormField
class CheckboxFormField extends FormField<bool> {
CheckboxFormField({
super.key,
required Widget label,
super.initialValue = false,
super.validator,
super.onSaved,
}) : super(
builder: (state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Checkbox(
value: state.value ?? false,
onChanged: state.didChange,
),
Expanded(child: GestureDetector(
onTap: () => state.didChange(!(state.value ?? false)),
child: label,
)),
],
),
if (state.hasError)
Padding(
padding: const EdgeInsets.only(left: 12, top: 4),
child: Text(
state.errorText!,
style: TextStyle(
color: Theme.of(state.context).colorScheme.error,
fontSize: 12,
),
),
),
],
);
},
);
}Date Picker FormField
class DatePickerFormField extends FormField<DateTime> {
DatePickerFormField({
super.key,
super.initialValue,
super.validator,
super.onSaved,
String? labelText,
DateTime? firstDate,
DateTime? lastDate,
}) : super(
builder: (state) {
return GestureDetector(
onTap: () async {
final picked = await showDatePicker(
context: state.context,
initialDate: state.value ?? DateTime.now(),
firstDate: firstDate ?? DateTime(1900),
lastDate: lastDate ?? DateTime(2100),
);
if (picked != null) {
state.didChange(picked);
}
},
child: InputDecorator(
decoration: InputDecoration(
labelText: labelText,
errorText: state.errorText,
suffixIcon: const Icon(Icons.calendar_today),
),
child: Text(
state.value != null
? DateFormat.yMMMd().format(state.value!)
: 'Select date',
),
),
);
},
);
}Form with Hooks
import 'package:flutter_hooks/flutter_hooks.dart';
class HookLoginForm extends HookWidget {
const HookLoginForm({super.key});
@override
Widget build(BuildContext context) {
final formKey = useMemoized(GlobalKey<FormState>.new);
final emailController = useTextEditingController();
final passwordController = useTextEditingController();
final emailFocus = useFocusNode();
final passwordFocus = useFocusNode();
final isLoading = useState(false);
Future<void> submit() async {
if (!formKey.currentState!.validate()) return;
isLoading.value = true;
try {
await authService.login(
email: emailController.text.trim(),
password: passwordController.text,
);
} finally {
isLoading.value = false;
}
}
return Form(
key: formKey,
child: Column(
children: [
TextFormField(
controller: emailController,
focusNode: emailFocus,
decoration: const InputDecoration(labelText: 'Email'),
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => passwordFocus.requestFocus(),
validator: Validators.email,
),
const SizedBox(height: 16),
TextFormField(
controller: passwordController,
focusNode: passwordFocus,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
onFieldSubmitted: (_) => submit(),
validator: Validators.password,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: isLoading.value ? null : submit,
child: isLoading.value
? const CircularProgressIndicator()
: const Text('Login'),
),
],
),
);
}
}Server-Side Validation
class ServerValidationForm extends StatefulWidget {
const ServerValidationForm({super.key});
@override
State<ServerValidationForm> createState() => _ServerValidationFormState();
}
class _ServerValidationFormState extends State<ServerValidationForm> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
Map<String, List<String>> _serverErrors = {};
String? _emailValidator(String? value) {
final clientError = Validators.email(value);
if (clientError != null) return clientError;
final serverError = _serverErrors['email'];
if (serverError != null && serverError.isNotEmpty) {
return serverError.first;
}
return null;
}
Future<void> _submit() async {
setState(() => _serverErrors = {});
if (!_formKey.currentState!.validate()) return;
try {
await api.register(email: _emailController.text);
} on ValidationException catch (e) {
setState(() => _serverErrors = e.errors);
_formKey.currentState!.validate();
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'Email'),
validator: _emailValidator,
onChanged: (_) {
if (_serverErrors.containsKey('email')) {
setState(() => _serverErrors.remove('email'));
}
},
),
ElevatedButton(
onPressed: _submit,
child: const Text('Register'),
),
],
),
);
}
}Auto-Save Form
class AutoSaveForm extends StatefulWidget {
const AutoSaveForm({super.key});
@override
State<AutoSaveForm> createState() => _AutoSaveFormState();
}
class _AutoSaveFormState extends State<AutoSaveForm> {
final _formKey = GlobalKey<FormState>();
Timer? _debounce;
bool _hasChanges = false;
void _onChanged() {
setState(() => _hasChanges = true);
_debounce?.cancel();
_debounce = Timer(const Duration(seconds: 2), _autoSave);
}
Future<void> _autoSave() async {
if (!_hasChanges) return;
if (!_formKey.currentState!.validate()) return;
_formKey.currentState!.save();
await saveToServer();
setState(() => _hasChanges = false);
}
@override
void dispose() {
_debounce?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
onChanged: _onChanged,
child: Column(
children: [
if (_hasChanges)
const Text('Saving...', style: TextStyle(color: Colors.grey)),
TextFormField(
decoration: const InputDecoration(labelText: 'Title'),
onSaved: (value) => saveField('title', value),
),
TextFormField(
decoration: const InputDecoration(labelText: 'Description'),
maxLines: 3,
onSaved: (value) => saveField('description', value),
),
],
),
);
}
}Common Keyboard Types
| Type | Usage |
|---|---|
TextInputType.text | General text |
TextInputType.emailAddress | Email with @ keyboard |
TextInputType.phone | Phone number pad |
TextInputType.number | Numeric keyboard |
TextInputType.numberWithOptions(decimal: true) | Numbers with decimal |
TextInputType.multiline | Multi-line text |
TextInputType.url | URL with shortcuts |
Form Checklist
| Item | Implementation |
|---|---|
| GlobalKey | GlobalKey<FormState>() for form |
| Dispose controllers | Clean up in dispose() |
| Validation | Client + server-side |
| Input formatters | Phone, currency, etc. |
| Keyboard types | Match input type |
| Text actions | textInputAction for flow |
| Loading state | Disable during submission |
| Error display | Show below fields |
---
Flutter and Material Design are trademarks of Google LLC.
GoRouter Navigation
GoRouter navigation guide covering route setup, guards, deep linking, and shell routes.
Basic Setup
import 'package:go_router/go_router.dart';
final goRouter = GoRouter(
initialLocation: '/',
debugLogDiagnostics: true,
redirect: (context, state) {
final isLoggedIn = /* check auth state */;
final isAuthRoute = state.matchedLocation.startsWith('/auth');
if (!isLoggedIn && !isAuthRoute) {
return '/auth/login';
}
if (isLoggedIn && isAuthRoute) {
return '/';
}
return null;
},
routes: [
GoRoute(
path: '/',
name: 'home',
builder: (context, state) => const HomeScreen(),
routes: [
GoRoute(
path: 'details/:id',
name: 'details',
builder: (context, state) {
final id = state.pathParameters['id']!;
final extra = state.extra as Map<String, dynamic>?;
return DetailsScreen(id: id, title: extra?['title']);
},
),
],
),
GoRoute(
path: '/auth/login',
name: 'login',
builder: (context, state) => const LoginScreen(),
),
],
);App Integration
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: goRouter,
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: ThemeMode.system,
);
}
}Navigation Methods
// Navigate and replace entire stack
context.go('/details/123');
// Navigate and add to stack (can go back)
context.push('/details/123');
// Go back
context.pop();
// Go back with result
context.pop(result);
// Replace current route
context.pushReplacement('/home');
// Navigate with extra data
context.push('/details/123', extra: {'title': 'Item Title'});
// Navigate by name
context.goNamed('details', pathParameters: {'id': '123'});
context.pushNamed('details', pathParameters: {'id': '123'}, extra: data);Navigation Reference
| Method | Behavior |
|---|---|
context.go() | Navigate, replace entire stack |
context.push() | Navigate, add to stack |
context.pop() | Go back one level |
context.pushReplacement() | Replace current route |
context.goNamed() | Navigate by route name |
context.canPop() | Check if can go back |
Shell Routes (Persistent UI)
final goRouter = GoRouter(
routes: [
ShellRoute(
builder: (context, state, child) {
return ScaffoldWithNavBar(child: child);
},
routes: [
GoRoute(
path: '/home',
builder: (_, __) => const HomeScreen(),
),
GoRoute(
path: '/search',
builder: (_, __) => const SearchScreen(),
),
GoRoute(
path: '/profile',
builder: (_, __) => const ProfileScreen(),
),
],
),
],
);
class ScaffoldWithNavBar extends StatelessWidget {
final Widget child;
const ScaffoldWithNavBar({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: _calculateSelectedIndex(context),
onDestinationSelected: (index) => _onItemTapped(index, context),
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
int _calculateSelectedIndex(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
if (location.startsWith('/home')) return 0;
if (location.startsWith('/search')) return 1;
if (location.startsWith('/profile')) return 2;
return 0;
}
void _onItemTapped(int index, BuildContext context) {
switch (index) {
case 0: context.go('/home');
case 1: context.go('/search');
case 2: context.go('/profile');
}
}
}Query Parameters
GoRoute(
path: '/search',
builder: (context, state) {
final query = state.uri.queryParameters['q'] ?? '';
final page = int.tryParse(state.uri.queryParameters['page'] ?? '1') ?? 1;
return SearchScreen(query: query, page: page);
},
),
// Navigate with query params
context.go('/search?q=flutter&page=2');
context.goNamed('search', queryParameters: {'q': 'flutter', 'page': '2'});Riverpod Integration
final routerProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authProvider);
return GoRouter(
refreshListenable: authState,
redirect: (context, state) {
final isLoggedIn = authState.isAuthenticated;
final isAuthRoute = state.matchedLocation.startsWith('/auth');
if (!isLoggedIn && !isAuthRoute) return '/auth/login';
if (isLoggedIn && isAuthRoute) return '/';
return null;
},
routes: [...],
);
});
// In app.dart
class MyApp extends ConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
return MaterialApp.router(routerConfig: router);
}
}Error Handling
final goRouter = GoRouter(
errorBuilder: (context, state) {
return ErrorScreen(error: state.error);
},
routes: [...],
);Deep Linking
Deep links work automatically when routes are configured with path parameters:
// URL: myapp://details/123
// or: https://myapp.com/details/123
GoRoute(
path: '/details/:id',
builder: (context, state) => DetailsScreen(id: state.pathParameters['id']!),
),Best Practices
| Do | Don't |
|---|---|
| Use named routes for maintainability | Hardcode paths everywhere |
Use push() for detail screens | Use go() for all navigation |
Pass simple data via extra | Pass complex objects via URL |
| Use redirect for auth guards | Check auth in every screen |
| Use ShellRoute for persistent UI | Rebuild nav bar in every screen |
---
GoRouter is an open-source navigation package for Flutter.
Localization
Internationalization (i18n) patterns using flutter_localizations and intl package for Flutter applications.
Setup
Dependencies
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: ^0.19.0
flutter:
generate: truel10n Configuration
# l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
nullable-getter: falseARB Files
English (Template)
// lib/l10n/app_en.arb
{
"@@locale": "en",
"appTitle": "My App",
"@appTitle": {
"description": "The application title"
},
"hello": "Hello",
"welcome": "Welcome, {name}!",
"@welcome": {
"description": "Welcome message with user name",
"placeholders": {
"name": {
"type": "String",
"example": "John"
}
}
},
"itemCount": "{count, plural, =0{No items} =1{1 item} other{{count} items}}",
"@itemCount": {
"description": "Number of items",
"placeholders": {
"count": {
"type": "int"
}
}
},
"lastUpdated": "Last updated: {date}",
"@lastUpdated": {
"description": "Last update timestamp",
"placeholders": {
"date": {
"type": "DateTime",
"format": "yMMMd"
}
}
},
"price": "Price: {amount}",
"@price": {
"description": "Product price",
"placeholders": {
"amount": {
"type": "double",
"format": "currency",
"optionalParameters": {
"symbol": "$",
"decimalDigits": 2
}
}
}
},
"gender": "{gender, select, male{He} female{She} other{They}} liked this",
"@gender": {
"description": "Gender-specific message",
"placeholders": {
"gender": {
"type": "String"
}
}
}
}Chinese
// lib/l10n/app_zh.arb
{
"@@locale": "zh",
"appTitle": "我的应用",
"hello": "你好",
"welcome": "欢迎,{name}!",
"itemCount": "{count, plural, =0{没有项目} other{{count} 个项目}}",
"lastUpdated": "最后更新:{date}",
"price": "价格:{amount}",
"gender": "{gender, select, male{他} female{她} other{Ta}}喜欢了这个"
}Japanese
// lib/l10n/app_ja.arb
{
"@@locale": "ja",
"appTitle": "マイアプリ",
"hello": "こんにちは",
"welcome": "ようこそ、{name}さん!",
"itemCount": "{count, plural, =0{アイテムなし} other{{count}件}}",
"lastUpdated": "最終更新:{date}",
"price": "価格:{amount}",
"gender": "{gender, select, male{彼} female{彼女} other{その人}}がいいねしました"
}App Configuration
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'My App',
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('en'),
Locale('zh'),
Locale('ja'),
],
locale: const Locale('en'),
home: const HomePage(),
);
}
}Using Translations
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(title: Text(l10n.appTitle)),
body: Column(
children: [
Text(l10n.hello),
Text(l10n.welcome('John')),
Text(l10n.itemCount(5)),
Text(l10n.lastUpdated(DateTime.now())),
Text(l10n.price(29.99)),
Text(l10n.gender('female')),
],
),
);
}
}Extension for Convenience
extension LocalizationExtension on BuildContext {
AppLocalizations get l10n => AppLocalizations.of(this);
}
// Usage
Text(context.l10n.hello)Dynamic Locale Switching
With Riverpod
@riverpod
class LocaleNotifier extends _$LocaleNotifier {
@override
Locale build() {
final saved = ref.watch(sharedPreferencesProvider).getString('locale');
if (saved != null) {
return Locale(saved);
}
return const Locale('en');
}
void setLocale(Locale locale) {
ref.read(sharedPreferencesProvider).setString('locale', locale.languageCode);
state = locale;
}
}
class MyApp extends ConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final locale = ref.watch(localeNotifierProvider);
return MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: locale,
home: const HomePage(),
);
}
}Language Selector
class LanguageSelector extends ConsumerWidget {
const LanguageSelector({super.key});
static const languages = [
(Locale('en'), 'English'),
(Locale('zh'), '中文'),
(Locale('ja'), '日本語'),
];
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentLocale = ref.watch(localeNotifierProvider);
return PopupMenuButton<Locale>(
initialValue: currentLocale,
onSelected: (locale) {
ref.read(localeNotifierProvider.notifier).setLocale(locale);
},
itemBuilder: (context) => languages.map((lang) {
return PopupMenuItem(
value: lang.$1,
child: Row(
children: [
if (currentLocale == lang.$1)
const Icon(Icons.check, size: 18),
const SizedBox(width: 8),
Text(lang.$2),
],
),
);
}).toList(),
child: const Icon(Icons.language),
);
}
}Date and Number Formatting
import 'package:intl/intl.dart';
class FormattingUtils {
static String formatDate(DateTime date, String locale) {
return DateFormat.yMMMd(locale).format(date);
}
static String formatDateTime(DateTime dateTime, String locale) {
return DateFormat.yMMMd(locale).add_jm().format(dateTime);
}
static String formatRelativeTime(DateTime dateTime, String locale) {
final now = DateTime.now();
final diff = now.difference(dateTime);
if (diff.inDays > 7) {
return DateFormat.yMMMd(locale).format(dateTime);
} else if (diff.inDays > 0) {
return '${diff.inDays}d ago';
} else if (diff.inHours > 0) {
return '${diff.inHours}h ago';
} else if (diff.inMinutes > 0) {
return '${diff.inMinutes}m ago';
} else {
return 'Just now';
}
}
static String formatCurrency(double amount, String locale, {String? symbol}) {
return NumberFormat.currency(
locale: locale,
symbol: symbol,
decimalDigits: 2,
).format(amount);
}
static String formatNumber(num number, String locale) {
return NumberFormat.decimalPattern(locale).format(number);
}
static String formatPercent(double value, String locale) {
return NumberFormat.percentPattern(locale).format(value);
}
static String formatCompact(num number, String locale) {
return NumberFormat.compact(locale: locale).format(number);
}
}Usage with Locale
class FormattedContent extends StatelessWidget {
const FormattedContent({super.key});
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context).toString();
return Column(
children: [
Text(FormattingUtils.formatDate(DateTime.now(), locale)),
Text(FormattingUtils.formatCurrency(1234.56, locale, symbol: '\$')),
Text(FormattingUtils.formatNumber(1234567, locale)),
Text(FormattingUtils.formatPercent(0.75, locale)),
Text(FormattingUtils.formatCompact(1500000, locale)),
],
);
}
}RTL Support
class RtlAwareWidget extends StatelessWidget {
const RtlAwareWidget({super.key});
@override
Widget build(BuildContext context) {
final isRtl = Directionality.of(context) == TextDirection.rtl;
return Row(
children: [
Icon(isRtl ? Icons.arrow_back : Icons.arrow_forward),
const Expanded(child: Text('Content')),
Padding(
padding: EdgeInsetsDirectional.only(start: 16),
child: const Icon(Icons.settings),
),
],
);
}
}Directional Widgets
| Standard | Directional |
|---|---|
EdgeInsets | EdgeInsetsDirectional |
Padding | Padding with EdgeInsetsDirectional |
Align | AlignmentDirectional |
Positioned | PositionedDirectional |
BorderRadius | BorderRadiusDirectional |
// Use directional
Padding(
padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
child: child,
)
Container(
alignment: AlignmentDirectional.centerStart,
child: child,
)
Container(
decoration: const BoxDecoration(
borderRadius: BorderRadiusDirectional.only(
topStart: Radius.circular(8),
bottomStart: Radius.circular(8),
),
),
)Organized Translations
Split by Feature
lib/
l10n/
app_en.arb # Common translations
app_zh.arb
features/
auth_en.arb # Auth feature translations
auth_zh.arb
settings_en.arb # Settings feature translations
settings_zh.arbNamespaced Keys
// app_en.arb
{
"auth_login": "Login",
"auth_logout": "Logout",
"auth_forgotPassword": "Forgot Password?",
"settings_title": "Settings",
"settings_language": "Language",
"settings_theme": "Theme",
"error_network": "Network error. Please try again.",
"error_unknown": "An unknown error occurred."
}Testing
void main() {
testWidgets('shows localized text', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: const HomePage(),
),
);
expect(find.text('Hello'), findsOneWidget);
});
testWidgets('switches locale', (tester) async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('zh'),
home: const HomePage(),
),
),
);
expect(find.text('你好'), findsOneWidget);
});
}ARB Placeholders Reference
| Type | Format Options |
|---|---|
String | None |
int | compact, compactCurrency, compactLong, compactSimpleCurrency |
double | compact, compactCurrency, currency, decimalPattern, decimalPercentPattern, percentPattern, scientificPattern, simpleCurrency |
DateTime | Any DateFormat pattern (yMd, yMMMd, jm, etc.) |
num | Same as int and double |
Localization Checklist
| Item | Implementation |
|---|---|
| Dependencies | flutter_localizations, intl |
| l10n.yaml | Configure ARB paths and output |
| ARB files | Create for each supported locale |
| App config | Add delegates and supported locales |
| Generate | Run flutter gen-l10n |
| Use translations | AppLocalizations.of(context) |
| Date/number formatting | Use intl formatters with locale |
| RTL support | Use directional widgets |
| Persist preference | Save user's locale choice |
| Testing | Test with different locales |
---
Flutter is a trademark of Google LLC. intl is an open-source package by the Dart team.
Networking
Dio configuration, interceptors, error handling, and caching strategies for Flutter network requests.
Dio Setup
import 'package:dio/dio.dart';
class ApiClient {
static final ApiClient _instance = ApiClient._internal();
factory ApiClient() => _instance;
late final Dio dio;
ApiClient._internal() {
dio = Dio(BaseOptions(
baseUrl: 'https://api.example.com/v1',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
));
dio.interceptors.addAll([
AuthInterceptor(),
LoggingInterceptor(),
RetryInterceptor(dio: dio),
]);
}
}Interceptors
Auth Interceptor
class AuthInterceptor extends Interceptor {
final TokenStorage _tokenStorage;
AuthInterceptor({TokenStorage? tokenStorage})
: _tokenStorage = tokenStorage ?? TokenStorage();
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
final token = await _tokenStorage.getAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401) {
try {
final newToken = await _refreshToken();
if (newToken != null) {
err.requestOptions.headers['Authorization'] = 'Bearer $newToken';
final response = await Dio().fetch(err.requestOptions);
return handler.resolve(response);
}
} catch (e) {
await _tokenStorage.clearTokens();
}
}
handler.next(err);
}
Future<String?> _refreshToken() async {
final refreshToken = await _tokenStorage.getRefreshToken();
if (refreshToken == null) return null;
final response = await Dio().post(
'https://api.example.com/v1/auth/refresh',
data: {'refresh_token': refreshToken},
);
if (response.statusCode == 200) {
final newToken = response.data['access_token'];
await _tokenStorage.saveAccessToken(newToken);
return newToken;
}
return null;
}
}Logging Interceptor
class LoggingInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
debugPrint('→ ${options.method} ${options.uri}');
if (options.data != null) {
debugPrint(' Body: ${options.data}');
}
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
debugPrint('← ${response.statusCode} ${response.requestOptions.uri}');
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
debugPrint('✗ ${err.response?.statusCode} ${err.requestOptions.uri}');
debugPrint(' Error: ${err.message}');
handler.next(err);
}
}Retry Interceptor
class RetryInterceptor extends Interceptor {
final Dio dio;
final int maxRetries;
final Duration retryDelay;
RetryInterceptor({
required this.dio,
this.maxRetries = 3,
this.retryDelay = const Duration(seconds: 1),
});
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
final retryCount = err.requestOptions.extra['retryCount'] ?? 0;
if (_shouldRetry(err) && retryCount < maxRetries) {
await Future.delayed(retryDelay * (retryCount + 1));
err.requestOptions.extra['retryCount'] = retryCount + 1;
try {
final response = await dio.fetch(err.requestOptions);
return handler.resolve(response);
} catch (e) {
return handler.next(err);
}
}
handler.next(err);
}
bool _shouldRetry(DioException err) {
return err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.sendTimeout ||
err.type == DioExceptionType.receiveTimeout ||
(err.response?.statusCode ?? 0) >= 500;
}
}Error Handling
Custom Exception
sealed class ApiException implements Exception {
final String message;
final int? statusCode;
final dynamic data;
const ApiException({
required this.message,
this.statusCode,
this.data,
});
}
class NetworkException extends ApiException {
const NetworkException({super.message = 'Network connection failed'});
}
class ServerException extends ApiException {
const ServerException({
required super.message,
super.statusCode,
super.data,
});
}
class UnauthorizedException extends ApiException {
const UnauthorizedException({super.message = 'Authentication required'});
}
class ValidationException extends ApiException {
final Map<String, List<String>> errors;
const ValidationException({
required this.errors,
super.message = 'Validation failed',
});
}Error Handler
class ApiErrorHandler {
static ApiException handle(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return const NetworkException(message: 'Connection timeout');
case DioExceptionType.connectionError:
return const NetworkException(message: 'No internet connection');
case DioExceptionType.badResponse:
return _handleResponse(error.response);
case DioExceptionType.cancel:
return const ApiException(message: 'Request cancelled');
default:
return ApiException(message: error.message ?? 'Unknown error');
}
}
static ApiException _handleResponse(Response? response) {
final statusCode = response?.statusCode ?? 0;
final data = response?.data;
switch (statusCode) {
case 400:
if (data is Map && data.containsKey('errors')) {
return ValidationException(
errors: Map<String, List<String>>.from(
(data['errors'] as Map).map(
(k, v) => MapEntry(k.toString(), List<String>.from(v)),
),
),
);
}
return ServerException(
message: data?['message'] ?? 'Bad request',
statusCode: statusCode,
);
case 401:
return const UnauthorizedException();
case 403:
return const ServerException(
message: 'Access denied',
statusCode: 403,
);
case 404:
return const ServerException(
message: 'Resource not found',
statusCode: 404,
);
case 422:
return ValidationException(
errors: _parseValidationErrors(data),
);
case >= 500:
return ServerException(
message: 'Server error',
statusCode: statusCode,
);
default:
return ServerException(
message: data?['message'] ?? 'Unknown error',
statusCode: statusCode,
);
}
}
static Map<String, List<String>> _parseValidationErrors(dynamic data) {
if (data is! Map) return {};
final errors = data['errors'];
if (errors is! Map) return {};
return errors.map((k, v) => MapEntry(
k.toString(),
v is List ? v.map((e) => e.toString()).toList() : [v.toString()],
));
}
}Repository Pattern
abstract class BaseRepository {
final Dio dio;
BaseRepository(this.dio);
Future<T> safeCall<T>(Future<T> Function() call) async {
try {
return await call();
} on DioException catch (e) {
throw ApiErrorHandler.handle(e);
}
}
}
class UserRepository extends BaseRepository {
UserRepository(super.dio);
Future<User> getUser(String id) => safeCall(() async {
final response = await dio.get('/users/$id');
return User.fromJson(response.data);
});
Future<List<User>> getUsers({int page = 1, int limit = 20}) => safeCall(() async {
final response = await dio.get('/users', queryParameters: {
'page': page,
'limit': limit,
});
return (response.data['data'] as List)
.map((e) => User.fromJson(e))
.toList();
});
Future<User> updateUser(String id, Map<String, dynamic> data) => safeCall(() async {
final response = await dio.patch('/users/$id', data: data);
return User.fromJson(response.data);
});
}Caching
Memory Cache
class CacheInterceptor extends Interceptor {
final Map<String, CacheEntry> _cache = {};
final Duration maxAge;
CacheInterceptor({this.maxAge = const Duration(minutes: 5)});
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (options.method != 'GET') {
handler.next(options);
return;
}
final key = _cacheKey(options);
final cached = _cache[key];
if (cached != null && !cached.isExpired) {
return handler.resolve(Response(
requestOptions: options,
data: cached.data,
statusCode: 200,
));
}
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
if (response.requestOptions.method == 'GET') {
final key = _cacheKey(response.requestOptions);
_cache[key] = CacheEntry(
data: response.data,
expiry: DateTime.now().add(maxAge),
);
}
handler.next(response);
}
String _cacheKey(RequestOptions options) {
return '${options.uri}';
}
void invalidate(String pattern) {
_cache.removeWhere((key, _) => key.contains(pattern));
}
void clear() => _cache.clear();
}
class CacheEntry {
final dynamic data;
final DateTime expiry;
CacheEntry({required this.data, required this.expiry});
bool get isExpired => DateTime.now().isAfter(expiry);
}Disk Cache with Hive
import 'package:hive_flutter/hive_flutter.dart';
class DiskCacheInterceptor extends Interceptor {
static const String _boxName = 'api_cache';
final Duration maxAge;
DiskCacheInterceptor({this.maxAge = const Duration(hours: 1)});
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
if (options.method != 'GET') {
handler.next(options);
return;
}
final box = await Hive.openBox(_boxName);
final key = _cacheKey(options);
final cached = box.get(key);
if (cached != null) {
final entry = CachedResponse.fromJson(cached);
if (!entry.isExpired) {
return handler.resolve(Response(
requestOptions: options,
data: entry.data,
statusCode: 200,
));
}
}
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) async {
if (response.requestOptions.method == 'GET') {
final box = await Hive.openBox(_boxName);
final key = _cacheKey(response.requestOptions);
await box.put(key, CachedResponse(
data: response.data,
expiry: DateTime.now().add(maxAge),
).toJson());
}
handler.next(response);
}
String _cacheKey(RequestOptions options) => options.uri.toString();
}
class CachedResponse {
final dynamic data;
final DateTime expiry;
CachedResponse({required this.data, required this.expiry});
bool get isExpired => DateTime.now().isAfter(expiry);
factory CachedResponse.fromJson(Map<String, dynamic> json) {
return CachedResponse(
data: json['data'],
expiry: DateTime.parse(json['expiry']),
);
}
Map<String, dynamic> toJson() => {
'data': data,
'expiry': expiry.toIso8601String(),
};
}Riverpod Integration
@riverpod
Dio dio(Ref ref) {
return ApiClient().dio;
}
@riverpod
UserRepository userRepository(Ref ref) {
return UserRepository(ref.watch(dioProvider));
}
@riverpod
Future<User> user(Ref ref, String id) async {
final repository = ref.watch(userRepositoryProvider);
return repository.getUser(id);
}
@riverpod
class Users extends _$Users {
@override
Future<List<User>> build() => _fetch();
Future<List<User>> _fetch() async {
final repository = ref.watch(userRepositoryProvider);
return repository.getUsers();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(_fetch);
}
}Request Cancellation
class SearchRepository extends BaseRepository {
CancelToken? _searchToken;
SearchRepository(super.dio);
Future<List<SearchResult>> search(String query) async {
_searchToken?.cancel();
_searchToken = CancelToken();
return safeCall(() async {
final response = await dio.get(
'/search',
queryParameters: {'q': query},
cancelToken: _searchToken,
);
return (response.data as List)
.map((e) => SearchResult.fromJson(e))
.toList();
});
}
}Common Patterns
| Pattern | Usage |
|---|---|
| Singleton client | Single Dio instance across app |
| Interceptor chain | Auth → Retry → Cache → Logging |
| Repository layer | Abstract API from business logic |
| Error mapping | Convert DioException to app exceptions |
| Cancel tokens | Debounce/cancel previous requests |
| Cache invalidation | Clear cache on mutations |
Networking Checklist
| Item | Implementation |
|---|---|
| Base configuration | Timeouts, headers, base URL |
| Auth handling | Token injection, refresh on 401 |
| Error handling | Typed exceptions, user messages |
| Retry logic | Exponential backoff for transient errors |
| Request logging | Debug interceptor |
| Caching | Memory/disk cache for GET requests |
| Cancellation | Cancel tokens for search/debounce |
---
Dio is an open-source package by the Flutter China community.
Performance Optimization
Flutter performance guide covering profiling, const optimization, and DevTools analysis.
Profiling Commands
# Run in profile mode (required for accurate measurements)
flutter run --profile
# Analyze code issues
flutter analyze
# Launch DevTools
flutter pub global activate devtools
flutter pub global run devtools
# Build release for testing
flutter build apk --release
flutter build ios --releaseConst Widget Optimization
The most important optimization for preventing unnecessary rebuilds:
// BAD - Creates new objects every build
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(16), // New object each time
child: Text('Hello'), // New widget each time
);
}
// GOOD - Const prevents rebuilds
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: const Text('Hello'),
);
}Extracting Const Widgets
// BAD - Inline static content
class MyScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
Icon(Icons.star, size: 48),
Text('Welcome'),
Text('Description text here'),
],
);
}
}
// GOOD - Extract to const classes
class MyScreen extends StatelessWidget {
const MyScreen({super.key});
@override
Widget build(BuildContext context) {
return const Column(
children: [
_Header(),
_Description(),
],
);
}
}
class _Header extends StatelessWidget {
const _Header();
@override
Widget build(BuildContext context) {
return const Column(
children: [
Icon(Icons.star, size: 48),
Text('Welcome'),
],
);
}
}Selective Provider Watching
// BAD - Rebuilds on any user change
class UserAvatar extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userProvider);
return CircleAvatar(
backgroundImage: NetworkImage(user.avatarUrl),
);
}
}
// GOOD - Only rebuilds when avatarUrl changes
class UserAvatar extends ConsumerWidget {
const UserAvatar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final avatarUrl = ref.watch(userProvider.select((u) => u.avatarUrl));
return CircleAvatar(
backgroundImage: NetworkImage(avatarUrl),
);
}
}RepaintBoundary
Isolate expensive widgets to prevent unnecessary repaints:
// Isolate complex animated widgets
RepaintBoundary(
child: ComplexAnimatedWidget(),
)
// Isolate frequently updating widgets
RepaintBoundary(
child: StreamBuilder<int>(
stream: counterStream,
builder: (context, snapshot) => Text('${snapshot.data}'),
),
)List Optimization
// BAD - Builds all items upfront
ListView(
children: items.map((item) => ItemWidget(item: item)).toList(),
)
// GOOD - Lazy loading with builder
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ItemWidget(
key: ValueKey(items[index].id),
item: items[index],
);
},
)
// For heterogeneous content
ListView.separated(
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(),
itemBuilder: (context, index) => ItemWidget(item: items[index]),
)Image Optimization
// Use cached_network_image for network images
CachedNetworkImage(
imageUrl: url,
placeholder: (_, __) => const ShimmerPlaceholder(),
errorWidget: (_, __, ___) => const Icon(Icons.error),
memCacheWidth: 200,
memCacheHeight: 200,
)
// Resize images in memory
Image.network(
url,
cacheWidth: 200, // Decode at smaller size
cacheHeight: 200, // Saves memory
)
// Precache images
precacheImage(NetworkImage(url), context);Heavy Computation
// BAD - Blocks UI thread
void processData() {
final result = heavyComputation(data); // UI freezes
updateUI(result);
}
// GOOD - Run in isolate
Future<void> processData() async {
final result = await compute(heavyComputation, data);
updateUI(result);
}
// For multiple operations
Future<void> processMultiple() async {
final results = await Future.wait([
compute(process1, data1),
compute(process2, data2),
compute(process3, data3),
]);
}Animation Performance
// Use AnimatedBuilder for custom animations
AnimatedBuilder(
animation: controller,
builder: (context, child) {
return Transform.rotate(
angle: controller.value * 2 * pi,
child: child, // Child not rebuilt
);
},
child: const ExpensiveWidget(),
)
// Prefer implicit animations for simple cases
AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: expanded ? 200 : 100,
child: const Content(),
)DevTools Analysis
Key Metrics
| Metric | Target | Action if Exceeded |
|---|---|---|
| Frame time | < 16ms (60fps) | Profile build/paint |
| Build time | < 8ms | Add const, extract widgets |
| Paint time | < 8ms | Add RepaintBoundary |
| Memory | Stable | Check for leaks |
Common Issues
| Issue | Symptom | Solution |
|---|---|---|
| Expensive builds | High build time | Extract const widgets |
| Excessive repaints | High paint time | Add RepaintBoundary |
| Memory leaks | Growing memory | Dispose controllers |
| Jank | Dropped frames | Use compute() |
Performance Checklist
| Check | Solution |
|---|---|
| Unnecessary rebuilds | Add const, use select() |
| Large lists | Use ListView.builder |
| Image loading | Use cached_network_image |
| Heavy computation | Use compute() |
| Jank in animations | Use RepaintBoundary |
| Memory leaks | Dispose controllers, cancel subscriptions |
| Network calls | Cache responses, debounce requests |
| Startup time | Defer initialization, lazy loading |
Dispose Pattern
class MyWidget extends StatefulWidget {
const MyWidget({super.key});
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
late final TextEditingController _controller;
late final StreamSubscription _subscription;
@override
void initState() {
super.initState();
_controller = TextEditingController();
_subscription = stream.listen(handleData);
}
@override
void dispose() {
_controller.dispose();
_subscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => Container();
}---
Flutter and DevTools are trademarks of Google LLC.
Platform Integration
Flutter platform-specific implementations for iOS, Android, Web, and Desktop.
Platform Detection
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
bool get isIOS => !kIsWeb && Platform.isIOS;
bool get isAndroid => !kIsWeb && Platform.isAndroid;
bool get isWeb => kIsWeb;
bool get isDesktop => !kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux);
bool get isMobile => !kIsWeb && (Platform.isIOS || Platform.isAndroid);Adaptive Widgets
Platform-Aware Components
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
class AdaptiveButton extends StatelessWidget {
final String label;
final VoidCallback onPressed;
const AdaptiveButton({
super.key,
required this.label,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
if (Platform.isIOS) {
return CupertinoButton.filled(
onPressed: onPressed,
child: Text(label),
);
}
return ElevatedButton(
onPressed: onPressed,
child: Text(label),
);
}
}Adaptive Dialog
Future<bool?> showAdaptiveConfirmDialog(
BuildContext context, {
required String title,
required String content,
}) async {
if (Platform.isIOS) {
return showCupertinoDialog<bool>(
context: context,
builder: (context) => CupertinoAlertDialog(
title: Text(title),
content: Text(content),
actions: [
CupertinoDialogAction(
isDestructiveAction: true,
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete'),
),
CupertinoDialogAction(
isDefaultAction: true,
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
],
),
);
}
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(content),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete'),
),
],
),
);
}Adaptive Scaffold
class AdaptiveScaffold extends StatelessWidget {
final String title;
final Widget body;
final List<Widget>? actions;
const AdaptiveScaffold({
super.key,
required this.title,
required this.body,
this.actions,
});
@override
Widget build(BuildContext context) {
if (Platform.isIOS) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(title),
trailing: actions != null
? Row(mainAxisSize: MainAxisSize.min, children: actions!)
: null,
),
child: SafeArea(child: body),
);
}
return Scaffold(
appBar: AppBar(title: Text(title), actions: actions),
body: body,
);
}
}Platform Channels
Method Channel (Dart Side)
import 'package:flutter/services.dart';
class NativeBridge {
static const _channel = MethodChannel('com.example.app/native');
static Future<String> getPlatformVersion() async {
final version = await _channel.invokeMethod<String>('getPlatformVersion');
return version ?? 'Unknown';
}
static Future<void> triggerHaptic() async {
await _channel.invokeMethod('triggerHaptic');
}
static Future<Map<String, dynamic>> getDeviceInfo() async {
final result = await _channel.invokeMethod<Map>('getDeviceInfo');
return Map<String, dynamic>.from(result ?? {});
}
}iOS Implementation (Swift)
// ios/Runner/AppDelegate.swift
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let controller = window?.rootViewController as! FlutterViewController
let channel = FlutterMethodChannel(
name: "com.example.app/native",
binaryMessenger: controller.binaryMessenger
)
channel.setMethodCallHandler { (call, result) in
switch call.method {
case "getPlatformVersion":
result("iOS " + UIDevice.current.systemVersion)
case "triggerHaptic":
let generator = UIImpactFeedbackGenerator(style: .medium)
generator.impactOccurred()
result(nil)
case "getDeviceInfo":
result([
"model": UIDevice.current.model,
"name": UIDevice.current.name,
"systemVersion": UIDevice.current.systemVersion
])
default:
result(FlutterMethodNotImplemented)
}
}
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}Android Implementation (Kotlin)
// android/app/src/main/kotlin/.../MainActivity.kt
package com.example.app
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.content.Context
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity: FlutterActivity() {
private val CHANNEL = "com.example.app/native"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"getPlatformVersion" -> {
result.success("Android ${Build.VERSION.RELEASE}")
}
"triggerHaptic" -> {
val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(
VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE)
)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(50)
}
result.success(null)
}
"getDeviceInfo" -> {
result.success(mapOf(
"model" to Build.MODEL,
"manufacturer" to Build.MANUFACTURER,
"version" to Build.VERSION.RELEASE
))
}
else -> result.notImplemented()
}
}
}
}iOS-Specific Configuration
Info.plist Permissions
<!-- ios/Runner/Info.plist -->
<key>NSCameraUsageDescription</key>
<string>This app needs camera access to take photos</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs photo library access to save images</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app needs location access to show nearby places</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app needs microphone access for voice recording</string>iOS App Icons and Launch Screen
ios/Runner/Assets.xcassets/
├── AppIcon.appiconset/
│ ├── Contents.json
│ └── Icon-App-*.png
└── LaunchImage.imageset/
├── Contents.json
└── LaunchImage*.pngAndroid-Specific Configuration
AndroidManifest.xml Permissions
<!-- android/app/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<application
android:label="My App"
android:icon="@mipmap/ic_launcher">
<!-- ... -->
</application>
</manifest>Build Gradle Configuration
// android/app/build.gradle
android {
compileSdkVersion 34
defaultConfig {
minSdkVersion 21
targetSdkVersion 34
multiDexEnabled true
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}Web-Specific
Conditional Imports
// lib/services/storage_service.dart
export 'storage_service_stub.dart'
if (dart.library.io) 'storage_service_native.dart'
if (dart.library.html) 'storage_service_web.dart';// lib/services/storage_service_web.dart
import 'dart:html' as html;
class StorageService {
void save(String key, String value) {
html.window.localStorage[key] = value;
}
String? load(String key) {
return html.window.localStorage[key];
}
}Web Index Configuration
<!-- web/index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My App</title>
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/png" href="favicon.png"/>
</head>
<body>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>Platform-Specific Styling
ThemeData get theme {
final baseTheme = ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
);
if (Platform.isIOS) {
return baseTheme.copyWith(
// iOS-style page transitions
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
},
),
);
}
return baseTheme;
}Platform Reference
| Feature | iOS | Android | Web |
|---|---|---|---|
| Navigation | Cupertino style | Material style | URL-based |
| Haptics | UIFeedbackGenerator | Vibrator | Not available |
| Storage | NSUserDefaults | SharedPreferences | localStorage |
| Deep links | Universal Links | App Links | URL routing |
| Notifications | APNs | FCM | Web Push |
---
Flutter, iOS, Android, and their respective logos are trademarks of Google LLC and Apple Inc.
Project Structure
Flutter project architecture guide covering feature-based structure, dependencies, and entry point setup.
Feature-Based Structure
lib/
├── main.dart # Entry point
├── app.dart # App widget, MaterialApp.router
├── core/
│ ├── constants/
│ │ ├── app_colors.dart
│ │ ├── app_strings.dart
│ │ └── app_sizes.dart
│ ├── theme/
│ │ ├── app_theme.dart
│ │ └── text_styles.dart
│ ├── utils/
│ │ ├── extensions.dart
│ │ └── validators.dart
│ └── errors/
│ └── failures.dart
├── features/
│ ├── auth/
│ │ ├── data/
│ │ │ ├── repositories/
│ │ │ │ └── auth_repository_impl.dart
│ │ │ └── datasources/
│ │ │ ├── auth_remote_datasource.dart
│ │ │ └── auth_local_datasource.dart
│ │ ├── domain/
│ │ │ ├── entities/
│ │ │ │ └── user.dart
│ │ │ ├── repositories/
│ │ │ │ └── auth_repository.dart
│ │ │ └── usecases/
│ │ │ ├── login.dart
│ │ │ └── logout.dart
│ │ ├── presentation/
│ │ │ ├── screens/
│ │ │ │ ├── login_screen.dart
│ │ │ │ └── register_screen.dart
│ │ │ └── widgets/
│ │ │ └── auth_form.dart
│ │ └── providers/
│ │ └── auth_provider.dart
│ └── home/
│ ├── data/
│ ├── domain/
│ ├── presentation/
│ └── providers/
├── shared/
│ ├── widgets/
│ │ ├── buttons/
│ │ │ └── primary_button.dart
│ │ ├── inputs/
│ │ │ └── text_input.dart
│ │ └── cards/
│ │ └── info_card.dart
│ ├── services/
│ │ ├── api_service.dart
│ │ └── storage_service.dart
│ └── models/
│ └── api_response.dart
└── routes/
└── app_router.dartFeature Layer Responsibilities
| Layer | Responsibility |
|---|---|
| data/ | API calls, local storage, DTOs, repository implementations |
| domain/ | Business logic, entities, abstract repositories, use cases |
| presentation/ | UI screens, widgets, view logic |
| providers/ | Riverpod providers or Bloc definitions |
pubspec.yaml Essentials
name: my_app
description: A Flutter application.
version: 1.0.0+1
publish_to: 'none'
environment:
sdk: '>=3.3.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
# State Management (choose one)
flutter_riverpod: ^2.5.0
riverpod_annotation: ^2.3.0
# OR
flutter_bloc: ^8.1.0
# Navigation
go_router: ^14.0.0
# Networking
dio: ^5.4.0
# Code Generation
freezed_annotation: ^2.4.0
json_annotation: ^4.9.0
# Storage
shared_preferences: ^2.2.0
hive_flutter: ^1.1.0
# Utilities
flutter_hooks: ^0.20.0
cached_network_image: ^3.3.0
intl: ^0.19.0
dev_dependencies:
flutter_test:
sdk: flutter
# Code Generation
build_runner: ^2.4.0
riverpod_generator: ^2.4.0
freezed: ^2.5.0
json_serializable: ^6.8.0
# Linting
flutter_lints: ^4.0.0
# Testing
bloc_test: ^9.1.0
mocktail: ^1.0.0
flutter:
uses-material-design: trueMain Entry Point
// main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize services
await Hive.initFlutter();
runApp(
const ProviderScope(
child: MyApp(),
),
);
}// app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class MyApp extends ConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
return MaterialApp.router(
title: 'My App',
routerConfig: router,
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: ThemeMode.system,
debugShowCheckedModeBanner: false,
);
}
}Router Provider
// routes/app_router.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
final routerProvider = Provider<GoRouter>((ref) {
return GoRouter(
initialLocation: '/',
debugLogDiagnostics: true,
redirect: (context, state) {
// Auth guard logic
return null;
},
routes: [
GoRoute(
path: '/',
name: 'home',
builder: (context, state) => const HomeScreen(),
),
// Add more routes
],
);
});Environment Configuration
// core/constants/environment.dart
enum Environment { dev, staging, prod }
class EnvConfig {
static Environment current = Environment.dev;
static String get baseUrl {
switch (current) {
case Environment.dev:
return 'https://dev-api.example.com';
case Environment.staging:
return 'https://staging-api.example.com';
case Environment.prod:
return 'https://api.example.com';
}
}
}Dependency Injection with Riverpod
// shared/services/api_service.dart
final apiServiceProvider = Provider<ApiService>((ref) {
final dio = Dio(BaseOptions(
baseUrl: EnvConfig.baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
));
// Add interceptors
dio.interceptors.add(AuthInterceptor(ref));
dio.interceptors.add(LogInterceptor(responseBody: true));
return ApiService(dio);
});
// features/auth/providers/auth_provider.dart
final authRepositoryProvider = Provider<AuthRepository>((ref) {
final api = ref.watch(apiServiceProvider);
final storage = ref.watch(storageServiceProvider);
return AuthRepositoryImpl(api: api, storage: storage);
});Best Practices
| Practice | Description |
|---|---|
| Feature isolation | Each feature is self-contained |
| Dependency inversion | Domain depends on abstractions |
| Single responsibility | One class, one purpose |
| Naming conventions | Clear, descriptive names |
| Barrel exports | One index.dart per folder |
---
Flutter is a trademark of Google LLC.
Riverpod State Management
Riverpod 2.0 state management guide covering provider types, notifier patterns, and widget integration.
Provider Types
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Simple computed value
final greetingProvider = Provider<String>((ref) {
final name = ref.watch(userNameProvider);
return 'Hello, $name';
});
// Simple mutable state
final counterProvider = StateProvider<int>((ref) => 0);
// Async state (API calls)
final usersProvider = FutureProvider<List<User>>((ref) async {
final api = ref.read(apiProvider);
return api.getUsers();
});
// Stream state (real-time)
final messagesProvider = StreamProvider<List<Message>>((ref) {
return ref.read(chatServiceProvider).messagesStream;
});Provider Type Reference
| Provider | Use Case |
|---|---|
Provider | Computed/derived values, dependency injection |
StateProvider | Simple mutable state (counter, toggle) |
FutureProvider | Async operations (one-time fetch) |
StreamProvider | Real-time data streams |
NotifierProvider | Complex state with methods |
AsyncNotifierProvider | Async state with methods |
Notifier Pattern (Riverpod 2.0)
Synchronous Notifier
@riverpod
class TodoList extends _$TodoList {
@override
List<Todo> build() => [];
void add(Todo todo) {
state = [...state, todo];
}
void toggle(String id) {
state = [
for (final todo in state)
if (todo.id == id)
todo.copyWith(completed: !todo.completed)
else
todo,
];
}
void remove(String id) {
state = state.where((t) => t.id != id).toList();
}
}Async Notifier
@riverpod
class UserProfile extends _$UserProfile {
@override
Future<User> build() async {
return ref.read(apiProvider).getCurrentUser();
}
Future<void> updateName(String name) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final updated = await ref.read(apiProvider).updateUser(name: name);
return updated;
});
}
Future<void> refresh() async {
ref.invalidateSelf();
await future;
}
}Usage in Widgets
ConsumerWidget (Recommended)
class TodoScreen extends ConsumerWidget {
const TodoScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final todos = ref.watch(todoListProvider);
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
key: ValueKey(todo.id),
title: Text(todo.title),
leading: Checkbox(
value: todo.completed,
onChanged: (_) => ref.read(todoListProvider.notifier).toggle(todo.id),
),
);
},
);
}
}Selective Rebuilds with select
class UserAvatar extends ConsumerWidget {
const UserAvatar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Only rebuilds when avatarUrl changes
final avatarUrl = ref.watch(userProvider.select((u) => u?.avatarUrl));
return CircleAvatar(
backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null,
);
}
}Async State Handling
class UserProfileScreen extends ConsumerWidget {
const UserProfileScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(userProfileProvider);
return userAsync.when(
data: (user) => UserProfileContent(user: user),
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => ErrorView(
message: err.toString(),
onRetry: () => ref.invalidate(userProfileProvider),
),
);
}
}Consumer for Scoped Rebuilds
class MyScreen extends StatelessWidget {
const MyScreen({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
const Text('Static content'),
Consumer(
builder: (context, ref, child) {
final count = ref.watch(counterProvider);
return Text('Count: $count');
},
),
],
);
}
}Provider Modifiers
// Auto-dispose when no longer used
@riverpod
class AutoDisposeExample extends _$AutoDisposeExample {
@override
String build() => 'value';
}
// Family - parameterized providers
@riverpod
Future<User> userById(UserByIdRef ref, String id) async {
return ref.read(apiProvider).getUser(id);
}
// Usage
final user = ref.watch(userByIdProvider('123'));Best Practices
| Do | Don't |
|---|---|
Use ref.watch() in build | Use ref.watch() in callbacks |
Use ref.read() in callbacks | Use ref.read() in build |
Use select() for granular rebuilds | Watch entire state unnecessarily |
| Create new state instances | Mutate state directly |
Use AsyncValue.guard() for errors | Catch errors manually |
Quick Reference
| Method | When to Use |
|---|---|
ref.watch() | In build method, rebuilds on change |
ref.read() | In callbacks, one-time read |
ref.listen() | Side effects on change |
ref.invalidate() | Force provider refresh |
ref.refresh() | Invalidate and get new value |
---
Riverpod is an open-source state management library by Remi Rousselet.
Testing Strategies
Flutter testing guide covering widget tests, unit tests, integration tests, and mocking patterns.
Test Types
| Type | Purpose | Speed | Scope |
|---|---|---|---|
| Unit tests | Business logic, utilities | Fast | Single function/class |
| Widget tests | UI components | Medium | Single widget |
| Integration tests | Full user flows | Slow | Multiple screens |
Widget Tests
Basic Widget Test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('Counter increments when button tapped', (tester) async {
await tester.pumpWidget(const MaterialApp(home: CounterScreen()));
// Verify initial state
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the increment button
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify state changed
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}Testing with Riverpod
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('displays user name from provider', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userProvider.overrideWithValue(
AsyncValue.data(User(name: 'Test User')),
),
],
child: const MaterialApp(home: UserScreen()),
),
);
expect(find.text('Test User'), findsOneWidget);
});
testWidgets('shows loading indicator', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userProvider.overrideWithValue(const AsyncValue.loading()),
],
child: const MaterialApp(home: UserScreen()),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('shows error message', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userProvider.overrideWithValue(
AsyncValue.error('Network error', StackTrace.current),
),
],
child: const MaterialApp(home: UserScreen()),
),
);
expect(find.text('Network error'), findsOneWidget);
});
}Testing with Bloc
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockCounterBloc extends MockBloc<CounterEvent, CounterState>
implements CounterBloc {}
void main() {
late MockCounterBloc mockBloc;
setUp(() {
mockBloc = MockCounterBloc();
});
testWidgets('displays current count', (tester) async {
when(() => mockBloc.state).thenReturn(const CounterState(value: 42));
await tester.pumpWidget(
MaterialApp(
home: BlocProvider<CounterBloc>.value(
value: mockBloc,
child: const CounterScreen(),
),
),
);
expect(find.text('42'), findsOneWidget);
});
testWidgets('calls increment on button tap', (tester) async {
when(() => mockBloc.state).thenReturn(const CounterState(value: 0));
await tester.pumpWidget(
MaterialApp(
home: BlocProvider<CounterBloc>.value(
value: mockBloc,
child: const CounterScreen(),
),
),
);
await tester.tap(find.byIcon(Icons.add));
verify(() => mockBloc.add(CounterIncremented())).called(1);
});
}Bloc Tests
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockUserRepository extends Mock implements UserRepository {}
void main() {
late MockUserRepository mockRepository;
setUp(() {
mockRepository = MockUserRepository();
});
group('UserBloc', () {
blocTest<UserBloc, UserState>(
'emits loading then success when user loaded',
setUp: () {
when(() => mockRepository.getUser())
.thenAnswer((_) async => User(name: 'Test'));
},
build: () => UserBloc(repository: mockRepository),
act: (bloc) => bloc.add(UserRequested()),
expect: () => [
const UserState(status: UserStatus.loading),
UserState(status: UserStatus.success, user: User(name: 'Test')),
],
);
blocTest<UserBloc, UserState>(
'emits loading then failure when error occurs',
setUp: () {
when(() => mockRepository.getUser())
.thenThrow(Exception('Network error'));
},
build: () => UserBloc(repository: mockRepository),
act: (bloc) => bloc.add(UserRequested()),
expect: () => [
const UserState(status: UserStatus.loading),
isA<UserState>()
.having((s) => s.status, 'status', UserStatus.failure),
],
);
});
}Unit Tests
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Validator', () {
test('returns error for empty email', () {
expect(Validator.email(''), 'Email is required');
});
test('returns error for invalid email', () {
expect(Validator.email('invalid'), 'Invalid email format');
});
test('returns null for valid email', () {
expect(Validator.email('test@example.com'), isNull);
});
});
group('Calculator', () {
late Calculator calculator;
setUp(() {
calculator = Calculator();
});
test('adds two numbers', () {
expect(calculator.add(2, 3), 5);
});
test('throws on division by zero', () {
expect(() => calculator.divide(10, 0), throwsArgumentError);
});
});
}Mocking with Mocktail
import 'package:mocktail/mocktail.dart';
// Create mock classes
class MockApiService extends Mock implements ApiService {}
class MockStorageService extends Mock implements StorageService {}
// Register fallback values for complex types
setUpAll(() {
registerFallbackValue(User(name: 'fallback'));
});
void main() {
late MockApiService mockApi;
setUp(() {
mockApi = MockApiService();
});
test('fetches user from API', () async {
// Arrange
when(() => mockApi.getUser(any()))
.thenAnswer((_) async => User(name: 'Test'));
// Act
final repository = UserRepository(api: mockApi);
final user = await repository.getUser('123');
// Assert
expect(user.name, 'Test');
verify(() => mockApi.getUser('123')).called(1);
});
}Integration Tests
// integration_test/app_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('complete login flow', (tester) async {
app.main();
await tester.pumpAndSettle();
// Navigate to login
await tester.tap(find.text('Login'));
await tester.pumpAndSettle();
// Enter credentials
await tester.enterText(
find.byKey(const Key('email_field')),
'test@example.com',
);
await tester.enterText(
find.byKey(const Key('password_field')),
'password123',
);
// Submit form
await tester.tap(find.text('Sign In'));
await tester.pumpAndSettle();
// Verify navigation to home
expect(find.text('Welcome'), findsOneWidget);
});
}Run integration tests:
flutter test integration_test/app_test.dartTest Helpers
// test/helpers/pump_app.dart
extension PumpApp on WidgetTester {
Future<void> pumpApp(Widget widget, {List<Override>? overrides}) {
return pumpWidget(
ProviderScope(
overrides: overrides ?? [],
child: MaterialApp(
home: widget,
),
),
);
}
}
// Usage
await tester.pumpApp(const MyWidget());Golden Tests
testWidgets('matches golden', (tester) async {
await tester.pumpWidget(const MaterialApp(home: MyWidget()));
await expectLater(
find.byType(MyWidget),
matchesGoldenFile('goldens/my_widget.png'),
);
});Update goldens:
flutter test --update-goldensTesting Checklist
| Test Type | What to Test |
|---|---|
| Widget tests | UI rendering, user interactions, state changes |
| Bloc tests | Event → state transitions, async operations |
| Unit tests | Validators, formatters, utilities, models |
| Integration tests | Critical user flows, navigation |
---
Flutter and flutter_test are trademarks of Google LLC.
Widget Patterns
Flutter widget best practices covering const optimization, responsive layouts, hooks, and sliver patterns.
Optimized Widget Pattern
Always use const constructors for static widgets to prevent unnecessary rebuilds:
class OptimizedCard extends StatelessWidget {
final String title;
final VoidCallback onTap;
const OptimizedCard({
super.key,
required this.title,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
),
),
);
}
}Extracting Const Widgets
class MyScreen extends StatelessWidget {
const MyScreen({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: const [
_Header(),
_Body(),
_Footer(),
],
);
}
}
class _Header extends StatelessWidget {
const _Header();
@override
Widget build(BuildContext context) {
return const Text('Header');
}
}Responsive Layout
class ResponsiveLayout extends StatelessWidget {
final Widget mobile;
final Widget? tablet;
final Widget desktop;
const ResponsiveLayout({
super.key,
required this.mobile,
this.tablet,
required this.desktop,
});
static const double mobileBreakpoint = 650;
static const double desktopBreakpoint = 1100;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= desktopBreakpoint) return desktop;
if (constraints.maxWidth >= mobileBreakpoint) return tablet ?? mobile;
return mobile;
},
);
}
}Breakpoint Reference
| Type | Width | Usage |
|---|---|---|
| Mobile | < 650pt | Single column, bottom nav |
| Tablet | 650-1100pt | Two columns, side nav optional |
| Desktop | > 1100pt | Multi-column, persistent nav |
Custom Hooks (flutter_hooks)
import 'package:flutter_hooks/flutter_hooks.dart';
class CounterWidget extends HookWidget {
const CounterWidget({super.key});
@override
Widget build(BuildContext context) {
final counter = useState(0);
final controller = useTextEditingController();
final isMounted = useIsMounted();
useEffect(() {
debugPrint('Widget mounted');
return () {
debugPrint('Widget disposed');
};
}, const []);
return Column(
children: [
Text('Count: ${counter.value}'),
ElevatedButton(
onPressed: () => counter.value++,
child: const Text('Increment'),
),
TextField(controller: controller),
],
);
}
}Common Hooks
| Hook | Purpose |
|---|---|
useState | Local state management |
useEffect | Side effects with cleanup |
useMemoized | Expensive computation caching |
useTextEditingController | Text field controller |
useAnimationController | Animation controller |
useFocusNode | Focus management |
useIsMounted | Check if widget is mounted |
Sliver Patterns
CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 200,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: const Text('Title'),
background: Image.network(imageUrl, fit: BoxFit.cover),
),
),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(
key: ValueKey(items[index].id),
title: Text(items[index].title),
),
childCount: items.length,
),
),
),
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('Footer'),
),
),
],
)Sliver Types
| Sliver | Usage |
|---|---|
SliverAppBar | Collapsing app bar |
SliverList | Lazy list |
SliverGrid | Lazy grid |
SliverToBoxAdapter | Single non-sliver widget |
SliverPadding | Add padding to sliver |
SliverFillRemaining | Fill remaining space |
Key Usage Patterns
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return Dismissible(
key: ValueKey(item.id),
child: ListTile(
key: ValueKey('tile_${item.id}'),
title: Text(item.title),
),
);
},
)| Key Type | When to Use |
|---|---|
ValueKey | Unique ID available |
ObjectKey | Object identity matters |
UniqueKey | Force rebuild |
GlobalKey | Access state across tree |
Optimization Checklist
| Pattern | Implementation |
|---|---|
| const widgets | Add const to static widgets |
| Keys | Use ValueKey for list items |
| Select | ref.watch(provider.select(...)) |
| RepaintBoundary | Isolate expensive repaints |
| ListView.builder | Lazy loading for lists |
| const constructors | Always use when possible |
---
Flutter and Material Design are trademarks of Google LLC.
Related skills
FAQ
Which state management does flutter-dev recommend?
Riverpod for dependency injection and simple state, and Bloc/Cubit for event-driven workflows and complex logic.
What is the performance target?
It targets under 16ms frame time for 60fps and recommends profiling with DevTools before optimizing.