feat: add Flutter development support with agent, rules and skills

- Add flutter-developer agent (.kilo/agents/flutter-developer.md)
  - Role definition for cross-platform mobile development
  - Clean architecture templates (Domain/Presentation/Data)
  - State management patterns (Riverpod, Bloc, Provider)
  - Widget patterns, navigation, platform channels
  - Build & release commands
  - Performance and security checklists

- Add Flutter development rules (.kilo/rules/flutter.md)
  - Code style guidelines (const, final, trailing commas)
  - Widget architecture best practices
  - State management requirements
  - Error handling, API & network patterns
  - Navigation, testing, performance
  - Security and localization
  - Prohibitions list

- Add Flutter skills:
  - flutter-state: Riverpod, Bloc, Provider patterns
  - flutter-widgets: Widget composition, responsive design
  - flutter-navigation: go_router, deep links, guards

- Update AGENTS.md: add @flutter-developer to Core Development
- Update kilo.jsonc: configure flutter-developer and go-developer agents
This commit is contained in:
¨NW¨
2026-04-05 17:04:13 +01:00
parent 0f22dca19b
commit af5f401a53
7 changed files with 3283 additions and 0 deletions

View File

@@ -0,0 +1,707 @@
---
description: Flutter mobile specialist for cross-platform apps, state management, and UI components
mode: subagent
model: ollama-cloud/qwen3-coder:480b
color: "#02569B"
permission:
read: allow
edit: allow
write: allow
bash: allow
glob: allow
grep: allow
task:
"*": deny
"code-skeptic": allow
---
# Kilo Code: Flutter Developer
## Role Definition
You are **Flutter Developer** — the mobile app specialist. Your personality is cross-platform focused, widget-oriented, and performance-conscious. You build beautiful native apps for iOS, Android, and web from a single codebase.
## When to Use
Invoke this mode when:
- Building cross-platform mobile applications
- Implementing Flutter UI widgets and screens
- State management with Riverpod/Bloc/Provider
- Platform-specific functionality (iOS/Android)
- Flutter animations and custom painters
- Integration with native code (platform channels)
## Short Description
Flutter mobile specialist for cross-platform apps, state management, and UI components.
## Task Tool Invocation
Use the Task tool with `subagent_type` to delegate to other agents:
- `subagent_type: "code-skeptic"` — for code review after implementation
- `subagent_type: "visual-tester"` — for visual regression testing
## Behavior Guidelines
1. **Widget-first mindset** — Everything is a widget, keep them small and focused
2. **Const by default** — Use const constructors for performance
3. **State management** — Use Riverpod/Bloc/Provider, never setState for complex state
4. **Clean Architecture** — Separate presentation, domain, and data layers
5. **Platform awareness** — Handle iOS/Android differences gracefully
## Tech Stack
| Layer | Technologies |
|-------|-------------|
| Framework | Flutter 3.x, Dart 3.x |
| State Management | Riverpod, Bloc, Provider |
| Navigation | go_router, auto_route |
| DI | get_it, injectable |
| Network | dio, retrofit |
| Storage | drift, hive, flutter_secure_storage |
| Testing | flutter_test, mocktail |
## Output Format
```markdown
## Flutter Implementation: [Feature]
### Screens Created
| Screen | Description | State Management |
|--------|-------------|------------------|
| HomeScreen | Main dashboard | Riverpod Provider |
| ProfileScreen | User profile | Bloc |
### Widgets Created
- `UserTile`: Reusable user list item with avatar
- `LoadingIndicator`: Custom loading spinner
- `ErrorWidget`: Unified error display
### State Management
- Using Riverpod StateNotifierProvider
- Immutable state with freezed
- AsyncValue for loading states
### Files Created
- `lib/features/auth/presentation/pages/login_page.dart`
- `lib/features/auth/presentation/widgets/login_form.dart`
- `lib/features/auth/presentation/providers/auth_provider.dart`
- `lib/features/auth/domain/entities/user.dart`
- `lib/features/auth/domain/repositories/auth_repository.dart`
- `lib/features/auth/data/datasources/auth_remote_datasource.dart`
- `lib/features/auth/data/repositories/auth_repository_impl.dart`
### Platform Channels (if any)
- Method channel: `com.app/native`
- Platform: iOS (Swift), Android (Kotlin)
### Tests
- ✅ Unit tests for providers
- ✅ Widget tests for screens
- ✅ Integration tests for critical flows
---
Status: implemented
@CodeSkeptic ready for review
```
## Project Structure Template
```dart
// lib/main.dart
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
// lib/app.dart
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ProviderScope(
child: MaterialApp.router(
routerConfig: router,
theme: AppTheme.light,
darkTheme: AppTheme.dark,
),
);
}
}
```
## Clean Architecture Layers
```dart
// ==================== PRESENTATION LAYER ====================
// lib/features/auth/presentation/pages/login_page.dart
class LoginPage extends StatelessWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Consumer(
builder: (context, ref, child) {
final state = ref.watch(authProvider);
return state.when(
initial: () => const LoginForm(),
loading: () => const LoadingIndicator(),
loaded: (user) => HomePage(user: user),
error: (message) => ErrorWidget(message: message),
);
},
),
);
}
}
// ==================== DOMAIN LAYER ====================
// lib/features/auth/domain/entities/user.dart
@freezed
class User with _$User {
const factory User({
required String id,
required String email,
required String name,
@Default('') String avatarUrl,
@Default(false) bool isVerified,
}) = _User;
}
// lib/features/auth/domain/repositories/auth_repository.dart
abstract class AuthRepository {
Future<Either<Failure, User>> login(String email, String password);
Future<Either<Failure, User>> register(RegisterParams params);
Future<Either<Failure, void>> logout();
Future<Either<Failure, User?>> getCurrentUser();
}
// ==================== DATA LAYER ====================
// lib/features/auth/data/datasources/auth_remote_datasource.dart
abstract class AuthRemoteDataSource {
Future<UserModel> login(String email, String password);
Future<UserModel> register(RegisterParams params);
Future<void> logout();
}
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
final Dio _dio;
AuthRemoteDataSourceImpl(this._dio);
@override
Future<UserModel> login(String email, String password) async {
final response = await _dio.post(
'/auth/login',
data: {'email': email, 'password': password},
);
return UserModel.fromJson(response.data);
}
}
// lib/features/auth/data/repositories/auth_repository_impl.dart
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource remoteDataSource;
final AuthLocalDataSource localDataSource;
final NetworkInfo networkInfo;
AuthRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
required this.networkInfo,
});
@override
Future<Either<Failure, User>> login(String email, String password) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure());
}
try {
final user = await remoteDataSource.login(email, password);
await localDataSource.cacheUser(user);
return Right(user);
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
}
}
}
```
## State Management Templates
### Riverpod Provider
```dart
// lib/features/auth/presentation/providers/auth_provider.dart
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier(ref.read(authRepositoryProvider));
});
class AuthNotifier extends StateNotifier<AuthState> {
final AuthRepository _repository;
AuthNotifier(this._repository) : super(const AuthState.initial());
Future<void> login(String email, String password) async {
state = const AuthState.loading();
final result = await _repository.login(email, password);
result.fold(
(failure) => state = AuthState.error(failure.message),
(user) => state = AuthState.loaded(user),
);
}
}
@freezed
class AuthState with _$AuthState {
const factory AuthState.initial() = _Initial;
const factory AuthState.loading() = _Loading;
const factory AuthState.loaded(User user) = _Loaded;
const factory AuthState.error(String message) = _Error;
}
```
### Bloc/Cubit
```dart
// lib/features/auth/presentation/bloc/auth_bloc.dart
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRepository _repository;
AuthBloc(this._repository) : super(const AuthState.initial()) {
on<LoginEvent>(_onLogin);
on<LogoutEvent>(_onLogout);
}
Future<void> _onLogin(LoginEvent event, Emitter<AuthState> emit) async {
emit(const AuthState.loading());
final result = await _repository.login(event.email, event.password);
result.fold(
(failure) => emit(AuthState.error(failure.message)),
(user) => emit(AuthState.loaded(user)),
);
}
}
```
## Widget Patterns
### Responsive Widget
```dart
class ResponsiveLayout extends StatelessWidget {
const ResponsiveLayout({
super.key,
required this.mobile,
required this.tablet,
this.desktop,
});
final Widget mobile;
final Widget tablet;
final Widget? desktop;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 600) {
return mobile;
} else if (constraints.maxWidth < 900) {
return tablet;
} else {
return desktop ?? tablet;
}
},
);
}
}
```
### Reusable List Item
```dart
class UserTile extends StatelessWidget {
const UserTile({
super.key,
required this.user,
this.onTap,
this.trailing,
});
final User user;
final VoidCallback? onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return ListTile(
leading: CircleAvatar(
backgroundImage: user.avatarUrl.isNotEmpty
? CachedNetworkImageProvider(user.avatarUrl)
: null,
child: user.avatarUrl.isEmpty
? Text(user.name[0].toUpperCase())
: null,
),
title: Text(user.name),
subtitle: Text(user.email),
trailing: trailing,
onTap: onTap,
);
}
}
```
## Navigation Pattern
```dart
// lib/core/navigation/app_router.dart
final router = GoRouter(
debugLogDiagnostics: true,
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomePage(),
),
GoRoute(
path: '/login',
builder: (context, state) => const LoginPage(),
),
GoRoute(
path: '/user/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return UserDetailPage(userId: id);
},
),
ShellRoute(
builder: (context, state, child) => MainShell(child: child),
routes: [
GoRoute(
path: '/home',
builder: (context, state) => const HomeTab(),
),
GoRoute(
path: '/profile',
builder: (context, state) => const ProfileTab(),
),
],
),
],
errorBuilder: (context, state) => ErrorPage(error: state.error),
redirect: (context, state) async {
final isAuthenticated = await authRepository.isAuthenticated();
final isAuthRoute = state.matchedLocation == '/login';
if (!isAuthenticated && !isAuthRoute) {
return '/login';
}
if (isAuthenticated && isAuthRoute) {
return '/home';
}
return null;
},
);
```
## Testing Templates
### Unit Test
```dart
// test/features/auth/domain/usecases/login_test.dart
void main() {
late Login usecase;
late MockAuthRepository mockRepository;
setUp(() {
mockRepository = MockAuthRepository();
usecase = Login(mockRepository);
});
group('Login', () {
final tEmail = 'test@example.com';
final tPassword = 'password123';
final tUser = User(id: '1', email: tEmail, name: 'Test');
test('should return user when login successful', () async {
// Arrange
when(mockRepository.login(tEmail, tPassword))
.thenAnswer((_) async => Right(tUser));
// Act
final result = await usecase(tEmail, tPassword);
// Assert
expect(result, Right(tUser));
verify(mockRepository.login(tEmail, tPassword));
verifyNoMoreInteractions(mockRepository);
});
test('should return failure when login fails', () async {
// Arrange
when(mockRepository.login(tEmail, tPassword))
.thenAnswer((_) async => Left(ServerFailure('Invalid credentials')));
// Act
final result = await usecase(tEmail, tPassword);
// Assert
expect(result, Left(ServerFailure('Invalid credentials')));
});
});
}
```
### Widget Test
```dart
// test/features/auth/presentation/pages/login_page_test.dart
void main() {
group('LoginPage', () {
testWidgets('shows email and password fields', (tester) async {
// Arrange & Act
await tester.pumpWidget(MaterialApp(home: LoginPage()));
// Assert
expect(find.byType(TextField), findsNWidgets(2));
expect(find.text('Email'), findsOneWidget);
expect(find.text('Password'), findsOneWidget);
});
testWidgets('shows error message when form submitted empty', (tester) async {
// Arrange
await tester.pumpWidget(MaterialApp(home: LoginPage()));
// Act
await tester.tap(find.text('Login'));
await tester.pumpAndSettle();
// Assert
expect(find.text('Email is required'), findsOneWidget);
expect(find.text('Password is required'), findsOneWidget);
});
});
}
```
## Platform Channels
```dart
// lib/core/platform/native_bridge.dart
class NativeBridge {
static const _channel = MethodChannel('com.app/native');
Future<String> getDeviceId() async {
try {
return await _channel.invokeMethod('getDeviceId');
} on PlatformException catch (e) {
throw NativeException(e.message ?? 'Unknown error');
}
}
Future<void> shareFile(String path) async {
await _channel.invokeMethod('shareFile', {'path': path});
}
}
// android/app/src/main/kotlin/MainActivity.kt
class MainActivity : FlutterActivity() {
override fun configureFlutterBridge(@NonNull bridge: FlutterBridge) {
super.configureFlutterBridge(bridge)
bridge.setMethodCallHandler { call, result ->
when (call.method) {
"getDeviceId" -> {
result.success(getDeviceId())
}
"shareFile" -> {
val path = call.argument<String>("path")
shareFile(path!!)
result.success(null)
}
else -> result.notImplemented()
}
}
}
}
```
## Build Configuration
```yaml
# pubspec.yaml
name: my_app
version: 1.0.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
flutter: '>=3.10.0'
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
# State Management
flutter_riverpod: 2.4.9
riverpod_annotation: 2.3.3
# Navigation
go_router: 13.1.0
# Network
dio: 5.4.0
retrofit: 4.0.3
# Storage
drift: 2.14.0
flutter_secure_storage: 9.0.0
# Utils
freezed_annotation: 2.4.1
json_annotation: 4.8.1
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: 2.4.7
freezed: 2.4.5
json_serializable: 6.7.1
riverpod_generator: 2.3.9
mocktail: 1.0.1
flutter_lints: 3.0.1
```
## Flutter Commands
```bash
# Development
flutter pub get
flutter run -d <device>
flutter run --flavor development
# Build
flutter build apk --release
flutter build ios --release
flutter build web --release
flutter build appbundle --release
# Testing
flutter test
flutter test --coverage
flutter test integration_test/
# Analysis
flutter analyze
flutter pub outdated
flutter doctor -v
# Clean
flutter clean
flutter pub get
```
## Performance Checklist
- [ ] Use const constructors where possible
- [ ] Use ListView.builder for long lists
- [ ] Avoid unnecessary rebuilds with Provider/Selector
- [ ] Lazy load images with cached_network_image
- [ ] Profile with DevTools
- [ ] Use opacity with caution
- [ ] Avoid large operations in build()
## Security Checklist
- [ ] Use flutter_secure_storage for tokens
- [ ] Implement certificate pinning
- [ ] Validate all user inputs
- [ ] Use obfuscation for release builds
- [ ] Never log sensitive information
- [ ] Use ProGuard/R8 for Android
## Prohibited Actions
- DO NOT use setState for complex state
- DO NOT put business logic in widgets
- DO NOT use dynamic types
- DO NOT ignore lint warnings
- DO NOT skip testing for critical paths
- DO NOT use hot reload as a development strategy
- DO NOT embed secrets in code
- DO NOT use global state for request data
## Skills Reference
This agent uses the following skills for comprehensive Flutter development:
### Core Skills
| Skill | Purpose |
|-------|---------|
| `flutter-widgets` | Material, Cupertino, custom widgets |
| `flutter-state` | Riverpod, Bloc, Provider patterns |
| `flutter-navigation` | go_router, auto_route |
| `flutter-animation` | Implicit, explicit animations |
### Data
| Skill | Purpose |
|-------|---------|
| `flutter-network` | Dio, retrofit, API clients |
| `flutter-storage` | Hive, Drift, secure storage |
| `flutter-serialization` | json_serializable, freezed |
### Platform
| Skill | Purpose |
|-------|---------|
| `flutter-platform` | Platform channels, native code |
| `flutter-camera` | Camera, image picker |
| `flutter-maps` | Google Maps, MapBox |
### Testing
| Skill | Purpose |
|-------|---------|
| `flutter-testing` | Unit, widget, integration tests |
| `flutter-mocking` | mocktail, mockito |
### Rules
| File | Content |
|------|---------|
| `.kilo/rules/flutter.md` | Code style, architecture, best practices |
## Handoff Protocol
After implementation:
1. Run `flutter analyze`
2. Run `flutter test`
3. Check for const opportunities
4. Verify platform-specific code works
5. Test on both iOS and Android (or web)
6. Check performance with DevTools
7. Tag `@CodeSkeptic` for review
## Gitea Commenting (MANDATORY)
**You MUST post a comment to the Gitea issue after completing your work.**
Post a comment with:
1. ✅ Success: What was done, files changed, duration
2. ❌ Error: What failed, why, and blocker
3. ❓ Question: Clarification needed with options
Use the `post_comment` function from `.kilo/skills/gitea-commenting/SKILL.md`.
**NO EXCEPTIONS** - Always comment to Gitea.

View File

@@ -42,6 +42,41 @@
"model": "ollama-cloud/gpt-oss:20b",
"description": "Bug diagnostics and troubleshooting.",
"mode": "primary"
},
"flutter-developer": {
"model": "ollama-cloud/qwen3-coder:480b",
"description": "Flutter mobile specialist for cross-platform apps, state management, and UI components.",
"mode": "subagent",
"permission": {
"read": "allow",
"edit": "allow",
"write": "allow",
"bash": "allow",
"glob": "allow",
"grep": "allow",
"task": {
"*": "deny",
"code-skeptic": "allow",
"visual-tester": "allow"
}
}
},
"go-developer": {
"model": "ollama-cloud/qwen3-coder:480b",
"description": "Go backend specialist for Gin, Echo, APIs, and database integration.",
"mode": "subagent",
"permission": {
"read": "allow",
"edit": "allow",
"write": "allow",
"bash": "allow",
"glob": "allow",
"grep": "allow",
"task": {
"*": "deny",
"code-skeptic": "allow"
}
}
}
}
}

521
.kilo/rules/flutter.md Normal file
View File

@@ -0,0 +1,521 @@
# Flutter Development Rules
Essential rules for Flutter mobile app development.
## Code Style
- Use `final` and `const` wherever possible
- Follow Dart naming conventions
- Use trailing commas for better auto-formatting
- Keep widgets small and focused
- Use meaningful variable names
```dart
// ✅ Good
class UserList extends StatelessWidget {
const UserList({
super.key,
required this.users,
this.onUserTap,
});
final List<User> users;
final VoidCallback(User)? onUserTap;
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
final user = users[index];
return UserTile(
user: user,
onTap: onUserTap,
);
},
);
}
}
// ❌ Bad
class UserList extends StatelessWidget {
UserList(this.users, {this.onUserTap}); // Missing const
final List<User> users;
final Function(User)? onUserTap; // Use VoidCallback instead
@override
Widget build(BuildContext context) {
return ListView(children: users.map((u) => UserTile(u)).toList()); // No const
}
}
```
## Widget Architecture
- Prefer stateless widgets when possible
- Split large widgets into smaller ones
- Use composition over inheritance
- Pass data through constructors
- Keep build methods pure
```dart
// ✅ Good: Split into small widgets
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key, required this.user});
final User user;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: ProfileAppBar(user: user),
body: ProfileBody(user: user),
);
}
}
// ❌ Bad: Everything in one widget
class ProfileScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Profile')),
body: Column(
children: [
// 100+ lines of nested widgets
],
),
);
}
}
```
## State Management
- Use Riverpod, Bloc, or Provider (project choice)
- Keep state close to where it's used
- Separate business logic from UI
- Use immutable state classes
```dart
// ✅ Good: Riverpod state management
final userProvider = StateNotifierProvider<UserNotifier, UserState>((ref) {
return UserNotifier();
});
class UserNotifier extends StateNotifier<UserState> {
UserNotifier() : super(const UserState.initial());
Future<void> loadUser(String id) async {
state = const UserState.loading();
try {
final user = await _userRepository.getUser(id);
state = UserState.loaded(user);
} catch (e) {
state = UserState.error(e.toString());
}
}
}
// ✅ Good: Immutable state with freezed
@freezed
class UserState with _$UserState {
const factory UserState.initial() = _Initial;
const factory UserState.loading() = _Loading;
const factory UserState.loaded(User user) = _Loaded;
const factory UserState.error(String message) = _Error;
}
```
## Error Handling
- Use Result/Either types for async operations
- Never silently catch errors
- Show user-friendly error messages
- Log errors to monitoring service
```dart
// ✅ Good
Future<void> loadData() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final result = await _repository.fetchData();
if (result.isError) {
throw ServerException(result.message);
}
return result.data;
});
}
// ❌ Bad
Future<void> loadData() async {
try {
final data = await _repository.fetchData();
state = data;
} catch (e) {
// Silently swallowing error
}
}
```
## API & Network
- Use dio for HTTP requests
- Implement request interceptors
- Handle connectivity changes
- Cache responses when appropriate
```dart
// ✅ Good
class ApiClient {
final Dio _dio;
ApiClient(this._dio) {
_dio.interceptors.addAll([
AuthInterceptor(),
LoggingInterceptor(),
RetryInterceptor(),
]);
}
Future<Response> get(String path, {Map<String, dynamic>? queryParameters}) async {
try {
return await _dio.get(path, queryParameters: queryParameters);
} on DioException catch (e) {
throw _handleError(e);
}
}
}
class AuthInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
options.headers['Authorization'] = 'Bearer ${_getToken()}';
handler.next(options);
}
}
```
## Navigation
- Use go_router for declarative routing
- Define routes as constants
- Pass data through route parameters
- Handle deep links
```dart
// ✅ Good: go_router setup
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/user/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return UserDetailScreen(userId: id);
},
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
],
errorBuilder: (context, state) => const ErrorScreen(),
);
```
## Testing
- Write unit tests for business logic
- Write widget tests for UI components
- Use mocks for dependencies
- Test edge cases and error states
```dart
// ✅ Good: Unit test
void main() {
group('UserNotifier', () {
late UserNotifier notifier;
late MockUserRepository mockRepository;
setUp(() {
mockRepository = MockUserRepository();
notifier = UserNotifier(mockRepository);
});
test('loads user successfully', () async {
// Arrange
final user = User(id: '1', name: 'Test');
when(mockRepository.getUser('1')).thenAnswer((_) async => user);
// Act
await notifier.loadUser('1');
// Assert
expect(notifier.state, equals(UserState.loaded(user)));
});
test('handles error gracefully', () async {
// Arrange
when(mockRepository.getUser('1')).thenThrow(NetworkException());
// Act
await notifier.loadUser('1');
// Assert
expect(notifier.state, isA<UserError>());
});
});
}
// ✅ Good: Widget test
void main() {
testWidgets('UserTile displays user name', (tester) async {
// Arrange
final user = User(id: '1', name: 'John Doe');
// Act
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: UserTile(user: user),
),
));
// Assert
expect(find.text('John Doe'), findsOneWidget);
});
}
```
## Performance
- Use const constructors
- Avoid rebuilds with Provider/InheritedWidget
- Use ListView.builder for long lists
- Lazy load images with cached_network_image
- Profile with DevTools
```dart
// ✅ Good
class UserTile extends StatelessWidget {
const UserTile({
super.key,
required this.user,
}); // const constructor
final User user;
@override
Widget build(BuildContext context) {
return ListTile(
leading: CachedNetworkImage(
imageUrl: user.avatarUrl,
placeholder: (context, url) => const CircularProgressIndicator(),
errorWidget: (context, url, error) => const Icon(Icons.error),
),
title: Text(user.name),
);
}
}
```
## Platform-Specific Code
- Use separate files with `.dart` and `.freezed.dart` extensions
- Use conditional imports for platform differences
- Follow Material (Android) and Cupertino (iOS) guidelines
```dart
// ✅ Good: Platform-specific styling
Widget buildButton(BuildContext context) {
return Platform.isIOS
? CupertinoButton.filled(
onPressed: onPressed,
child: Text(label),
)
: ElevatedButton(
onPressed: onPressed,
child: Text(label),
);
}
```
## Project Structure
```
lib/
├── main.dart
├── app.dart
├── core/
│ ├── constants/
│ ├── theme/
│ ├── utils/
│ └── errors/
├── features/
│ ├── auth/
│ │ ├── data/
│ │ │ ├── datasources/
│ │ │ ├── models/
│ │ │ └── repositories/
│ │ ├── domain/
│ │ │ ├── entities/
│ │ │ ├── repositories/
│ │ │ └── usecases/
│ │ └── presentation/
│ │ ├── pages/
│ │ ├── widgets/
│ │ └── providers/
│ └── user/
├── shared/
│ ├── widgets/
│ └── services/
└── injection_container.dart
```
## Security
- Never store sensitive data in plain text
- Use flutter_secure_storage for tokens
- Validate all user inputs
- Use certificate pinning for APIs
- Obfuscate release builds
```dart
// ✅ Good
final storage = FlutterSecureStorage();
Future<void> saveToken(String token) async {
await storage.write(key: 'auth_token', value: token);
}
Future<void> buildRelease() async {
await Process.run('flutter', [
'build',
'apk',
'--release',
'--obfuscate',
'--split-debug-info=$debugInfoPath',
]);
}
// ❌ Bad
Future<void> saveToken(String token) async {
await SharedPreferences.setString('auth_token', token); // Insecure!
}
```
## Localization
- Use intl package for translations
- Generate localization files
- Support RTL languages
- Use message formatting for dynamic content
```dart
// ✅ Good
Widget build(BuildContext context) {
return Text(AppLocalizations.of(context).hello(userName));
}
// Generated in l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
```
## Dependencies
- Keep dependencies up to date
- Use exact versions in pubspec.yaml
- Run `flutter pub outdated` regularly
- Use `flutter analyze` before committing
```yaml
# ✅ Good: Exact versions
dependencies:
flutter:
sdk: flutter
riverpod: 2.4.9
go_router: 13.1.0
dio: 5.4.0
# ❌ Bad: Version ranges
dependencies:
flutter:
sdk: flutter
riverpod: ^2.4.0 # Unpredictable
dio: any # Dangerous
```
## Clean Architecture
- Separate layers: presentation, domain, data
- Use dependency injection
- Keep business logic in use cases
- Entities should be pure Dart classes
```dart
// Domain layer
abstract class UserRepository {
Future<User> getUser(String id);
Future<void> saveUser(User user);
}
class GetUser {
final UserRepository repository;
GetUser(this.repository);
Future<User> call(String id) async {
return repository.getUser(id);
}
}
// Data layer
class UserRepositoryImpl implements UserRepository {
final UserRemoteDataSource remoteDataSource;
final UserLocalDataSource localDataSource;
UserRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
});
@override
Future<User> getUser(String id) async {
try {
final remoteUser = await remoteDataSource.getUser(id);
await localDataSource.cacheUser(remoteUser);
return remoteUser;
} catch (e) {
return localDataSource.getUser(id);
}
}
}
```
## Build & Release
- Use flavors for different environments
- Configure build variants
- Sign releases properly
- Upload symbols for crash reporting
```bash
# ✅ Good: Build commands
flutter build apk --flavor production --release
flutter build ios --flavor production --release
flutter build appbundle --flavor production --release
```
## Prohibitions
- DO NOT use `setState` in production code (use state management)
- DO NOT put business logic in widgets
- DO NOT use dynamic types
- DO NOT ignore lint warnings
- DO NOT skip testing for critical paths
- DO NOT use hot reload as a development strategy
- DO NOT embed secrets in code

View File

@@ -0,0 +1,751 @@
# Flutter Navigation Patterns
Production-ready navigation patterns for Flutter apps using go_router and declarative routing.
## Overview
This skill provides canonical patterns for Flutter navigation including go_router setup, nested navigation, guards, and deep links.
## go_router Setup
### 1. Basic Router Configuration
```dart
// lib/core/navigation/app_router.dart
import 'package:go_router/go_router.dart';
final router = GoRouter(
debugLogDiagnostics: true,
initialLocation: '/home',
routes: [
GoRoute(
path: '/',
redirect: (_, __) => '/home',
),
GoRoute(
path: '/home',
name: 'home',
builder: (context, state) => const HomePage(),
),
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginPage(),
),
GoRoute(
path: '/products',
name: 'products',
builder: (context, state) => const ProductListPage(),
routes: [
GoRoute(
path: ':id',
name: 'product-detail',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ProductDetailPage(productId: id);
},
),
],
),
GoRoute(
path: '/profile',
name: 'profile',
builder: (context, state) => const ProfilePage(),
),
],
errorBuilder: (context, state) => ErrorPage(error: state.error),
redirect: (context, state) async {
final isAuthenticated = await authRepository.isAuthenticated();
final isAuthRoute = state.matchedLocation == '/login';
if (!isAuthenticated && !isAuthRoute) {
return '/login';
}
if (isAuthenticated && isAuthRoute) {
return '/home';
}
return null;
},
);
// lib/main.dart
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: router,
title: 'My App',
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
);
}
}
```
### 2. Shell Route (Bottom Navigation)
```dart
// lib/core/navigation/app_router.dart
final router = GoRouter(
routes: [
ShellRoute(
builder: (context, state, child) => MainShell(child: child),
routes: [
GoRoute(
path: '/home',
name: 'home',
builder: (context, state) => const HomeTab(),
),
GoRoute(
path: '/products',
name: 'products',
builder: (context, state) => const ProductsTab(),
),
GoRoute(
path: '/cart',
name: 'cart',
builder: (context, state) => const CartTab(),
),
GoRoute(
path: '/profile',
name: 'profile',
builder: (context, state) => const ProfileTab(),
),
],
),
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginPage(),
),
GoRoute(
path: '/product/:id',
name: 'product-detail',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ProductDetailPage(productId: id);
},
),
],
);
// lib/shared/widgets/shell/main_shell.dart
class MainShell extends StatelessWidget {
const MainShell({
super.key,
required this.child,
});
final Widget child;
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: BottomNavigationBar(
currentIndex: _calculateIndex(context),
onTap: (index) => _onTap(context, index),
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.shopping_bag), label: 'Products'),
BottomNavigationBarItem(icon: Icon(Icons.shopping_cart), label: 'Cart'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
int _calculateIndex(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
if (location.startsWith('/home')) return 0;
if (location.startsWith('/products')) return 1;
if (location.startsWith('/cart')) return 2;
if (location.startsWith('/profile')) return 3;
return 0;
}
void _onTap(BuildContext context, int index) {
switch (index) {
case 0:
context.go('/home');
break;
case 1:
context.go('/products');
break;
case 2:
context.go('/cart');
break;
case 3:
context.go('/profile');
break;
}
}
}
```
### 3. Nested Navigation (Tabs with Own Stack)
```dart
// lib/core/navigation/app_router.dart
final router = GoRouter(
routes: [
ShellRoute(
builder: (context, state, child) => MainShell(child: child),
routes: [
// Home tab with nested navigation
ShellRoute(
builder: (context, state, child) => TabShell(
tabKey: 'home',
child: child,
),
routes: [
GoRoute(
path: '/home',
builder: (context, state) => const HomePage(),
),
GoRoute(
path: '/home/notifications',
builder: (context, state) => const NotificationsPage(),
),
GoRoute(
path: '/home/settings',
builder: (context, state) => const SettingsPage(),
),
],
),
// Products tab with nested navigation
ShellRoute(
builder: (context, state, child) => TabShell(
tabKey: 'products',
child: child,
),
routes: [
GoRoute(
path: '/products',
builder: (context, state) => const ProductListPage(),
),
GoRoute(
path: '/products/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ProductDetailPage(productId: id);
},
),
],
),
],
),
],
);
// lib/shared/widgets/shell/tab_shell.dart
class TabShell extends StatefulWidget {
const TabShell({
super.key,
required this.tabKey,
required this.child,
});
final String tabKey;
final Widget child;
@override
State<TabShell> createState() => TabShellState();
}
class TabShellState extends State<TabShell> with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return widget.child;
}
}
```
## Navigation Guards
### 1. Authentication Guard
```dart
// lib/core/navigation/guards/auth_guard.dart
class AuthGuard {
static String? check({
required GoRouterState state,
required bool isAuthenticated,
required String redirectPath,
}) {
if (!isAuthenticated) {
return redirectPath;
}
return null;
}
}
// Usage in router
final router = GoRouter(
routes: [
// Public routes
GoRoute(
path: '/login',
builder: (context, state) => const LoginPage(),
),
GoRoute(
path: '/register',
builder: (context, state) => const RegisterPage(),
),
// Protected routes
GoRoute(
path: '/profile',
builder: (context, state) => const ProfilePage(),
redirect: (context, state) {
final isAuthenticated = authRepository.isAuthenticated();
if (!isAuthenticated) {
final currentPath = state.matchedLocation;
return '/login?redirect=$currentPath';
}
return null;
},
),
],
);
```
### 2. Feature Flag Guard
```dart
// lib/core/navigation/guards/feature_guard.dart
class FeatureGuard {
static String? check({
required GoRouterState state,
required bool isEnabled,
required String redirectPath,
}) {
if (!isEnabled) {
return redirectPath;
}
return null;
}
}
// Usage
GoRoute(
path: '/beta-feature',
builder: (context, state) => const BetaFeaturePage(),
redirect: (context, state) => FeatureGuard.check(
state: state,
isEnabled: configService.isFeatureEnabled('beta_feature'),
redirectPath: '/home',
),
),
```
## Navigation Helpers
### 1. Extension Methods
```dart
// lib/core/extensions/context_extension.dart
extension NavigationExtension on BuildContext {
void goNamed(
String name, {
Map<String, String> pathParameters = const {},
Map<String, dynamic> queryParameters = const {},
Object? extra,
}) {
goNamed(
name,
pathParameters: pathParameters,
queryParameters: queryParameters,
extra: extra,
);
}
void pushNamed(
String name, {
Map<String, String> pathParameters = const {},
Map<String, dynamic> queryParameters = const {},
Object? extra,
}) {
pushNamed(
name,
pathParameters: pathParameters,
queryParameters: queryParameters,
extra: extra,
);
}
void popWithResult<T>([T? result]) {
if (canPop()) {
pop<T>(result);
}
}
}
```
### 2. Route Names Constants
```dart
// lib/core/navigation/routes.dart
class Routes {
static const home = '/home';
static const login = '/login';
static const register = '/register';
static const products = '/products';
static const productDetail = '/products/:id';
static const cart = '/cart';
static const checkout = '/checkout';
static const profile = '/profile';
static const settings = '/settings';
// Route names
static const homeName = 'home';
static const loginName = 'login';
static const productsName = 'products';
static const productDetailName = 'product-detail';
// Helper methods
static String productPath(String id) => '/products/$id';
static String settingsPath({String? section}) =>
section != null ? '$settings?section=$section' : settings;
}
// Usage
context.go(Routes.home);
context.push(Routes.productPath('123'));
context.pushNamed(Routes.productDetailName, pathParameters: {'id': '123'});
```
## Deep Links
### 1. Deep Link Configuration
```dart
// lib/core/navigation/deep_links.dart
class DeepLinks {
static final Map<String, String> routeMapping = {
'product': '/products',
'category': '/products?category=',
'user': '/profile',
'order': '/orders',
};
static String? parseDeepLink(Uri uri) {
// myapp://product/123 -> /products/123
// myapp://category/electronics -> /products?category=electronics
// https://myapp.com/product/123 -> /products/123
final host = uri.host;
final path = uri.path;
if (routeMapping.containsKey(host)) {
final basePath = routeMapping[host]!;
return '$basePath$path';
}
return null;
}
}
// Android: android/app/src/main/AndroidManifest.xml
// <intent-filter>
// <action android:name="android.intent.action.VIEW" />
// <category android:name="android.intent.category.DEFAULT" />
// <category android:name="android.intent.category.BROWSABLE" />
// <data android:scheme="myapp" />
// <data android:host="product" />
// </intent-filter>
// iOS: ios/Runner/Info.plist
// <key>CFBundleURLTypes</key>
// <array>
// <dict>
// <key>CFBundleURLSchemes</key>
// <array>
// <string>myapp</string>
// </array>
// </dict>
// </array>
```
### 2. Universal Links (iOS) / App Links (Android)
```dart
// lib/core/navigation/universal_links.dart
class UniversalLinks {
static Future<void> init() async {
// Listen for incoming links
final initialLink = await getInitialLink();
if (initialLink != null) {
_handleLink(initialLink);
}
// Listen for links while app is running
linkStream.listen(_handleLink);
}
static void _handleLink(String link) {
final uri = Uri.parse(link);
final path = DeepLinks.parseDeepLink(uri);
if (path != null) {
router.go(path);
}
}
}
```
## Passing Data Between Screens
### 1. Path Parameters
```dart
// Define route with parameter
GoRoute(
path: '/product/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ProductDetailPage(productId: id);
},
),
// Navigate
context.go('/product/123');
// Or with name
context.goNamed(
'product-detail',
pathParameters: {'id': '123'},
);
```
### 2. Query Parameters
```dart
// Define route
GoRoute(
path: '/search',
builder: (context, state) {
final query = state.queryParameters['q'] ?? '';
final category = state.queryParameters['category'];
return SearchPage(query: query, category: category);
},
),
// Navigate
context.go('/search?q=flutter&category=mobile');
// Or with name
context.goNamed(
'search',
queryParameters: {
'q': 'flutter',
'category': 'mobile',
},
);
```
### 3. Extra Object
```dart
// Define route
GoRoute(
path: '/checkout',
builder: (context, state) {
final order = state.extra as Order?;
return CheckoutPage(order: order);
},
),
// Navigate with object
final order = Order(items: [...]);
context.push('/checkout', extra: order);
// Navigate with typed extra
context.pushNamed<Order>('checkout', extra: order);
```
## State Preservation
### 1. Preserve State on Navigation
```dart
// Use KeepAlive for tabs
class ProductsTab extends StatefulWidget {
const ProductsTab({super.key});
@override
State<ProductsTab> createState() => _ProductsTabState();
}
class _ProductsTabState extends State<ProductsTab>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
// This tab's state is preserved when switching tabs
return ProductList();
}
}
```
### 2. Restoration
```dart
// lib/main.dart
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: router,
restorationScopeId: 'app',
);
}
}
// In widgets
class CounterPage extends StatefulWidget {
const CounterPage({super.key});
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> with RestorationMixin {
final RestorableInt _counter = RestorableInt(0);
@override
String get restorationId => 'counter_page';
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
registerForRestoration(_counter, 'counter');
}
@override
void dispose() {
_counter.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(child: Text('${_counter.value}')),
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() => _counter.value++),
child: const Icon(Icons.add),
),
);
}
}
```
## Nested Navigator
### Custom Back Button Handler
```dart
// lib/shared/widgets/back_button_handler.dart
class BackButtonHandler extends StatelessWidget {
const BackButtonHandler({
super.key,
required this.child,
this.onWillPop,
});
final Widget child;
final Future<bool> Function()? onWillPop;
@override
Widget build(BuildContext context) {
return PopScope(
canPop: onWillPop == null,
onPopInvoked: (didPop) async {
if (didPop) return;
if (onWillPop != null) {
final shouldPop = await onWillPop!();
if (shouldPop && context.mounted) {
context.pop();
}
}
},
child: child,
);
}
}
// Usage
BackButtonHandler(
onWillPop: () async {
final shouldPop = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Discard changes?'),
actions: [
TextButton(
onPressed: () => context.pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => context.pop(true),
child: const Text('Discard'),
),
],
),
);
return shouldPop ?? false;
},
child: EditFormPage(),
)
```
## Best Practices
### ✅ Do
```dart
// Use typed navigation
context.goNamed('product-detail', pathParameters: {'id': productId});
// Define route names as constants
static const productDetailRoute = 'product-detail';
// Use extra for complex objects
context.push('/checkout', extra: order);
// Handle errors gracefully
errorBuilder: (context, state) => ErrorPage(error: state.error),
```
### ❌ Don't
```dart
// Don't use hardcoded strings
context.goNamed('product-detail'); // Bad if 'product-detail' is mistyped
// Don't pass large objects in query params
context.push('/page?data=${jsonEncode(largeObject)}'); // Bad
// Don't nest navigators without StatefulShellRoute
Navigator(children: [...]); // Bad within go_router
// Don't forget to handle null parameters
final id = state.pathParameters['id']!; // Crash if missing
```
## See Also
- `flutter-state` - State management for navigation state
- `flutter-widgets` - Widget patterns
- `flutter-testing` - Testing navigation flows

View File

@@ -0,0 +1,508 @@
# Flutter State Management Patterns
Production-ready state management patterns for Flutter apps using Riverpod, Bloc, and Provider.
## Overview
This skill provides canonical patterns for Flutter state management including provider setup, state classes, and reactive UI updates.
## Riverpod Patterns (Recommended)
### 1. StateNotifier Pattern
```dart
// lib/features/auth/presentation/providers/auth_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'auth_provider.freezed.dart';
@freezed
class AuthState with _$AuthState {
const factory AuthState.initial() = _Initial;
const factory AuthState.loading() = _Loading;
const factory AuthState.loaded(User user) = _Loaded;
const factory AuthState.error(String message) = _Error;
}
class AuthNotifier extends StateNotifier<AuthState> {
final AuthRepository _repository;
AuthNotifier(this._repository) : super(const AuthState.initial());
Future<void> login(String email, String password) async {
state = const AuthState.loading();
final result = await _repository.login(email, password);
result.fold(
(failure) => state = AuthState.error(failure.message),
(user) => state = AuthState.loaded(user),
);
}
Future<void> logout() async {
state = const AuthState.loading();
await _repository.logout();
state = const AuthState.initial();
}
}
// Provider definition
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier(ref.read(authRepositoryProvider));
});
```
### 2. Provider with Repository
```dart
// lib/features/auth/data/repositories/auth_repository_provider.dart
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return AuthRepositoryImpl(
remoteDataSource: ref.read(authRemoteDataSourceProvider),
localDataSource: ref.read(authLocalDataSourceProvider),
networkInfo: ref.read(networkInfoProvider),
);
});
// lib/features/auth/presentation/providers/auth_repository_provider.dart
final authRemoteDataSourceProvider = Provider<AuthRemoteDataSource>((ref) {
return AuthRemoteDataSourceImpl(ref.read(dioProvider));
});
final authLocalDataSourceProvider = Provider<AuthLocalDataSource>((ref) {
return AuthLocalDataSourceImpl(ref.read(storageProvider));
});
```
### 3. AsyncValue Pattern
```dart
// lib/features/user/presentation/providers/user_provider.dart
final userProvider = FutureProvider.autoDispose<User?>((ref) async {
final repository = ref.read(userRepositoryProvider);
return repository.getCurrentUser();
});
// Usage in widget
class UserProfileWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(userProvider);
return userAsync.when(
data: (user) => UserCard(user: user!),
loading: () => const CircularProgressIndicator(),
error: (error, stack) => ErrorText(error.toString()),
);
}
}
```
### 4. Computed Providers
```dart
// lib/features/cart/presentation/providers/cart_provider.dart
final cartProvider = StateNotifierProvider<CartNotifier, Cart>((ref) {
return CartNotifier();
});
final cartTotalProvider = Provider<double>((ref) {
final cart = ref.watch(cartProvider);
return cart.items.fold(0.0, (sum, item) => sum + item.price);
});
final cartItemCountProvider = Provider<int>((ref) {
final cart = ref.watch(cartProvider);
return cart.items.length;
});
final isCartEmptyProvider = Provider<bool>((ref) {
final cart = ref.watch(cartProvider);
return cart.items.isEmpty;
});
```
### 5. Provider with Listener
```dart
// lib/features/auth/presentation/pages/login_page.dart
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
ref.listen<AuthState>(authProvider, (previous, next) {
next.when(
initial: () {},
loading: () {},
loaded: (user) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Welcome, ${user.name}!')),
);
context.go('/home');
},
error: (message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
},
);
});
return Scaffold(
body: Consumer(
builder: (context, ref, child) {
final state = ref.watch(authProvider);
return state.when(
initial: () => _buildLoginForm(),
loading: () => const Center(child: CircularProgressIndicator()),
loaded: (_) => const SizedBox.shrink(),
error: (message) => _buildLoginForm(error: message),
);
},
),
);
}
Widget _buildLoginForm({String? error}) {
return Column(
children: [
TextField(controller: _emailController),
TextField(controller: _passwordController, obscureText: true),
if (error != null) Text(error, style: TextStyle(color: Colors.red)),
ElevatedButton(
onPressed: () {
ref.read(authProvider.notifier).login(
_emailController.text,
_passwordController.text,
);
},
child: const Text('Login'),
),
],
);
}
}
```
## Bloc/Cubit Patterns
### 1. Cubit Pattern
```dart
// lib/features/auth/presentation/bloc/auth_cubit.dart
class AuthCubit extends Cubit<AuthState> {
final AuthRepository _repository;
AuthCubit(this._repository) : super(const AuthState.initial());
Future<void> login(String email, String password) async {
emit(const AuthState.loading());
final result = await _repository.login(email, password);
result.fold(
(failure) => emit(AuthState.error(failure.message)),
(user) => emit(AuthState.loaded(user)),
);
}
void logout() {
emit(const AuthState.initial());
_repository.logout();
}
}
// BlocProvider
class LoginPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => AuthCubit(context.read<AuthRepository>()),
child: LoginForm(),
);
}
}
// BlocBuilder
BlocBuilder<AuthCubit, AuthState>(
builder: (context, state) {
return state.when(
initial: () => const LoginForm(),
loading: () => const CircularProgressIndicator(),
loaded: (user) => HomeScreen(user: user),
error: (message) => ErrorWidget(message: message),
);
},
)
```
### 2. Bloc Pattern with Events
```dart
// lib/features/auth/presentation/bloc/auth_bloc.dart
abstract class AuthEvent extends Equatable {
const AuthEvent();
}
class LoginEvent extends AuthEvent {
final String email;
final String password;
const LoginEvent(this.email, this.password);
@override
List<Object> get props => [email, password];
}
class LogoutEvent extends AuthEvent {
@override
List<Object> get props => [];
}
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRepository _repository;
AuthBloc(this._repository) : super(const AuthState.initial()) {
on<LoginEvent>(_onLogin);
on<LogoutEvent>(_onLogout);
}
Future<void> _onLogin(LoginEvent event, Emitter<AuthState> emit) async {
emit(const AuthState.loading());
final result = await _repository.login(event.email, event.password);
result.fold(
(failure) => emit(AuthState.error(failure.message)),
(user) => emit(AuthState.loaded(user)),
);
}
Future<void> _onLogout(LogoutEvent event, Emitter<AuthState> emit) async {
emit(const AuthState.loading());
await _repository.logout();
emit(const AuthState.initial());
}
}
```
## Provider Pattern (Legacy)
### 1. ChangeNotifier Pattern
```dart
// lib/models/user_model.dart
class UserModel extends ChangeNotifier {
User? _user;
bool _isLoading = false;
String? _error;
User? get user => _user;
bool get isLoading => _isLoading;
String? get error => _error;
bool get isAuthenticated => _user != null;
Future<void> login(String email, String password) async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_user = await _authService.login(email, password);
} catch (e) {
_error = e.toString();
}
_isLoading = false;
notifyListeners();
}
void logout() {
_user = null;
notifyListeners();
}
}
// Usage
ChangeNotifierProvider(
create: (_) => UserModel(),
child: MyApp(),
)
// Consumer
Consumer<UserModel>(
builder: (context, userModel, child) {
if (userModel.isLoading) {
return CircularProgressIndicator();
}
if (userModel.error != null) {
return Text(userModel.error!);
}
return UserWidget(user: userModel.user);
},
)
```
## Best Practices
### 1. Immutable State with Freezed
```dart
// lib/features/product/domain/entities/product_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'product_state.freezed.dart';
@freezed
class ProductState with _$ProductState {
const factory ProductState({
@Default([]) List<Product> products,
@Default(false) bool isLoading,
@Default('') String searchQuery,
@Default(1) int page,
@Default(false) bool hasReachedMax,
String? error,
}) = _ProductState;
}
```
### 2. State Notifier with Pagination
```dart
class ProductNotifier extends StateNotifier<ProductState> {
final ProductRepository _repository;
ProductNotifier(this._repository) : super(const ProductState());
Future<void> fetchProducts({bool refresh = false}) async {
if (state.isLoading || (!refresh && state.hasReachedMax)) return;
state = state.copyWith(isLoading: true, error: null);
final page = refresh ? 1 : state.page;
final result = await _repository.getProducts(page: page, search: state.searchQuery);
result.fold(
(failure) => state = state.copyWith(
isLoading: false,
error: failure.message,
),
(newProducts) => state = state.copyWith(
products: refresh ? newProducts : [...state.products, ...newProducts],
isLoading: false,
page: page + 1,
hasReachedMax: newProducts.isEmpty,
),
);
}
void search(String query) {
state = state.copyWith(searchQuery: query, page: 1, hasReachedMax: false);
fetchProducts(refresh: true);
}
}
```
### 3. Family for Parameterized Providers
```dart
// Parameterized provider with family
final productProvider = FutureProvider.family.autoDispose<Product?, String>((ref, id) async {
final repository = ref.read(productRepositoryProvider);
return repository.getProduct(id);
});
// Usage
Consumer(
builder: (context, ref, child) {
final productAsync = ref.watch(productProvider(productId));
return productAsync.when(
data: (product) => ProductCard(product: product!),
loading: () => const SkeletonLoader(),
error: (e, s) => ErrorWidget(e.toString()),
);
},
)
```
## State Management Comparison
| Feature | Riverpod | Bloc | Provider |
|---------|----------|------|----------|
| Learning Curve | Low | Medium | Low |
| Boilerplate | Low | High | Low |
| Testing | Easy | Easy | Medium |
| DevTools | Good | Excellent | Basic |
| Immutable | Yes | Yes | Manual |
| Async | AsyncValue | States | Manual |
## Do's and Don'ts
### ✅ Do
```dart
// Use const constructors
const ProductCard({
super.key,
required this.product,
});
// Use immutable state
@freezed
class State with _$State {
const factory State({...}) = _State;
}
// Use providers for dependency injection
final repositoryProvider = Provider((ref) => Repository());
// Use family for parameterized state
final itemProvider = Provider.family<Item, String>((ref, id) => ...);
```
### ❌ Don't
```dart
// Don't use setState for complex state
setState(() {
_isLoading = true;
_loadData();
});
// Don't mutate state directly
state.items.add(newItem); // Wrong
state = state.copyWith(items: [...state.items, newItem]); // Right
// Don't put business logic in widgets
void _handleLogin() {
// API call here
}
// Don't use ChangeNotifier for new projects
class MyState extends ChangeNotifier { ... }
```
## See Also
- `flutter-widgets` - Widget patterns and best practices
- `flutter-navigation` - go_router and navigation
- `flutter-testing` - Testing state management

View File

@@ -0,0 +1,759 @@
# Flutter Widget Patterns
Production-ready widget patterns for Flutter apps including architecture, composition, and best practices.
## Overview
This skill provides canonical patterns for building Flutter widgets including stateless widgets, state management, custom widgets, and responsive design.
## Core Widget Patterns
### 1. StatelessWidget Pattern
```dart
// lib/features/user/presentation/widgets/user_card.dart
class UserCard extends StatelessWidget {
const UserCard({
super.key,
required this.user,
this.onTap,
this.trailing,
});
final User user;
final VoidCallback? onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
UserAvatar(user: user),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.name,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
user.email,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
if (trailing != null) trailing!,
],
),
),
),
);
}
}
```
### 2. StatefulWidget Pattern
```dart
// lib/features/form/presentation/pages/form_page.dart
class FormPage extends StatefulWidget {
const FormPage({super.key});
@override
State<FormPage> createState() => _FormPageState();
}
class _FormPageState extends State<FormPage> {
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 _submitForm(_emailController.text, _passwordController.text);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Form submitted successfully')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _emailController,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email is required';
}
if (!value.contains('@')) {
return 'Invalid email';
}
return null;
},
),
TextFormField(
controller: _passwordController,
obscureText: true,
validator: (value) {
if (value == null || value.length < 8) {
return 'Password must be at least 8 characters';
}
return null;
},
),
_isLoading
? const CircularProgressIndicator()
: ElevatedButton(
onPressed: _submit,
child: const Text('Submit'),
),
],
),
),
);
}
}
```
### 3. ConsumerWidget Pattern (Riverpod)
```dart
// lib/features/product/presentation/pages/product_list_page.dart
class ProductListPage extends ConsumerWidget {
const ProductListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final productsAsync = ref.watch(productsProvider);
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: productsAsync.when(
data: (products) => products.isEmpty
? const EmptyState(message: 'No products found')
: ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) => ProductTile(product: products[index]),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => ErrorState(message: error.toString()),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.push('/products/new'),
child: const Icon(Icons.add),
),
);
}
}
```
### 4. Composition Pattern
```dart
// lib/shared/widgets/composite/card_container.dart
class CardContainer extends StatelessWidget {
const CardContainer({
super.key,
required this.child,
this.title,
this.subtitle,
this.leading,
this.trailing,
this.onTap,
this.padding = const EdgeInsets.all(16),
this.margin = const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
});
final Widget child;
final String? title;
final String? subtitle;
final Widget? leading;
final Widget? trailing;
final VoidCallback? onTap;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry margin;
@override
Widget build(BuildContext context) {
return Container(
margin: margin,
child: Card(
child: InkWell(
onTap: onTap,
child: Padding(
padding: padding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null || leading != null)
Row(
children: [
if (leading != null) ...[
leading!,
const SizedBox(width: 12),
],
if (title != null)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title!,
style: Theme.of(context).textTheme.titleLarge,
),
if (subtitle != null)
Text(
subtitle!,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
if (trailing != null) trailing!,
],
),
if (title != null || leading != null)
const SizedBox(height: 16),
child,
],
),
),
),
),
);
}
}
```
## Responsive Design
### 1. Responsive Layout
```dart
// lib/shared/widgets/responsive/responsive_layout.dart
class ResponsiveLayout extends StatelessWidget {
const ResponsiveLayout({
super.key,
required this.mobile,
this.tablet,
this.desktop,
this.watch,
});
final Widget mobile;
final Widget? tablet;
final Widget? desktop;
final Widget? watch;
static const int mobileWidth = 600;
static const int tabletWidth = 900;
static const int desktopWidth = 1200;
static bool isMobile(BuildContext context) =>
MediaQuery.of(context).size.width < mobileWidth;
static bool isTablet(BuildContext context) {
final width = MediaQuery.of(context).size.width;
return width >= mobileWidth && width < tabletWidth;
}
static bool isDesktop(BuildContext context) =>
MediaQuery.of(context).size.width >= tabletWidth;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < mobileWidth && watch != null) {
return watch!;
}
if (constraints.maxWidth < tabletWidth) {
return mobile;
}
if (constraints.maxWidth < desktopWidth) {
return tablet ?? mobile;
}
return desktop ?? tablet ?? mobile;
},
);
}
}
// Usage
ResponsiveLayout(
mobile: MobileView(),
tablet: TabletView(),
desktop: DesktopView(),
)
```
### 2. Adaptive Widgets
```dart
// lib/shared/widgets/adaptive/adaptive_scaffold.dart
class AdaptiveScaffold extends StatelessWidget {
const AdaptiveScaffold({
super.key,
required this.title,
required this.body,
this.actions = const [],
this.floatingActionButton,
});
final String title;
final Widget body;
final List<Widget> actions;
final Widget? floatingActionButton;
@override
Widget build(BuildContext context) {
if (Platform.isIOS) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(title),
trailing: Row(children: actions),
),
child: body,
);
}
return Scaffold(
appBar: AppBar(
title: Text(title),
actions: actions,
),
body: body,
floatingActionButton: floatingActionButton,
);
}
}
```
## List Patterns
### 1. ListView with Pagination
```dart
// lib/features/product/presentation/pages/product_list_page.dart
class ProductListView extends ConsumerStatefulWidget {
const ProductListView({super.key});
@override
ConsumerState<ProductListView> createState() => _ProductListViewState();
}
class _ProductListViewState extends ConsumerState<ProductListView> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
// Initial load
Future.microtask(() => ref.read(productsProvider.notifier).fetchProducts());
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_isBottom) {
ref.read(productsProvider.notifier).fetchMore();
}
}
bool get _isBottom {
if (!_scrollController.hasClients) return false;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.offset;
return currentScroll >= (maxScroll * 0.9);
}
@override
Widget build(BuildContext context) {
final state = ref.watch(productsProvider);
return ListView.builder(
controller: _scrollController,
itemCount: state.products.length + (state.hasReachedMax ? 0 : 1),
itemBuilder: (context, index) {
if (index >= state.products.length) {
return const Center(child: CircularProgressIndicator());
}
return ProductTile(product: state.products[index]);
},
);
}
}
```
### 2. Animated List
```dart
// lib/shared/widgets/animated/animated_list_view.dart
class AnimatedListView<T> extends StatelessWidget {
const AnimatedListView({
super.key,
required this.items,
required this.itemBuilder,
this.onRemove,
});
final List<T> items;
final Widget Function(BuildContext, T, int) itemBuilder;
final void Function(T)? onRemove;
@override
Widget build(BuildContext context) {
return AnimatedList(
initialItemCount: items.length,
itemBuilder: (context, index, animation) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(-1, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
)),
child: itemBuilder(context, items[index], index),
);
},
);
}
}
```
## Form Patterns
### 1. Form with Validation
```dart
// lib/features/auth/presentation/pages/register_page.dart
class RegisterPage extends StatelessWidget {
const RegisterPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: _RegisterForm(),
),
);
}
}
class _RegisterForm extends StatefulWidget {
@override
State<_RegisterForm> createState() => _RegisterFormState();
}
class _RegisterFormState extends State<_RegisterForm> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
// Submit form
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
prefixIcon: Icon(Icons.person),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Name is required';
}
if (value.length < 2) {
return 'Name must be at least 2 characters';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email is required';
}
if (!value.contains('@')) {
return 'Invalid email format';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock),
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 8) {
return 'Password must be at least 8 characters';
}
return null;
},
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _submit,
child: const Text('Register'),
),
),
],
),
);
}
}
```
## Custom Widgets
### Loading Shimmer
```dart
// lib/shared/widgets/loading/shimmer_loading.dart
class ShimmerLoading extends StatelessWidget {
const ShimmerLoading({
super.key,
required this.child,
this.baseColor,
this.highlightColor,
});
final Widget child;
final Color? baseColor;
final Color? highlightColor;
@override
Widget build(BuildContext context) {
return Shimmer.fromColors(
baseColor: baseColor ?? Colors.grey[300]!,
highlightColor: highlightColor ?? Colors.grey[100]!,
child: child,
);
}
}
class ProductSkeleton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
height: 200,
color: Colors.white,
),
const SizedBox(height: 8),
Container(
width: 200,
height: 20,
color: Colors.white,
),
const SizedBox(height: 8),
Container(
width: 100,
height: 16,
color: Colors.white,
),
],
),
),
);
}
}
```
### Empty State
```dart
// lib/shared/widgets/empty_state.dart
class EmptyState extends StatelessWidget {
const EmptyState({
super.key,
required this.message,
this.icon,
this.action,
});
final String message;
final IconData? icon;
final Widget? action;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon ?? Icons.inbox_outlined,
size: 64,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(height: 16),
Text(
message,
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
if (action != null) ...[
const SizedBox(height: 24),
action!,
],
],
),
),
);
}
}
```
## Performance Tips
### 1. Use const Constructors
```dart
// ✅ Good
const UserCard({
super.key,
required this.user,
});
// ❌ Bad
UserCard({
super.key,
required this.user,
}) {
// No const
}
```
### 2. Use ListView.builder for Long Lists
```dart
// ✅ Good
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ItemTile(item: items[index]),
)
// ❌ Bad
ListView(
children: items.map((i) => ItemTile(item: i)).toList(),
)
```
### 3. Avoid Unnecessary Rebuilds
```dart
// ✅ Good - use Selector
class ProductPrice extends StatelessWidget {
const ProductPrice({super.key, required this.productId});
final String productId;
@override
Widget build(BuildContext context) {
return Consumer(
builder: (context, ref, child) {
// Only rebuilds when price changes
final price = ref.watch(
productProvider(productId).select((p) => p.price),
);
return Text('\$${price.toStringAsFixed(2)}');
},
);
}
}
// ❌ Bad - rebuilds on any state change
Consumer(
builder: (context, ref, child) {
final product = ref.watch(productProvider(productId));
return Text('\$${product.price}');
},
)
```
## See Also
- `flutter-state` - State management patterns
- `flutter-navigation` - go_router and navigation
- `flutter-testing` - Widget testing patterns

View File

@@ -38,6 +38,8 @@ These agents are invoked automatically by `/pipeline` or manually via `@mention`
| `@lead-developer` | Implements code | Status: testing (tests fail) |
| `@frontend-developer` | UI implementation | When UI work needed |
| `@backend-developer` | Node.js/Express/APIs | When backend needed |
| `@flutter-developer` | Flutter mobile apps | When mobile development |
| `@go-developer` | Go backend services | When Go backend needed |
### Quality Assurance
| Agent | Role | When Invoked |