🌐 Language: English · Tiếng Việt · 中文 · 한국어
The app uses Material 3 with light and dark themes. Seed color is Indigo (Colors.indigo).
class AppTheme {
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.light,
),
);
}
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.dark,
),
);
}
}Used in app.dart:
MaterialApp(
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system, // Follow device setting
);| Token | Light | Dark | Usage |
|---|---|---|---|
| Primary | Indigo 500 | Indigo 200 | Buttons, highlights, selected states |
| Secondary | Teal 500 | Teal 200 | Accents, secondary actions |
| Tertiary | Pink 500 | Pink 200 | Links, badges, emphasis |
| Surface | White | Grey 800 | Cards, dialogs, containers |
| Error | Red 600 | Red 400 | Error messages, invalid states |
| Success | Green 600 | Green 400 | Success messages, confirmations |
| Warning | Amber 600 | Amber 400 | Warnings, caution messages |
Access in code:
final colorScheme = Theme.of(context).colorScheme;
Container(
color: colorScheme.primary,
child: Text('Hello', style: TextStyle(color: colorScheme.onPrimary)),
);Material 3 provides a predefined text theme. Use theme.textTheme instead of hardcoding sizes.
| Name | Size | Weight | Usage |
|---|---|---|---|
| displayLarge | 57 | 400 | App title, hero text |
| displayMedium | 45 | 400 | Major section headers |
| displaySmall | 36 | 400 | Page headings |
| headlineLarge | 32 | 400 | Feature headers |
| headlineMedium | 28 | 400 | Section headers |
| headlineSmall | 24 | 400 | Card headers |
| titleLarge | 22 | 500 | Dialog titles |
| titleMedium | 16 | 500 | Subheaders |
| titleSmall | 14 | 500 | Labels |
| bodyLarge | 16 | 400 | Primary body text |
| bodyMedium | 14 | 400 | Secondary body text |
| bodySmall | 12 | 400 | Helper text, captions |
| labelLarge | 14 | 500 | Button text |
| labelMedium | 12 | 500 | Small labels |
| labelSmall | 11 | 500 | Tiny labels |
Example usage:
// ✗ BAD: Hardcoded size
Text('Hello', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold));
// ✓ GOOD: Use theme
Text(
'Hello',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
);Base unit: 8 pixels. All spacing uses multiples of 8.
| Value | Pixels | Usage |
|---|---|---|
| xs | 4 | Minimal (icon-to-text spacing) |
| sm | 8 | Tight spacing (padding in small widgets) |
| md | 16 | Standard (padding in cards, gaps between elements) |
| lg | 24 | Generous (padding in screens, section spacing) |
| xl | 32 | Large (spacing between major sections) |
| 2xl | 48 | Extra large (screen top/bottom padding) |
Example:
Padding(
padding: EdgeInsets.all(16), // md: standard padding
child: Column(
spacing: 8, // sm: tight between items
children: [
Text('Title'),
SizedBox(height: 24), // lg: section break
Text('Content'),
],
),
);LoadingView — spinner with message
LoadingView(message: 'Fetching data...');ErrorView — error message + retry button
ErrorView(
error: 'Failed to load',
onRetry: () => ref.refresh(helloControllerProvider),
);Built-in Material widgets:
ElevatedButton,TextButton,OutlinedButtonTextField(with Material 3 outline style)Card(with Material 3 elevation)Scaffold,AppBar,FloatingActionButtonSnackBar(for transient messages)
Keep widgets small (<100 lines). Extract sub-widgets into separate files.
Example:
features/hello/presentation/
├── screens/
│ └── hello_screen.dart # Main screen (Router target)
└── widgets/
├── hello_card.dart # Reusable greeting card
└── hello_actions.dart # Action buttons
// hello_screen.dart
class HelloScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Hello')),
body: HelloCard(), // Extracted
);
}
}
// widgets/hello_card.dart
class HelloCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final helloAsync = ref.watch(helloControllerProvider);
return helloAsync.when(
data: (hello) => _buildContent(hello),
loading: () => LoadingView(),
error: (err, _) => ErrorView(error: err),
);
}
Widget _buildContent(HelloResponseDto hello) {
return Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(hello.message),
),
);
}
}No fixed width containers. Use Flexible, Expanded, FractionallySizedBox for responsiveness.
// ✗ BAD: Fixed width on tablet
Container(width: 300, child: MyWidget());
// ✓ GOOD: Adaptive
ConstrainedBox(
constraints: BoxConstraints(maxWidth: 600),
child: MyWidget(),
);Tablet support: Use MediaQuery.of(context).size for breakpoint-aware layouts.
final isMobile = MediaQuery.of(context).size.width < 600;
// Build different layouts for mobile vs tabletMaterial 3 theme automatically handles dark mode. No special logic needed unless you want custom colors per theme.
Test dark mode:
fvm flutter run --dart-define=FLAVOR=dev -v
# Then swipe from top → Accessibility → Dark mode (emulator only)
# Or system settings on real deviceGuidelines:
- Contrast: Minimum 4.5:1 for text on backgrounds (Material 3 handles this)
- Touch targets: Minimum 48×48 dp (Material buttons follow this)
- Semantics: Use
Semanticswidget for screen readers - Labels: All buttons/icons must have tooltip or semantic label
Example:
// ✓ GOOD
IconButton(
icon: Icon(Icons.close),
onPressed: () => Navigator.pop(context),
tooltip: 'Close', // Screen reader label
);Uses go_router (see architecture.md).
Define routes in lib/core/router/app_router.dart:
final appRouterProvider = Provider<GoRouter>((ref) {
return GoRouter(
routes: [
GoRoute(
path: '/hello',
name: 'hello',
builder: (context, state) => HelloScreen(),
),
],
);
});Navigate:
// Push named route
context.pushNamed('hello');
// Pop
context.pop();Keep animations subtle and purposeful:
- Page transitions: 300ms
- Dialog open: 200ms
- Button press feedback: 100ms
Use Flutter's built-in:
// ✓ GOOD: PageRouteBuilder for transition
PageRouteBuilder(
transitionDuration: Duration(milliseconds: 300),
pageBuilder: (context, animation, secondaryAnimation) => HelloScreen(),
);
// ✓ GOOD: AnimatedBuilder for complex animations
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.translate(
offset: Offset(_controller.value * 100, 0),
child: child,
);
},
);- Never hardcode colors. Always use
theme.colorSchemeortheme.textTheme. - Avoid custom ThemeData unless necessary. Material 3 covers 95% of use cases.
- Test both light and dark themes. Use
flutter run -d chromewith DevTools to switch. - Use
Theme.of(context)notcontext.theme. Clearer and more explicit. - Localize text using ARB files, not static strings. See i18n-guide.md.
If you need a custom widget (e.g., custom button style), place it in lib/shared/widgets/ and document its usage with an example.
Template:
/// A custom action button with consistent styling.
///
/// Example:
/// ```dart
/// ActionButton(
/// label: 'Submit',
/// onPressed: () => handleSubmit(),
/// isLoading: false,
/// );
/// ```
class ActionButton extends StatelessWidget {
final String label;
final VoidCallback onPressed;
final bool isLoading;
const ActionButton({
required this.label,
required this.onPressed,
this.isLoading = false,
});
@override
Widget build(BuildContext context) {
// Use theme colors, not hardcoded
return ElevatedButton(
onPressed: isLoading ? null : onPressed,
child: isLoading
? SizedBox(height: 20, width: 20, child: CircularProgressIndicator())
: Text(label),
);
}
}Last updated: April 2026