- 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
11 KiB
11 KiB
Flutter Development Rules
Essential rules for Flutter mobile app development.
Code Style
- Use
finalandconstwherever possible - Follow Dart naming conventions
- Use trailing commas for better auto-formatting
- Keep widgets small and focused
- Use meaningful variable names
// ✅ 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
// ✅ 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
// ✅ 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
// ✅ 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
// ✅ 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
// ✅ 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
// ✅ 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
// ✅ 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
.dartand.freezed.dartextensions - Use conditional imports for platform differences
- Follow Material (Android) and Cupertino (iOS) guidelines
// ✅ 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
// ✅ 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
// ✅ 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 outdatedregularly - Use
flutter analyzebefore committing
# ✅ 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
// 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
# ✅ 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
setStatein 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