From af5f401a533810926da2bf1ec559456d92c1cabd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8NW=C2=A8?= <¨neroworld@mail.ru¨> Date: Sun, 5 Apr 2026 17:04:13 +0100 Subject: [PATCH] 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 --- .kilo/agents/flutter-developer.md | 707 +++++++++++++++++++++ .kilo/kilo.jsonc | 35 ++ .kilo/rules/flutter.md | 521 ++++++++++++++++ .kilo/skills/flutter-navigation/SKILL.md | 751 ++++++++++++++++++++++ .kilo/skills/flutter-state/SKILL.md | 508 +++++++++++++++ .kilo/skills/flutter-widgets/SKILL.md | 759 +++++++++++++++++++++++ AGENTS.md | 2 + 7 files changed, 3283 insertions(+) create mode 100644 .kilo/agents/flutter-developer.md create mode 100644 .kilo/rules/flutter.md create mode 100644 .kilo/skills/flutter-navigation/SKILL.md create mode 100644 .kilo/skills/flutter-state/SKILL.md create mode 100644 .kilo/skills/flutter-widgets/SKILL.md diff --git a/.kilo/agents/flutter-developer.md b/.kilo/agents/flutter-developer.md new file mode 100644 index 0000000..f2c4923 --- /dev/null +++ b/.kilo/agents/flutter-developer.md @@ -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> login(String email, String password); + Future> register(RegisterParams params); + Future> logout(); + Future> getCurrentUser(); +} + +// ==================== DATA LAYER ==================== + +// lib/features/auth/data/datasources/auth_remote_datasource.dart +abstract class AuthRemoteDataSource { + Future login(String email, String password); + Future register(RegisterParams params); + Future logout(); +} + +class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { + final Dio _dio; + + AuthRemoteDataSourceImpl(this._dio); + + @override + Future 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> 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((ref) { + return AuthNotifier(ref.read(authRepositoryProvider)); +}); + +class AuthNotifier extends StateNotifier { + final AuthRepository _repository; + + AuthNotifier(this._repository) : super(const AuthState.initial()); + + Future 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 { + final AuthRepository _repository; + + AuthBloc(this._repository) : super(const AuthState.initial()) { + on(_onLogin); + on(_onLogout); + } + + Future _onLogin(LoginEvent event, Emitter 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 getDeviceId() async { + try { + return await _channel.invokeMethod('getDeviceId'); + } on PlatformException catch (e) { + throw NativeException(e.message ?? 'Unknown error'); + } + } + + Future 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("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 +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. \ No newline at end of file diff --git a/.kilo/kilo.jsonc b/.kilo/kilo.jsonc index 35bfbee..3bbb10f 100644 --- a/.kilo/kilo.jsonc +++ b/.kilo/kilo.jsonc @@ -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" + } + } } } } \ No newline at end of file diff --git a/.kilo/rules/flutter.md b/.kilo/rules/flutter.md new file mode 100644 index 0000000..ea3a729 --- /dev/null +++ b/.kilo/rules/flutter.md @@ -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 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 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((ref) { + return UserNotifier(); +}); + +class UserNotifier extends StateNotifier { + UserNotifier() : super(const UserState.initial()); + + Future 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 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 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 get(String path, {Map? 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()); + }); + }); +} + +// ✅ 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 saveToken(String token) async { + await storage.write(key: 'auth_token', value: token); +} + +Future buildRelease() async { + await Process.run('flutter', [ + 'build', + 'apk', + '--release', + '--obfuscate', + '--split-debug-info=$debugInfoPath', + ]); +} + +// ❌ Bad +Future 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 getUser(String id); + Future saveUser(User user); +} + +class GetUser { + final UserRepository repository; + + GetUser(this.repository); + + Future 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 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 \ No newline at end of file diff --git a/.kilo/skills/flutter-navigation/SKILL.md b/.kilo/skills/flutter-navigation/SKILL.md new file mode 100644 index 0000000..d52df4c --- /dev/null +++ b/.kilo/skills/flutter-navigation/SKILL.md @@ -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 createState() => TabShellState(); +} + +class TabShellState extends State 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 pathParameters = const {}, + Map queryParameters = const {}, + Object? extra, + }) { + goNamed( + name, + pathParameters: pathParameters, + queryParameters: queryParameters, + extra: extra, + ); + } + + void pushNamed( + String name, { + Map pathParameters = const {}, + Map queryParameters = const {}, + Object? extra, + }) { + pushNamed( + name, + pathParameters: pathParameters, + queryParameters: queryParameters, + extra: extra, + ); + } + + void popWithResult([T? result]) { + if (canPop()) { + pop(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 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 +// +// +// +// +// +// +// + +// iOS: ios/Runner/Info.plist +// CFBundleURLTypes +// +// +// CFBundleURLSchemes +// +// myapp +// +// +// +``` + +### 2. Universal Links (iOS) / App Links (Android) + +```dart +// lib/core/navigation/universal_links.dart +class UniversalLinks { + static Future 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('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 createState() => _ProductsTabState(); +} + +class _ProductsTabState extends State + 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 createState() => _CounterPageState(); +} + +class _CounterPageState extends State 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 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( + 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 \ No newline at end of file diff --git a/.kilo/skills/flutter-state/SKILL.md b/.kilo/skills/flutter-state/SKILL.md new file mode 100644 index 0000000..9e393b6 --- /dev/null +++ b/.kilo/skills/flutter-state/SKILL.md @@ -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 { + final AuthRepository _repository; + + AuthNotifier(this._repository) : super(const AuthState.initial()); + + Future 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 logout() async { + state = const AuthState.loading(); + await _repository.logout(); + state = const AuthState.initial(); + } +} + +// Provider definition +final authProvider = StateNotifierProvider((ref) { + return AuthNotifier(ref.read(authRepositoryProvider)); +}); +``` + +### 2. Provider with Repository + +```dart +// lib/features/auth/data/repositories/auth_repository_provider.dart +final authRepositoryProvider = Provider((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((ref) { + return AuthRemoteDataSourceImpl(ref.read(dioProvider)); +}); + +final authLocalDataSourceProvider = Provider((ref) { + return AuthLocalDataSourceImpl(ref.read(storageProvider)); +}); +``` + +### 3. AsyncValue Pattern + +```dart +// lib/features/user/presentation/providers/user_provider.dart +final userProvider = FutureProvider.autoDispose((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((ref) { + return CartNotifier(); +}); + +final cartTotalProvider = Provider((ref) { + final cart = ref.watch(cartProvider); + return cart.items.fold(0.0, (sum, item) => sum + item.price); +}); + +final cartItemCountProvider = Provider((ref) { + final cart = ref.watch(cartProvider); + return cart.items.length; +}); + +final isCartEmptyProvider = Provider((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 createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState { + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + ref.listen(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 { + final AuthRepository _repository; + + AuthCubit(this._repository) : super(const AuthState.initial()); + + Future 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()), + child: LoginForm(), + ); + } +} + +// BlocBuilder +BlocBuilder( + 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 get props => [email, password]; +} + +class LogoutEvent extends AuthEvent { + @override + List get props => []; +} + +class AuthBloc extends Bloc { + final AuthRepository _repository; + + AuthBloc(this._repository) : super(const AuthState.initial()) { + on(_onLogin); + on(_onLogout); + } + + Future _onLogin(LoginEvent event, Emitter 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 _onLogout(LogoutEvent event, Emitter 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 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( + 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 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 { + final ProductRepository _repository; + + ProductNotifier(this._repository) : super(const ProductState()); + + Future 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((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((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 \ No newline at end of file diff --git a/.kilo/skills/flutter-widgets/SKILL.md b/.kilo/skills/flutter-widgets/SKILL.md new file mode 100644 index 0000000..7a3a7ac --- /dev/null +++ b/.kilo/skills/flutter-widgets/SKILL.md @@ -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 createState() => _FormPageState(); +} + +class _FormPageState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _isLoading = false; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _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 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 createState() => _ProductListViewState(); +} + +class _ProductListViewState extends ConsumerState { + 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 extends StatelessWidget { + const AnimatedListView({ + super.key, + required this.items, + required this.itemBuilder, + this.onRemove, + }); + + final List 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( + 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(); + final _nameController = TextEditingController(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + + @override + void dispose() { + _nameController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _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 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 0015bef..06ecaab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 |