mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-03 23:53:29 +08:00
feat: add C# and Dart language support
This commit is contained in:
159
rules/dart/coding-style.md
Normal file
159
rules/dart/coding-style.md
Normal file
@@ -0,0 +1,159 @@
|
||||
---
|
||||
paths:
|
||||
- "**/*.dart"
|
||||
- "**/pubspec.yaml"
|
||||
- "**/analysis_options.yaml"
|
||||
---
|
||||
# Dart/Flutter Coding Style
|
||||
|
||||
> This file extends [common/coding-style.md](../common/coding-style.md) with Dart and Flutter-specific content.
|
||||
|
||||
## Formatting
|
||||
|
||||
- **dart format** for all `.dart` files — enforced in CI (`dart format --set-exit-if-changed .`)
|
||||
- Line length: 80 characters (dart format default)
|
||||
- Trailing commas on multi-line argument/parameter lists to improve diffs and formatting
|
||||
|
||||
## Immutability
|
||||
|
||||
- Prefer `final` for local variables and `const` for compile-time constants
|
||||
- Use `const` constructors wherever all fields are `final`
|
||||
- Return unmodifiable collections from public APIs (`List.unmodifiable`, `Map.unmodifiable`)
|
||||
- Use `copyWith()` for state mutations in immutable state classes
|
||||
|
||||
```dart
|
||||
// BAD
|
||||
var count = 0;
|
||||
List<String> items = ['a', 'b'];
|
||||
|
||||
// GOOD
|
||||
final count = 0;
|
||||
const items = ['a', 'b'];
|
||||
```
|
||||
|
||||
## Naming
|
||||
|
||||
Follow Dart conventions:
|
||||
- `camelCase` for variables, parameters, and named constructors
|
||||
- `PascalCase` for classes, enums, typedefs, and extensions
|
||||
- `snake_case` for file names and library names
|
||||
- `SCREAMING_SNAKE_CASE` for constants declared with `const` at top level
|
||||
- Prefix private members with `_`
|
||||
- Extension names describe the type they extend: `StringExtensions`, not `MyHelpers`
|
||||
|
||||
## Null Safety
|
||||
|
||||
- Avoid `!` (bang operator) — prefer `?.`, `??`, `if (x != null)`, or Dart 3 pattern matching; reserve `!` only where a null value is a programming error and crashing is the right behaviour
|
||||
- Avoid `late` unless initialization is guaranteed before first use (prefer nullable or constructor init)
|
||||
- Use `required` for constructor parameters that must always be provided
|
||||
|
||||
```dart
|
||||
// BAD — crashes at runtime if user is null
|
||||
final name = user!.name;
|
||||
|
||||
// GOOD — null-aware operators
|
||||
final name = user?.name ?? 'Unknown';
|
||||
|
||||
// GOOD — Dart 3 pattern matching (exhaustive, compiler-checked)
|
||||
final name = switch (user) {
|
||||
User(:final name) => name,
|
||||
null => 'Unknown',
|
||||
};
|
||||
|
||||
// GOOD — early-return null guard
|
||||
String getUserName(User? user) {
|
||||
if (user == null) return 'Unknown';
|
||||
return user.name; // promoted to non-null after the guard
|
||||
}
|
||||
```
|
||||
|
||||
## Sealed Types and Pattern Matching (Dart 3+)
|
||||
|
||||
Use sealed classes to model closed state hierarchies:
|
||||
|
||||
```dart
|
||||
sealed class AsyncState<T> {
|
||||
const AsyncState();
|
||||
}
|
||||
|
||||
final class Loading<T> extends AsyncState<T> {
|
||||
const Loading();
|
||||
}
|
||||
|
||||
final class Success<T> extends AsyncState<T> {
|
||||
const Success(this.data);
|
||||
final T data;
|
||||
}
|
||||
|
||||
final class Failure<T> extends AsyncState<T> {
|
||||
const Failure(this.error);
|
||||
final Object error;
|
||||
}
|
||||
```
|
||||
|
||||
Always use exhaustive `switch` with sealed types — no default/wildcard:
|
||||
|
||||
```dart
|
||||
// BAD
|
||||
if (state is Loading) { ... }
|
||||
|
||||
// GOOD
|
||||
return switch (state) {
|
||||
Loading() => const CircularProgressIndicator(),
|
||||
Success(:final data) => DataWidget(data),
|
||||
Failure(:final error) => ErrorWidget(error.toString()),
|
||||
};
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Specify exception types in `on` clauses — never use bare `catch (e)`
|
||||
- Never catch `Error` subtypes — they indicate programming bugs
|
||||
- Use `Result`-style types or sealed classes for recoverable errors
|
||||
- Avoid using exceptions for control flow
|
||||
|
||||
```dart
|
||||
// BAD
|
||||
try {
|
||||
await fetchUser();
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
}
|
||||
|
||||
// GOOD
|
||||
try {
|
||||
await fetchUser();
|
||||
} on NetworkException catch (e) {
|
||||
log('Network error: ${e.message}');
|
||||
} on NotFoundException {
|
||||
handleNotFound();
|
||||
}
|
||||
```
|
||||
|
||||
## Async / Futures
|
||||
|
||||
- Always `await` Futures or explicitly call `unawaited()` to signal intentional fire-and-forget
|
||||
- Never mark a function `async` if it never `await`s anything
|
||||
- Use `Future.wait` / `Future.any` for concurrent operations
|
||||
- Check `context.mounted` before using `BuildContext` after any `await` (Flutter 3.7+)
|
||||
|
||||
```dart
|
||||
// BAD — ignoring Future
|
||||
fetchData(); // fire-and-forget without marking intent
|
||||
|
||||
// GOOD
|
||||
unawaited(fetchData()); // explicit fire-and-forget
|
||||
await fetchData(); // or properly awaited
|
||||
```
|
||||
|
||||
## Imports
|
||||
|
||||
- Use `package:` imports throughout — never relative imports (`../`) for cross-feature or cross-layer code
|
||||
- Order: `dart:` → external `package:` → internal `package:` (same package)
|
||||
- No unused imports — `dart analyze` enforces this with `unused_import`
|
||||
|
||||
## Code Generation
|
||||
|
||||
- Generated files (`.g.dart`, `.freezed.dart`, `.gr.dart`) must be committed or gitignored consistently — pick one strategy per project
|
||||
- Never manually edit generated files
|
||||
- Keep generator annotations (`@JsonSerializable`, `@freezed`, `@riverpod`, etc.) on the canonical source file only
|
||||
66
rules/dart/hooks.md
Normal file
66
rules/dart/hooks.md
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
paths:
|
||||
- "**/*.dart"
|
||||
- "**/pubspec.yaml"
|
||||
- "**/analysis_options.yaml"
|
||||
---
|
||||
# Dart/Flutter Hooks
|
||||
|
||||
> This file extends [common/hooks.md](../common/hooks.md) with Dart and Flutter-specific content.
|
||||
|
||||
## PostToolUse Hooks
|
||||
|
||||
Configure in `~/.claude/settings.json`:
|
||||
|
||||
- **dart format**: Auto-format `.dart` files after edit
|
||||
- **dart analyze**: Run static analysis after editing Dart files and surface warnings
|
||||
- **flutter test**: Optionally run affected tests after significant changes
|
||||
|
||||
## Recommended Hook Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": { "tool_name": "Edit", "file_paths": ["**/*.dart"] },
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "dart format $CLAUDE_FILE_PATHS" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pre-commit Checks
|
||||
|
||||
Run before committing Dart/Flutter changes:
|
||||
|
||||
```bash
|
||||
dart format --set-exit-if-changed .
|
||||
dart analyze --fatal-infos
|
||||
flutter test
|
||||
```
|
||||
|
||||
## Useful One-liners
|
||||
|
||||
```bash
|
||||
# Format all Dart files
|
||||
dart format .
|
||||
|
||||
# Analyze and report issues
|
||||
dart analyze
|
||||
|
||||
# Run all tests with coverage
|
||||
flutter test --coverage
|
||||
|
||||
# Regenerate code-gen files
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
# Check for outdated packages
|
||||
flutter pub outdated
|
||||
|
||||
# Upgrade packages within constraints
|
||||
flutter pub upgrade
|
||||
```
|
||||
261
rules/dart/patterns.md
Normal file
261
rules/dart/patterns.md
Normal file
@@ -0,0 +1,261 @@
|
||||
---
|
||||
paths:
|
||||
- "**/*.dart"
|
||||
- "**/pubspec.yaml"
|
||||
---
|
||||
# Dart/Flutter Patterns
|
||||
|
||||
> This file extends [common/patterns.md](../common/patterns.md) with Dart, Flutter, and common ecosystem-specific content.
|
||||
|
||||
## Repository Pattern
|
||||
|
||||
```dart
|
||||
abstract interface class UserRepository {
|
||||
Future<User?> getById(String id);
|
||||
Future<List<User>> getAll();
|
||||
Stream<List<User>> watchAll();
|
||||
Future<void> save(User user);
|
||||
Future<void> delete(String id);
|
||||
}
|
||||
|
||||
class UserRepositoryImpl implements UserRepository {
|
||||
const UserRepositoryImpl(this._remote, this._local);
|
||||
|
||||
final UserRemoteDataSource _remote;
|
||||
final UserLocalDataSource _local;
|
||||
|
||||
@override
|
||||
Future<User?> getById(String id) async {
|
||||
final local = await _local.getById(id);
|
||||
if (local != null) return local;
|
||||
final remote = await _remote.getById(id);
|
||||
if (remote != null) await _local.save(remote);
|
||||
return remote;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<User>> getAll() async {
|
||||
final remote = await _remote.getAll();
|
||||
for (final user in remote) {
|
||||
await _local.save(user);
|
||||
}
|
||||
return remote;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<User>> watchAll() => _local.watchAll();
|
||||
|
||||
@override
|
||||
Future<void> save(User user) => _local.save(user);
|
||||
|
||||
@override
|
||||
Future<void> delete(String id) async {
|
||||
await _remote.delete(id);
|
||||
await _local.delete(id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## State Management: BLoC/Cubit
|
||||
|
||||
```dart
|
||||
// Cubit — simple state transitions
|
||||
class CounterCubit extends Cubit<int> {
|
||||
CounterCubit() : super(0);
|
||||
|
||||
void increment() => emit(state + 1);
|
||||
void decrement() => emit(state - 1);
|
||||
}
|
||||
|
||||
// BLoC — event-driven
|
||||
@immutable
|
||||
sealed class CartEvent {}
|
||||
class CartItemAdded extends CartEvent { CartItemAdded(this.item); final Item item; }
|
||||
class CartItemRemoved extends CartEvent { CartItemRemoved(this.id); final String id; }
|
||||
class CartCleared extends CartEvent {}
|
||||
|
||||
@immutable
|
||||
class CartState {
|
||||
const CartState({this.items = const []});
|
||||
final List<Item> items;
|
||||
CartState copyWith({List<Item>? items}) => CartState(items: items ?? this.items);
|
||||
}
|
||||
|
||||
class CartBloc extends Bloc<CartEvent, CartState> {
|
||||
CartBloc() : super(const CartState()) {
|
||||
on<CartItemAdded>((event, emit) =>
|
||||
emit(state.copyWith(items: [...state.items, event.item])));
|
||||
on<CartItemRemoved>((event, emit) =>
|
||||
emit(state.copyWith(items: state.items.where((i) => i.id != event.id).toList())));
|
||||
on<CartCleared>((_, emit) => emit(const CartState()));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## State Management: Riverpod
|
||||
|
||||
```dart
|
||||
// Simple provider
|
||||
@riverpod
|
||||
Future<List<User>> users(Ref ref) async {
|
||||
final repo = ref.watch(userRepositoryProvider);
|
||||
return repo.getAll();
|
||||
}
|
||||
|
||||
// Notifier for mutable state
|
||||
@riverpod
|
||||
class CartNotifier extends _$CartNotifier {
|
||||
@override
|
||||
List<Item> build() => [];
|
||||
|
||||
void add(Item item) => state = [...state, item];
|
||||
void remove(String id) => state = state.where((i) => i.id != id).toList();
|
||||
void clear() => state = [];
|
||||
}
|
||||
|
||||
// ConsumerWidget
|
||||
class CartPage extends ConsumerWidget {
|
||||
const CartPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final items = ref.watch(cartNotifierProvider);
|
||||
return ListView(
|
||||
children: items.map((item) => CartItemTile(item: item)).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
Constructor injection is preferred. Use `get_it` or Riverpod providers at composition root:
|
||||
|
||||
```dart
|
||||
// get_it registration (in a setup file)
|
||||
void setupDependencies() {
|
||||
final di = GetIt.instance;
|
||||
di.registerSingleton<ApiClient>(ApiClient(baseUrl: Env.apiUrl));
|
||||
di.registerSingleton<UserRepository>(
|
||||
UserRepositoryImpl(di<ApiClient>(), di<LocalDatabase>()),
|
||||
);
|
||||
di.registerFactory(() => UserListViewModel(di<UserRepository>()));
|
||||
}
|
||||
```
|
||||
|
||||
## ViewModel Pattern (without BLoC/Riverpod)
|
||||
|
||||
```dart
|
||||
class UserListViewModel extends ChangeNotifier {
|
||||
UserListViewModel(this._repository);
|
||||
|
||||
final UserRepository _repository;
|
||||
|
||||
AsyncState<List<User>> _state = const Loading();
|
||||
AsyncState<List<User>> get state => _state;
|
||||
|
||||
Future<void> load() async {
|
||||
_state = const Loading();
|
||||
notifyListeners();
|
||||
try {
|
||||
final users = await _repository.getAll();
|
||||
_state = Success(users);
|
||||
} on Exception catch (e) {
|
||||
_state = Failure(e);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## UseCase Pattern
|
||||
|
||||
```dart
|
||||
class GetUserUseCase {
|
||||
const GetUserUseCase(this._repository);
|
||||
final UserRepository _repository;
|
||||
|
||||
Future<User?> call(String id) => _repository.getById(id);
|
||||
}
|
||||
|
||||
class CreateUserUseCase {
|
||||
const CreateUserUseCase(this._repository, this._idGenerator);
|
||||
final UserRepository _repository;
|
||||
final IdGenerator _idGenerator; // injected — domain layer must not depend on uuid package directly
|
||||
|
||||
Future<void> call(CreateUserInput input) async {
|
||||
// Validate, apply business rules, then persist
|
||||
final user = User(id: _idGenerator.generate(), name: input.name, email: input.email);
|
||||
await _repository.save(user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Immutable State with freezed
|
||||
|
||||
```dart
|
||||
@freezed
|
||||
class UserState with _$UserState {
|
||||
const factory UserState({
|
||||
@Default([]) List<User> users,
|
||||
@Default(false) bool isLoading,
|
||||
String? errorMessage,
|
||||
}) = _UserState;
|
||||
}
|
||||
```
|
||||
|
||||
## Clean Architecture Layer Boundaries
|
||||
|
||||
```
|
||||
lib/
|
||||
├── domain/ # Pure Dart — no Flutter, no external packages
|
||||
│ ├── entities/
|
||||
│ ├── repositories/ # Abstract interfaces
|
||||
│ └── usecases/
|
||||
├── data/ # Implements domain interfaces
|
||||
│ ├── datasources/
|
||||
│ ├── models/ # DTOs with fromJson/toJson
|
||||
│ └── repositories/
|
||||
└── presentation/ # Flutter widgets + state management
|
||||
├── pages/
|
||||
├── widgets/
|
||||
└── providers/ (or blocs/ or viewmodels/)
|
||||
```
|
||||
|
||||
- Domain must not import `package:flutter` or any data-layer package
|
||||
- Data layer maps DTOs to domain entities at repository boundaries
|
||||
- Presentation calls use cases, not repositories directly
|
||||
|
||||
## Navigation (GoRouter)
|
||||
|
||||
```dart
|
||||
final router = GoRouter(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const HomePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/users/:id',
|
||||
builder: (context, state) {
|
||||
final id = state.pathParameters['id']!;
|
||||
return UserDetailPage(userId: id);
|
||||
},
|
||||
),
|
||||
],
|
||||
// refreshListenable re-evaluates redirect whenever auth state changes
|
||||
refreshListenable: GoRouterRefreshStream(authCubit.stream),
|
||||
redirect: (context, state) {
|
||||
final isLoggedIn = context.read<AuthCubit>().state is AuthAuthenticated;
|
||||
if (!isLoggedIn && !state.matchedLocation.startsWith('/login')) {
|
||||
return '/login';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
See skill: `flutter-dart-code-review` for the comprehensive review checklist.
|
||||
See skill: `compose-multiplatform-patterns` for Kotlin Multiplatform/Flutter interop patterns.
|
||||
135
rules/dart/security.md
Normal file
135
rules/dart/security.md
Normal file
@@ -0,0 +1,135 @@
|
||||
---
|
||||
paths:
|
||||
- "**/*.dart"
|
||||
- "**/pubspec.yaml"
|
||||
- "**/AndroidManifest.xml"
|
||||
- "**/Info.plist"
|
||||
---
|
||||
# Dart/Flutter Security
|
||||
|
||||
> This file extends [common/security.md](../common/security.md) with Dart, Flutter, and mobile-specific content.
|
||||
|
||||
## Secrets Management
|
||||
|
||||
- Never hardcode API keys, tokens, or credentials in Dart source
|
||||
- Use `--dart-define` or `--dart-define-from-file` for compile-time config (values are not truly secret — use a backend proxy for server-side secrets)
|
||||
- Use `flutter_dotenv` or equivalent, with `.env` files listed in `.gitignore`
|
||||
- Store runtime secrets in platform-secure storage: `flutter_secure_storage` (Keychain on iOS, EncryptedSharedPreferences on Android)
|
||||
|
||||
```dart
|
||||
// BAD
|
||||
const apiKey = 'sk-abc123...';
|
||||
|
||||
// GOOD — compile-time config (not secret, just configurable)
|
||||
const apiKey = String.fromEnvironment('API_KEY');
|
||||
|
||||
// GOOD — runtime secret from secure storage
|
||||
final token = await secureStorage.read(key: 'auth_token');
|
||||
```
|
||||
|
||||
## Network Security
|
||||
|
||||
- Enforce HTTPS — no `http://` calls in production
|
||||
- Configure Android `network_security_config.xml` to block cleartext traffic
|
||||
- Set `NSAppTransportSecurity` in `Info.plist` to disallow arbitrary loads
|
||||
- Set request timeouts on all HTTP clients — never leave defaults
|
||||
- Consider certificate pinning for high-security endpoints
|
||||
|
||||
```dart
|
||||
// Dio with timeout and HTTPS enforcement
|
||||
final dio = Dio(BaseOptions(
|
||||
baseUrl: 'https://api.example.com',
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
));
|
||||
```
|
||||
|
||||
## Input Validation
|
||||
|
||||
- Validate and sanitize all user input before sending to API or storage
|
||||
- Never pass unsanitized input to SQL queries — use parameterized queries (sqflite, drift)
|
||||
- Sanitize deep link URLs before navigation — validate scheme, host, and path parameters
|
||||
- Use `Uri.tryParse` and validate before navigating
|
||||
|
||||
```dart
|
||||
// BAD — SQL injection
|
||||
await db.rawQuery("SELECT * FROM users WHERE email = '$userInput'");
|
||||
|
||||
// GOOD — parameterized
|
||||
await db.query('users', where: 'email = ?', whereArgs: [userInput]);
|
||||
|
||||
// BAD — unvalidated deep link
|
||||
final uri = Uri.parse(incomingLink);
|
||||
context.go(uri.path); // could navigate to any route
|
||||
|
||||
// GOOD — validated deep link
|
||||
final uri = Uri.tryParse(incomingLink);
|
||||
if (uri != null && uri.host == 'myapp.com' && _allowedPaths.contains(uri.path)) {
|
||||
context.go(uri.path);
|
||||
}
|
||||
```
|
||||
|
||||
## Data Protection
|
||||
|
||||
- Store tokens, PII, and credentials only in `flutter_secure_storage`
|
||||
- Never write sensitive data to `SharedPreferences` or local files in plaintext
|
||||
- Clear auth state on logout: tokens, cached user data, cookies
|
||||
- Use biometric authentication (`local_auth`) for sensitive operations
|
||||
- Avoid logging sensitive data — no `print(token)` or `debugPrint(password)`
|
||||
|
||||
## Android-Specific
|
||||
|
||||
- Declare only required permissions in `AndroidManifest.xml`
|
||||
- Export Android components (`Activity`, `Service`, `BroadcastReceiver`) only when necessary; add `android:exported="false"` where not needed
|
||||
- Review intent filters — exported components with implicit intent filters are accessible by any app
|
||||
- Use `FLAG_SECURE` for screens displaying sensitive data (prevents screenshots)
|
||||
|
||||
```xml
|
||||
<!-- AndroidManifest.xml — restrict exported components -->
|
||||
<activity android:name=".MainActivity" android:exported="true">
|
||||
<!-- Only the launcher activity needs exported=true -->
|
||||
</activity>
|
||||
<activity android:name=".SensitiveActivity" android:exported="false" />
|
||||
```
|
||||
|
||||
## iOS-Specific
|
||||
|
||||
- Declare only required usage descriptions in `Info.plist` (`NSCameraUsageDescription`, etc.)
|
||||
- Store secrets in Keychain — `flutter_secure_storage` uses Keychain on iOS
|
||||
- Use App Transport Security (ATS) — disallow arbitrary loads
|
||||
- Enable data protection entitlement for sensitive files
|
||||
|
||||
## WebView Security
|
||||
|
||||
- Use `webview_flutter` v4+ (`WebViewController` / `WebViewWidget`) — the legacy `WebView` widget is removed
|
||||
- Disable JavaScript unless explicitly required (`JavaScriptMode.disabled`)
|
||||
- Validate URLs before loading — never load arbitrary URLs from deep links
|
||||
- Never expose Dart callbacks to JavaScript unless absolutely needed and carefully sandboxed
|
||||
- Use `NavigationDelegate.onNavigationRequest` to intercept and validate navigation requests
|
||||
|
||||
```dart
|
||||
// webview_flutter v4+ API (WebViewController + WebViewWidget)
|
||||
final controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.disabled) // disabled unless required
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onNavigationRequest: (request) {
|
||||
final uri = Uri.tryParse(request.url);
|
||||
if (uri == null || uri.host != 'trusted.example.com') {
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
return NavigationDecision.navigate;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// In your widget tree:
|
||||
WebViewWidget(controller: controller)
|
||||
```
|
||||
|
||||
## Obfuscation and Build Security
|
||||
|
||||
- Enable obfuscation in release builds: `flutter build apk --obfuscate --split-debug-info=./debug-info/`
|
||||
- Keep `--split-debug-info` output out of version control (used for crash symbolication only)
|
||||
- Ensure ProGuard/R8 rules don't inadvertently expose serialized classes
|
||||
- Run `flutter analyze` and address all warnings before release
|
||||
215
rules/dart/testing.md
Normal file
215
rules/dart/testing.md
Normal file
@@ -0,0 +1,215 @@
|
||||
---
|
||||
paths:
|
||||
- "**/*.dart"
|
||||
- "**/pubspec.yaml"
|
||||
- "**/analysis_options.yaml"
|
||||
---
|
||||
# Dart/Flutter Testing
|
||||
|
||||
> This file extends [common/testing.md](../common/testing.md) with Dart and Flutter-specific content.
|
||||
|
||||
## Test Framework
|
||||
|
||||
- **flutter_test** / **dart:test** — built-in test runner
|
||||
- **mockito** (with `@GenerateMocks`) or **mocktail** (no codegen) for mocking
|
||||
- **bloc_test** for BLoC/Cubit unit tests
|
||||
- **fake_async** for controlling time in unit tests
|
||||
- **integration_test** for end-to-end device tests
|
||||
|
||||
## Test Types
|
||||
|
||||
| Type | Tool | Location | When to Write |
|
||||
|------|------|----------|---------------|
|
||||
| Unit | `dart:test` | `test/unit/` | All domain logic, state managers, repositories |
|
||||
| Widget | `flutter_test` | `test/widget/` | All widgets with meaningful behavior |
|
||||
| Golden | `flutter_test` | `test/golden/` | Design-critical UI components |
|
||||
| Integration | `integration_test` | `integration_test/` | Critical user flows on real device/emulator |
|
||||
|
||||
## Unit Tests: State Managers
|
||||
|
||||
### BLoC with `bloc_test`
|
||||
|
||||
```dart
|
||||
group('CartBloc', () {
|
||||
late CartBloc bloc;
|
||||
late MockCartRepository repository;
|
||||
|
||||
setUp(() {
|
||||
repository = MockCartRepository();
|
||||
bloc = CartBloc(repository);
|
||||
});
|
||||
|
||||
tearDown(() => bloc.close());
|
||||
|
||||
blocTest<CartBloc, CartState>(
|
||||
'emits updated items when CartItemAdded',
|
||||
build: () => bloc,
|
||||
act: (b) => b.add(CartItemAdded(testItem)),
|
||||
expect: () => [CartState(items: [testItem])],
|
||||
);
|
||||
|
||||
blocTest<CartBloc, CartState>(
|
||||
'emits empty cart when CartCleared',
|
||||
seed: () => CartState(items: [testItem]),
|
||||
build: () => bloc,
|
||||
act: (b) => b.add(CartCleared()),
|
||||
expect: () => [const CartState()],
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Riverpod with `ProviderContainer`
|
||||
|
||||
```dart
|
||||
test('usersProvider loads users from repository', () async {
|
||||
final container = ProviderContainer(
|
||||
overrides: [userRepositoryProvider.overrideWithValue(FakeUserRepository())],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final result = await container.read(usersProvider.future);
|
||||
expect(result, isNotEmpty);
|
||||
});
|
||||
```
|
||||
|
||||
## Widget Tests
|
||||
|
||||
```dart
|
||||
testWidgets('CartPage shows item count badge', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
cartNotifierProvider.overrideWith(() => FakeCartNotifier([testItem])),
|
||||
],
|
||||
child: const MaterialApp(home: CartPage()),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(find.byType(CartItemTile), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows empty state when cart is empty', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [cartNotifierProvider.overrideWith(() => FakeCartNotifier([]))],
|
||||
child: const MaterialApp(home: CartPage()),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
expect(find.text('Your cart is empty'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
## Fakes Over Mocks
|
||||
|
||||
Prefer hand-written fakes for complex dependencies:
|
||||
|
||||
```dart
|
||||
class FakeUserRepository implements UserRepository {
|
||||
final _users = <String, User>{};
|
||||
Object? fetchError;
|
||||
|
||||
@override
|
||||
Future<User?> getById(String id) async {
|
||||
if (fetchError != null) throw fetchError!;
|
||||
return _users[id];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<User>> getAll() async {
|
||||
if (fetchError != null) throw fetchError!;
|
||||
return _users.values.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<User>> watchAll() => Stream.value(_users.values.toList());
|
||||
|
||||
@override
|
||||
Future<void> save(User user) async {
|
||||
_users[user.id] = user;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id) async {
|
||||
_users.remove(id);
|
||||
}
|
||||
|
||||
void addUser(User user) => _users[user.id] = user;
|
||||
}
|
||||
```
|
||||
|
||||
## Async Testing
|
||||
|
||||
```dart
|
||||
// Use fake_async for controlling timers and Futures
|
||||
test('debounce triggers after 300ms', () {
|
||||
fakeAsync((async) {
|
||||
final debouncer = Debouncer(delay: const Duration(milliseconds: 300));
|
||||
var callCount = 0;
|
||||
debouncer.run(() => callCount++);
|
||||
expect(callCount, 0);
|
||||
async.elapse(const Duration(milliseconds: 200));
|
||||
expect(callCount, 0);
|
||||
async.elapse(const Duration(milliseconds: 200));
|
||||
expect(callCount, 1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Golden Tests
|
||||
|
||||
```dart
|
||||
testWidgets('UserCard golden test', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: UserCard(user: testUser)),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
find.byType(UserCard),
|
||||
matchesGoldenFile('goldens/user_card.png'),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
Run `flutter test --update-goldens` when intentional visual changes are made.
|
||||
|
||||
## Test Naming
|
||||
|
||||
Use descriptive, behavior-focused names:
|
||||
|
||||
```dart
|
||||
test('returns null when user does not exist', () { ... });
|
||||
test('throws NotFoundException when id is empty string', () { ... });
|
||||
testWidgets('disables submit button while form is invalid', (tester) async { ... });
|
||||
```
|
||||
|
||||
## Test Organization
|
||||
|
||||
```
|
||||
test/
|
||||
├── unit/
|
||||
│ ├── domain/
|
||||
│ │ └── usecases/
|
||||
│ └── data/
|
||||
│ └── repositories/
|
||||
├── widget/
|
||||
│ └── presentation/
|
||||
│ └── pages/
|
||||
└── golden/
|
||||
└── widgets/
|
||||
|
||||
integration_test/
|
||||
└── flows/
|
||||
├── login_flow_test.dart
|
||||
└── checkout_flow_test.dart
|
||||
```
|
||||
|
||||
## Coverage
|
||||
|
||||
- Target 80%+ line coverage for business logic (domain + state managers)
|
||||
- All state transitions must have tests: loading → success, loading → error, retry
|
||||
- Run `flutter test --coverage` and inspect `lcov.info` with a coverage reporter
|
||||
- Coverage failures should block CI when below threshold
|
||||
Reference in New Issue
Block a user