Initial commit of the Flutter Cursor Generator project, including the core generator tool, project brief schema, example project setup, and CI configuration. Added README documentation outlining repository structure, quick start guide, and detailed descriptions of features and architecture pillars.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
---
|
||||
description: "Clean Architecture conventions for {{PROJECT_NAME}}"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Clean Architecture — {{PROJECT_NAME}}
|
||||
|
||||
## Layer structure
|
||||
```
|
||||
lib/features/[feature]/
|
||||
├── domain/
|
||||
│ ├── entities/ ← Pure Dart, no framework imports
|
||||
│ ├── repositories/ ← Abstract interfaces only
|
||||
│ └── usecases/ ← Single-responsibility business operations
|
||||
├── data/
|
||||
│ ├── datasources/ ← Remote (API) + Local (cache) implementations
|
||||
│ ├── models/ ← DTOs with fromJson/toJson (can use Freezed)
|
||||
│ └── repositories/ ← Implements domain/repositories interfaces
|
||||
└── presentation/
|
||||
├── bloc/ or notifiers/
|
||||
├── pages/
|
||||
└── widgets/
|
||||
```
|
||||
|
||||
## Import rules (STRICTLY ENFORCED by arch-guard hook)
|
||||
{{ARCH_IMPORT_RULES}}
|
||||
|
||||
## UseCase pattern
|
||||
```dart
|
||||
// One UseCase = one operation = one method
|
||||
class GetProductsUseCase {
|
||||
final ProductRepository _repository;
|
||||
const GetProductsUseCase(this._repository);
|
||||
|
||||
Future<Either<AppError, List<Product>>> call(ProductFilter filter) =>
|
||||
_repository.getProducts(filter);
|
||||
}
|
||||
```
|
||||
|
||||
## Entity rules
|
||||
- Entities are pure Dart — zero Flutter or framework imports
|
||||
- Entities are immutable — use `final` fields + factory constructors
|
||||
- Entities NEVER have `fromJson`/`toJson` — that belongs in the data layer model
|
||||
|
||||
## Repository rules
|
||||
- Domain defines the **interface** (abstract class)
|
||||
- Data layer **implements** it
|
||||
- Use `Either<Failure, T>` or `Result<T>` return types — never throw in domain
|
||||
|
||||
## Dependency injection
|
||||
- Use `injectable` + `get_it` if `codegen` includes `injectable`
|
||||
- All UseCases injected into BLoC/Notifier via constructor
|
||||
- `DataSource → Repository → UseCase → Bloc` dependency direction
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
description: "Feature-First architecture conventions for {{PROJECT_NAME}}"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Feature-First Architecture — {{PROJECT_NAME}}
|
||||
|
||||
## Folder structure
|
||||
```
|
||||
lib/
|
||||
features/
|
||||
auth/
|
||||
auth_screen.dart
|
||||
auth_provider.dart (or auth_bloc.dart)
|
||||
auth_repository.dart
|
||||
auth_model.dart
|
||||
widgets/
|
||||
login_form.dart
|
||||
social_login_button.dart
|
||||
home/
|
||||
home_screen.dart
|
||||
home_provider.dart
|
||||
...
|
||||
core/
|
||||
di/ ← Dependency injection setup
|
||||
network/ ← HTTP client, interceptors
|
||||
storage/ ← Local storage abstraction
|
||||
widgets/ ← Shared widgets used by 3+ features
|
||||
theme/ ← App theme, colors, text styles
|
||||
routing/ ← Router config
|
||||
shared/
|
||||
models/ ← Models shared across features
|
||||
utils/ ← Pure utility functions
|
||||
```
|
||||
|
||||
## Import rules (STRICTLY ENFORCED by arch-guard hook)
|
||||
{{ARCH_IMPORT_RULES}}
|
||||
|
||||
## Feature isolation rules
|
||||
- A feature folder is self-contained: its own screen, state, model, repository
|
||||
- Features MUST NOT import from other feature folders
|
||||
- Shared code MUST be extracted to `core/` or `shared/` before sharing
|
||||
- The 3-feature rule: if 3+ features need the same widget → move it to `core/widgets/`
|
||||
|
||||
## File naming within a feature
|
||||
- `[feature]_screen.dart` — the main screen widget
|
||||
- `[feature]_provider.dart` — Riverpod providers (or `[feature]_bloc.dart`)
|
||||
- `[feature]_repository.dart` — data fetching + caching
|
||||
- `[feature]_model.dart` — data model (Freezed or plain Dart)
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
description: "Layered architecture conventions for {{PROJECT_NAME}}"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Layered Architecture — {{PROJECT_NAME}}
|
||||
|
||||
## Layers (top → bottom)
|
||||
```
|
||||
Presentation → Service/BLoC → Repository → Data Source
|
||||
```
|
||||
|
||||
## Rules
|
||||
- Dependencies flow downward only — upper layers depend on lower layers
|
||||
- Each layer communicates via interfaces (abstract classes)
|
||||
- Data transformations happen at layer boundaries (DTOs → domain models)
|
||||
{{ARCH_IMPORT_RULES}}
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
description: "MVC architecture conventions for {{PROJECT_NAME}}"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# MVC Architecture — {{PROJECT_NAME}}
|
||||
|
||||
## Layer responsibilities
|
||||
- **Model**: Data + business logic. Pure Dart.
|
||||
- **View**: Renders UI, observes Controller state. No business logic.
|
||||
- **Controller** (GetX): Connects Model ↔ View. Manages state transitions.
|
||||
|
||||
## Import rules
|
||||
{{ARCH_IMPORT_RULES}}
|
||||
|
||||
## Controller rules
|
||||
- Controllers are injected via `Binding`, never created in widgets
|
||||
- One Controller per feature screen (not per widget)
|
||||
- Controllers fetch data in `onInit()`, clean up in `onClose()`
|
||||
- All reactive state marked with `.obs`
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
description: "MVVM architecture conventions for {{PROJECT_NAME}}"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# MVVM Architecture — {{PROJECT_NAME}}
|
||||
|
||||
## Layer responsibilities
|
||||
- **View** (Widget): Renders UI, dispatches user actions to ViewModel. Zero business logic.
|
||||
- **ViewModel**: Holds UI state, calls Model/Repository, exposes streams/notifiers. Zero Flutter imports.
|
||||
- **Model**: Plain Dart data structures + repository interfaces.
|
||||
|
||||
## Import rules
|
||||
{{ARCH_IMPORT_RULES}}
|
||||
|
||||
## ViewModel rules
|
||||
```dart
|
||||
// ViewModel has NO Flutter imports — testable with plain dart test
|
||||
class AuthViewModel extends ChangeNotifier {
|
||||
final AuthRepository _repository;
|
||||
AuthViewModel(this._repository);
|
||||
|
||||
AuthViewState _state = const AuthViewState.initial();
|
||||
AuthViewState get state => _state;
|
||||
|
||||
Future<void> login(String email, String password) async {
|
||||
_state = const AuthViewState.loading();
|
||||
notifyListeners();
|
||||
final result = await _repository.login(email, password);
|
||||
_state = result.fold(
|
||||
(error) => AuthViewState.error(error.message),
|
||||
(user) => AuthViewState.success(user),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
description: "Firebase conventions for {{PROJECT_NAME}}"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Firebase Standards — {{PROJECT_NAME}}
|
||||
|
||||
## Firestore
|
||||
- Collection names: `camelCase` plural — `users`, `products`, `orderItems`
|
||||
- Document IDs: use Firebase auto-IDs unless a natural key exists
|
||||
- **Streams vs Futures**: use `snapshots()` for live data, `get()` for one-time reads
|
||||
- Always handle `FirebaseException` explicitly — catch by `e.code` not generic `Exception`
|
||||
- Paginate large collections with `startAfterDocument` — never fetch unbounded collections
|
||||
|
||||
```dart
|
||||
// ✅ Stream-based real-time listener
|
||||
Stream<List<Product>> watchProducts() {
|
||||
return _firestore.collection('products')
|
||||
.where('isActive', isEqualTo: true)
|
||||
.snapshots()
|
||||
.map((snap) => snap.docs.map(Product.fromDoc).toList());
|
||||
}
|
||||
|
||||
// ✅ Handle FirebaseException by code
|
||||
try {
|
||||
await _firestore.collection('orders').add(order.toMap());
|
||||
} on FirebaseException catch (e) {
|
||||
switch (e.code) {
|
||||
case 'permission-denied': throw AppError.authError('Insufficient permissions');
|
||||
case 'unavailable': throw AppError.networkError();
|
||||
default: throw AppError.unknown(e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Firebase Auth
|
||||
- Always use `authStateChanges()` stream — never cache auth state locally
|
||||
- Handle all error codes: `user-not-found`, `wrong-password`, `email-already-in-use`, `network-request-failed`
|
||||
- Sign-out: clear all local state AND call `FirebaseAuth.instance.signOut()`
|
||||
|
||||
## Cloud Functions
|
||||
- Call via `FirebaseFunctions.instance.httpsCallable('functionName')`
|
||||
- Handle `FirebaseFunctionsException` with `.code` and `.message`
|
||||
- Never expose internal errors to client — functions return structured error responses
|
||||
|
||||
## Security (complement to security-standards.mdc)
|
||||
- Firestore Security Rules must be tested with the emulator before deploying
|
||||
- No `allow read, write: if true` — even in development
|
||||
- Rule coverage: every collection must have explicit rules
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
description: "Real-time feature conventions for {{PROJECT_NAME}}"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Real-time Features — {{PROJECT_NAME}}
|
||||
|
||||
## Connection management
|
||||
- Always expose a `connectionState` stream — UI must show "offline" indicator
|
||||
- Implement exponential backoff for reconnection (1s, 2s, 4s, 8s, max 60s)
|
||||
- Cancel all subscriptions in `dispose()` — memory leaks are the #1 bug in real-time apps
|
||||
|
||||
## Offline-first strategy
|
||||
- Cache last known state locally (Hive, Drift, or Isar)
|
||||
- Show stale data with a "last updated" timestamp while reconnecting
|
||||
- Queue mutations offline, replay on reconnect (use `connectivity_plus`)
|
||||
|
||||
## WebSocket / SSE
|
||||
- Use `web_socket_channel` for WebSocket — never raw `dart:io` WebSocket
|
||||
- Implement heartbeat/ping to detect dead connections
|
||||
- Parse and validate all incoming messages — never trust raw server data
|
||||
|
||||
## UI indicators
|
||||
- Show a persistent banner when offline: "You're offline — changes will sync when reconnected"
|
||||
- Animate the banner away on reconnection — don't just hide it abruptly
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
description: "REST API conventions for {{PROJECT_NAME}}"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# REST API Standards — {{PROJECT_NAME}}
|
||||
|
||||
## HTTP client setup (Dio)
|
||||
```dart
|
||||
// In core/network/dio_client.dart
|
||||
Dio createDioClient({required AppConfig config}) {
|
||||
final dio = Dio(BaseOptions(
|
||||
baseUrl: config.baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
|
||||
));
|
||||
|
||||
dio.interceptors.addAll([
|
||||
AuthInterceptor(tokenStorage: getIt<TokenStorage>()),
|
||||
LoggingInterceptor(), // debug builds only
|
||||
RetryInterceptor(dio), // 3 retries on network errors
|
||||
]);
|
||||
return dio;
|
||||
}
|
||||
```
|
||||
|
||||
## DTOs (Data Transfer Objects)
|
||||
- DTOs live in the `data/` layer — **never** pass raw JSON maps to the domain layer
|
||||
- Use Freezed for DTOs if `codegen` includes `json_serializable` or `freezed`
|
||||
- `fromJson` factory must handle null fields gracefully — API response fields are not guaranteed
|
||||
|
||||
## Error handling
|
||||
```dart
|
||||
// Map HTTP errors → AppError domain types
|
||||
AppError _mapDioError(DioException e) => switch (e.type) {
|
||||
DioExceptionType.connectionTimeout => const NetworkError(statusCode: null),
|
||||
DioExceptionType.receiveTimeout => const NetworkError(statusCode: null),
|
||||
DioExceptionType.badResponse => _mapStatusCode(e.response?.statusCode),
|
||||
DioExceptionType.connectionError => const NetworkError(statusCode: null),
|
||||
_ => UnknownError(e),
|
||||
};
|
||||
```
|
||||
|
||||
## API versioning
|
||||
- Base URL includes version: `https://api.{{PROJECT_NAME}}.com/v1/`
|
||||
- When upgrading API version, keep old version working until all clients migrate
|
||||
|
||||
## Auth token interceptor
|
||||
- Inject `Authorization: Bearer <token>` automatically on every request
|
||||
- On 401: refresh token once, retry original request, then logout if refresh fails
|
||||
- On 403: map to `AppError.authError('Insufficient permissions')`
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
description: "Supabase conventions for {{PROJECT_NAME}}"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Supabase Standards — {{PROJECT_NAME}}
|
||||
|
||||
## Row Level Security (RLS) awareness
|
||||
- **ALWAYS** assume RLS is enabled — never write queries that assume full table access
|
||||
- Test queries in the Supabase dashboard before implementing in Flutter
|
||||
- If a query returns empty unexpectedly, check RLS policies first
|
||||
|
||||
## Queries
|
||||
```dart
|
||||
// ✅ Type-safe query with error handling
|
||||
Future<List<Product>> getProducts() async {
|
||||
final response = await _supabase
|
||||
.from('products')
|
||||
.select('id, name, price, category_id, categories(name)')
|
||||
.eq('is_active', true)
|
||||
.order('created_at', ascending: false)
|
||||
.limit(50);
|
||||
return response.map(Product.fromMap).toList();
|
||||
}
|
||||
```
|
||||
|
||||
## Realtime subscriptions
|
||||
```dart
|
||||
StreamSubscription<List<Map<String, dynamic>>>? _sub;
|
||||
|
||||
void watchOrders(String userId) {
|
||||
_sub = _supabase
|
||||
.from('orders')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('user_id', userId)
|
||||
.listen(
|
||||
(data) => _updateOrders(data),
|
||||
onError: (e) => _handleError(e),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel(); // ALWAYS cancel in dispose()
|
||||
super.dispose();
|
||||
}
|
||||
```
|
||||
|
||||
## Auth session
|
||||
- Use `supabase.auth.onAuthStateChange` stream — never poll auth state
|
||||
- Persist session: `supabase-flutter` handles this automatically via secure storage
|
||||
- `session.accessToken` expires — check `session.isExpired` before sensitive operations
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
description: "Freezed code generation conventions for {{PROJECT_NAME}} — Pillar 4"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Freezed Standards — {{PROJECT_NAME}}
|
||||
|
||||
## When to use Freezed
|
||||
- All domain entities and data models — use `@freezed`
|
||||
- Union types / sealed classes — use `@freezed` with multiple constructors
|
||||
- BLoC states and events — use `@freezed`
|
||||
|
||||
## Model pattern
|
||||
```dart
|
||||
@freezed
|
||||
class Product with _$Product {
|
||||
const factory Product({
|
||||
required String id,
|
||||
required String name,
|
||||
required double price,
|
||||
@Default(0) int stockCount,
|
||||
String? imageUrl,
|
||||
}) = _Product;
|
||||
|
||||
factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json);
|
||||
}
|
||||
```
|
||||
|
||||
## Union type pattern
|
||||
```dart
|
||||
@freezed
|
||||
sealed class ProductState with _$ProductState {
|
||||
const factory ProductState.initial() = ProductInitial;
|
||||
const factory ProductState.loading() = ProductLoading;
|
||||
const factory ProductState.loaded(List<Product> products) = ProductLoaded;
|
||||
const factory ProductState.error(String message) = ProductError;
|
||||
}
|
||||
|
||||
// Usage — exhaustive switch
|
||||
final widget = state.when(
|
||||
initial: () => const SizedBox.shrink(),
|
||||
loading: () => const ProductShimmer(),
|
||||
loaded: (products) => ProductList(products: products),
|
||||
error: (msg) => ErrorWidget(message: msg),
|
||||
);
|
||||
```
|
||||
|
||||
## Critical rules
|
||||
- **NEVER** edit `.freezed.dart` or `.g.dart` files — they are generated
|
||||
- Run `dart run build_runner build --delete-conflicting-outputs` after changes
|
||||
- Run `dart run build_runner watch` during development
|
||||
- Add `*.freezed.dart` and `*.g.dart` to `.gitignore` (or commit them — team must decide)
|
||||
- Always define `copyWith` via Freezed — never write manual `copyWith`
|
||||
|
||||
## Patterns to avoid
|
||||
```dart
|
||||
// ❌ Manual copyWith — replaced by Freezed
|
||||
Product copyWith({String? name, double? price}) => Product(
|
||||
id: id, name: name ?? this.name, price: price ?? this.price,
|
||||
);
|
||||
|
||||
// ✅ Let Freezed generate it
|
||||
product.copyWith(name: 'New Name', price: 9.99)
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
description: "Injectable dependency injection conventions for {{PROJECT_NAME}} — Pillar 4"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Injectable (get_it) Standards — {{PROJECT_NAME}}
|
||||
|
||||
## Setup
|
||||
```dart
|
||||
// lib/core/di/injection.dart
|
||||
@InjectableInit()
|
||||
void configureDependencies() => getIt.init();
|
||||
```
|
||||
|
||||
## Annotations
|
||||
```dart
|
||||
@singleton // One instance for app lifetime
|
||||
@lazySingleton // Created on first access (preferred for most services)
|
||||
@injectable // New instance each time (use sparingly)
|
||||
@factoryMethod // Custom factory logic
|
||||
```
|
||||
|
||||
## Example
|
||||
```dart
|
||||
@lazySingleton
|
||||
class ProductRepository {
|
||||
final DioClient _client;
|
||||
const ProductRepository(this._client); // Constructor injection
|
||||
}
|
||||
|
||||
@injectable
|
||||
class GetProductsUseCase {
|
||||
final ProductRepository _repo;
|
||||
const GetProductsUseCase(this._repo);
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
- Run `dart run build_runner build` after adding/modifying `@injectable` annotations
|
||||
- **NEVER** use `getIt<T>()` in widget `build()` methods — inject via constructor or provider
|
||||
- Use `@module` for third-party registrations (Dio, SharedPreferences, etc.)
|
||||
- Register environment-specific implementations with `@Environment('dev')` / `@Environment('prod')`
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
description: "json_serializable conventions for {{PROJECT_NAME}} — Pillar 4"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# json_serializable Standards — {{PROJECT_NAME}}
|
||||
|
||||
## Model annotation
|
||||
```dart
|
||||
@JsonSerializable(explicitToJson: true) // explicitToJson for nested objects
|
||||
class ProductDto {
|
||||
final String id;
|
||||
final String name;
|
||||
@JsonKey(name: 'unit_price') // snake_case API → camelCase Dart
|
||||
final double unitPrice;
|
||||
@JsonKey(defaultValue: false)
|
||||
final bool isActive;
|
||||
final DateTime createdAt; // auto-converted from ISO 8601 string
|
||||
|
||||
const ProductDto({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.unitPrice,
|
||||
required this.isActive,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory ProductDto.fromJson(Map<String, dynamic> json) => _$ProductDtoFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$ProductDtoToJson(this);
|
||||
}
|
||||
```
|
||||
|
||||
## Critical rules
|
||||
- **NEVER** edit `*.g.dart` files
|
||||
- Use `@JsonKey(defaultValue: ...)` for nullable API fields — API contracts change
|
||||
- Use `explicitToJson: true` whenever the model has nested objects
|
||||
- Null safety: API fields not guaranteed to be non-null should be `String?` not `String`
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
description: "Retrofit (Dio) API client conventions for {{PROJECT_NAME}} — Pillar 4"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Retrofit Standards — {{PROJECT_NAME}}
|
||||
|
||||
## API client definition
|
||||
```dart
|
||||
@RestApi()
|
||||
abstract class ProductApiClient {
|
||||
factory ProductApiClient(Dio dio, {String? baseUrl}) = _ProductApiClient;
|
||||
|
||||
@GET('/products')
|
||||
Future<List<ProductDto>> getProducts(@Query('category') String? category);
|
||||
|
||||
@GET('/products/{id}')
|
||||
Future<ProductDto> getProduct(@Path('id') String id);
|
||||
|
||||
@POST('/products')
|
||||
Future<ProductDto> createProduct(@Body() CreateProductDto dto);
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
- **NEVER** edit `*.g.dart` files
|
||||
- Run `dart run build_runner build` after modifying API client
|
||||
- All DTOs used in Retrofit must have `fromJson`/`toJson` (via `json_serializable` or Freezed)
|
||||
- Handle `DioException` in the repository layer — never let it reach the presentation layer
|
||||
- Use `@Headers({'Content-Type': 'application/json'})` at class level, not per-method
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
description: "Global error handling strategy for {{PROJECT_NAME}} — always applied"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Global Error Handling — {{PROJECT_NAME}}
|
||||
|
||||
## Flutter error boundaries
|
||||
Configure in `main.dart` — do this ONCE and never bypass it:
|
||||
|
||||
```dart
|
||||
void main() {
|
||||
FlutterError.onError = (details) {
|
||||
FlutterError.presentError(details);
|
||||
// TODO: Send to crash reporter (Sentry/Firebase Crashlytics)
|
||||
crashReporter.recordFlutterError(details);
|
||||
};
|
||||
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
// TODO: Send to crash reporter
|
||||
crashReporter.recordError(error, stack, fatal: true);
|
||||
return true;
|
||||
};
|
||||
|
||||
runApp(const MyApp());
|
||||
}
|
||||
```
|
||||
|
||||
## Error types hierarchy
|
||||
Define a sealed class for domain errors — never throw raw exceptions in business logic:
|
||||
|
||||
```dart
|
||||
sealed class AppError {
|
||||
const AppError();
|
||||
}
|
||||
class NetworkError extends AppError { final int? statusCode; const NetworkError({this.statusCode}); }
|
||||
class AuthError extends AppError { final String reason; const AuthError(this.reason); }
|
||||
class NotFoundError extends AppError { final String resource; const NotFoundError(this.resource); }
|
||||
class UnknownError extends AppError { final Object cause; const UnknownError(this.cause); }
|
||||
```
|
||||
|
||||
## Repository layer
|
||||
- Wrap ALL external calls in try/catch and return `Either<AppError, T>` or `Result<T>`
|
||||
- NEVER let raw exceptions bubble to the presentation layer
|
||||
- Log at the repository layer, not the UI layer
|
||||
|
||||
## Presentation layer
|
||||
- Every async widget MUST handle error state explicitly — no silent failures
|
||||
- Show user-friendly error messages: map `AppError` subtype → readable string
|
||||
- Provide a "Try again" action for recoverable errors (network, timeout)
|
||||
- For fatal errors (auth expired), redirect to login — never show a dead screen
|
||||
|
||||
## Crash reporting
|
||||
- Integrate Sentry or Firebase Crashlytics before first TestFlight / Play beta
|
||||
- Set `user` context on crash reporter after login (id only, no PII)
|
||||
- Add `breadcrumbs` for key user actions to aid reproduction
|
||||
|
||||
## Logging strategy
|
||||
```
|
||||
Level | Use case
|
||||
DEBUG | Development only (strip from release)
|
||||
INFO | Key user flows (login, purchase, etc.)
|
||||
WARNING | Recoverable errors, fallbacks used
|
||||
ERROR | Unrecoverable errors, unexpected states
|
||||
```
|
||||
- Use `logger` package — never bare `print()`
|
||||
- Logger instance per class: `final _log = Logger('ClassName');`
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
description: "Localization / i18n conventions for {{PROJECT_NAME}}"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Localization Standards — {{PROJECT_NAME}}
|
||||
|
||||
## Supported locales: {{LOCALES_LIST}}
|
||||
|
||||
## Setup
|
||||
- Use Flutter's built-in `AppLocalizations` (generated from `.arb` files)
|
||||
- ARB files: `lib/l10n/app_en.arb`, `lib/l10n/app_fr.arb`, etc.
|
||||
- Never hardcode user-facing strings — always use `context.l10n.stringKey`
|
||||
|
||||
## AppLocalizations access
|
||||
```dart
|
||||
// In widgets:
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
Text(l10n.welcomeMessage) // ✅
|
||||
|
||||
// Extension for convenience:
|
||||
extension LocalizationX on BuildContext {
|
||||
AppLocalizations get l10n => AppLocalizations.of(this)!;
|
||||
}
|
||||
Text(context.l10n.welcomeMessage) // ✅
|
||||
```
|
||||
|
||||
## ARB file format
|
||||
```json
|
||||
{
|
||||
"welcomeMessage": "Welcome back, {name}!",
|
||||
"@welcomeMessage": {
|
||||
"description": "Shown on home screen after login",
|
||||
"placeholders": {
|
||||
"name": { "type": "String", "example": "Alice" }
|
||||
}
|
||||
},
|
||||
"itemCount": "{count, plural, =0{No items} =1{1 item} other{{count} items}}",
|
||||
"@itemCount": {
|
||||
"placeholders": {
|
||||
"count": { "type": "int" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
- **NEVER** hardcode user-facing strings as string literals in widget files
|
||||
- All new strings added to ALL locale ARB files simultaneously — broken translations break builds
|
||||
- Use ICU message format for plurals and gendered strings
|
||||
- Date/time formatting: use `intl` package `DateFormat` — never `date.toString()`
|
||||
- Currency: use `NumberFormat.currency(locale: locale)` — never manual formatting
|
||||
- RTL support: wrap with `Directionality` where needed; test with Arabic locale
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
description: "Android-specific conventions for {{PROJECT_NAME}} — Pillar 4"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Android Platform Standards — {{PROJECT_NAME}}
|
||||
|
||||
## Target SDK
|
||||
- `compileSdkVersion` / `targetSdkVersion`: 34 (Android 14) minimum for new apps
|
||||
- `minSdkVersion`: 21 (Android 5.0) unless brief specifies otherwise
|
||||
- Update `android/app/build.gradle` when bumping target SDK
|
||||
|
||||
## Permissions
|
||||
- Declare only needed permissions in `AndroidManifest.xml`
|
||||
- Runtime permissions: use `permission_handler` — never skip rationale step
|
||||
- Android 13+: granular media permissions (`READ_MEDIA_IMAGES` not `READ_EXTERNAL_STORAGE`)
|
||||
- Android 14+: `FOREGROUND_SERVICE_TYPE` required for foreground services
|
||||
|
||||
## Adaptive icons
|
||||
- Provide both foreground and background layers in `android/app/src/main/res/`
|
||||
- Test on dark theme, coloured theme, and themed icons (Android 13+)
|
||||
|
||||
## Deep links / App Links
|
||||
- Verify domain ownership: `.well-known/assetlinks.json` on your server
|
||||
- Test with: `adb shell am start -a android.intent.action.VIEW -d "https://{{PROJECT_NAME}}.com/products/123"`
|
||||
|
||||
## ProGuard / R8
|
||||
- Obfuscation rules in `android/app/proguard-rules.pro`
|
||||
- Keep rules for: Dio, Freezed models, `@JsonKey` annotated classes
|
||||
- Test release build thoroughly — obfuscation can break reflection-based code
|
||||
|
||||
## Notifications
|
||||
- Create notification channels before showing any notification (Android 8+)
|
||||
- Notification icons must be monochrome on Android 5+
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
description: "Flutter Desktop conventions for {{PROJECT_NAME}} — Pillar 4"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Flutter Desktop Standards — {{PROJECT_NAME}}
|
||||
|
||||
## Window management
|
||||
- Use `window_manager` package for window title, size, minimize/maximize controls
|
||||
- Set minimum window size to prevent unusable UI: `windowManager.setMinimumSize(const Size(800, 600))`
|
||||
- Remember window position/size across sessions using `shared_preferences`
|
||||
|
||||
## Keyboard shortcuts
|
||||
- All primary actions must have keyboard shortcuts
|
||||
- Use `Shortcuts` widget + `Actions` widget for app-wide shortcuts
|
||||
- Follow platform conventions: `Cmd+S` (macOS), `Ctrl+S` (Windows/Linux) for save
|
||||
|
||||
## Context menus
|
||||
- Right-click context menus: use `ContextMenuRegion` widget
|
||||
- Desktop users expect right-click everywhere
|
||||
|
||||
## Platform-specific behavior
|
||||
```dart
|
||||
if (Platform.isMacOS) {
|
||||
// macOS: use mac_window_manager for traffic lights placement
|
||||
} else if (Platform.isWindows) {
|
||||
// Windows: custom title bar with min/max/close buttons
|
||||
} else if (Platform.isLinux) {
|
||||
// Linux: respect window manager decorations
|
||||
}
|
||||
```
|
||||
|
||||
## File system
|
||||
- `path_provider` provides platform-appropriate directories
|
||||
- `file_picker` for open/save dialogs — never hardcode paths
|
||||
- Handle file permission errors gracefully (especially on macOS with sandboxing)
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
description: "iOS-specific conventions for {{PROJECT_NAME}} — Pillar 4"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# iOS Platform Standards — {{PROJECT_NAME}}
|
||||
|
||||
## Platform-specific imports
|
||||
- Use `dart:io` checks: `if (Platform.isIOS)` for conditional code
|
||||
- iOS-only plugins: declare in `pubspec.yaml` with platform filter
|
||||
- **NEVER** call `dart:io` directly in shared code — use `platform_channel` or `universal_io`
|
||||
|
||||
## iOS-specific requirements
|
||||
- Minimum deployment target: iOS 13.0 (or as specified in `ios/Podfile`)
|
||||
- Privacy manifests: `ios/Runner/PrivacyInfo.xcprivacy` — required for App Store since 2024
|
||||
- Required `Info.plist` keys before using:
|
||||
- Camera: `NSCameraUsageDescription`
|
||||
- Photo library: `NSPhotoLibraryUsageDescription`
|
||||
- Location: `NSLocationWhenInUseUsageDescription`
|
||||
- Notifications: handled via `permission_handler`
|
||||
|
||||
## Push notifications (iOS)
|
||||
- Configure APNs certificates in Xcode signing & capabilities
|
||||
- Request permission with `permission_handler` — show rationale screen first
|
||||
- Handle foreground vs background vs terminated app states separately
|
||||
- Test on a physical device — iOS simulator does not support push
|
||||
|
||||
## Safe area
|
||||
- Always wrap root scaffold with `SafeArea` or use `MediaQuery.of(context).padding`
|
||||
- Dynamic Island / notch: test on iPhone 14 Pro and iPhone 15 Pro simulators
|
||||
|
||||
## App Store compliance
|
||||
- Screenshot: use `ScreenshotController` to exclude sensitive screens
|
||||
- Sign in with Apple: required if any third-party social login is offered
|
||||
- IPv6 compatibility required — no IPv4-only network code
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
description: "Flutter Web conventions for {{PROJECT_NAME}} — Pillar 4"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Flutter Web Standards — {{PROJECT_NAME}}
|
||||
|
||||
## Critical: No dart:io on web
|
||||
- **NEVER** import `dart:io` in shared code — it crashes on web
|
||||
- Use `dart:html` or `universal_io` for platform-specific I/O
|
||||
- Use `path_provider` alternatives: `universal_html` for web file access
|
||||
- Check: `kIsWeb` constant before any platform-specific code
|
||||
|
||||
```dart
|
||||
// ✅ Platform-safe
|
||||
import 'package:universal_io/io.dart';
|
||||
|
||||
// ❌ Crashes on web
|
||||
import 'dart:io';
|
||||
```
|
||||
|
||||
## Web rendering
|
||||
- Choose renderer carefully:
|
||||
- `canvaskit` — pixel-perfect, larger initial load, better for graphics
|
||||
- `html` — smaller load, uses DOM elements, inconsistent rendering
|
||||
- Set in `index.html`: `flutterWebRenderer: "canvaskit"`
|
||||
|
||||
## PWA requirements
|
||||
- Update `web/manifest.json`: name, icons (192×192, 512×512), theme_color
|
||||
- Service worker: configure for offline caching of app shell
|
||||
- Test with Chrome DevTools → Lighthouse → PWA audit
|
||||
|
||||
## Web-specific rendering caveats
|
||||
- `BackdropFilter` has limited support on `html` renderer
|
||||
- `Canvas` operations differ between renderers — test both
|
||||
- Text selection differs — use `SelectableText` not `Text` where appropriate
|
||||
- Scrollbars appear automatically on web — style or hide with `ScrollbarTheme`
|
||||
|
||||
## URL strategy
|
||||
- Use `usePathUrlStrategy()` in `main.dart` for clean URLs (no `#`)
|
||||
- Configure server to redirect all paths to `index.html` (SPA routing)
|
||||
|
||||
## Performance
|
||||
- Lazy-load routes — use `GoRouter` deferred loading
|
||||
- Initial load budget: < 3MB (canvaskit) or < 1MB (html renderer)
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
description: "Auto Route conventions for {{PROJECT_NAME}}"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Auto Route Standards — {{PROJECT_NAME}}
|
||||
|
||||
## Route definitions
|
||||
```dart
|
||||
@AutoRouterConfig()
|
||||
class AppRouter extends $AppRouter {
|
||||
@override
|
||||
List<AutoRoute> get routes => [
|
||||
AutoRoute(page: HomeRoute.page, initial: true),
|
||||
AutoRoute(page: ProductRoute.page, path: '/products/:id'),
|
||||
AutoRoute(page: LoginRoute.page, guards: [AuthGuard]),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation
|
||||
```dart
|
||||
context.router.push(ProductRoute(id: product.id));
|
||||
context.router.pop();
|
||||
context.router.replace(HomeRoute());
|
||||
```
|
||||
|
||||
## Rules
|
||||
- Always use typed `Route` classes — never string paths
|
||||
- Guards implement `AutoRouteGuard`
|
||||
- **NEVER** use `Navigator.push` directly
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
description: "GetX Navigation conventions for {{PROJECT_NAME}}"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# GetX Navigation — {{PROJECT_NAME}}
|
||||
|
||||
## Named routes
|
||||
```dart
|
||||
// app_pages.dart — central route definitions
|
||||
abstract class AppPages {
|
||||
static const initial = Routes.home;
|
||||
static final routes = [
|
||||
GetPage(name: Routes.home, page: () => const HomeView(), binding: HomeBinding()),
|
||||
GetPage(name: Routes.product, page: () => const ProductView(), binding: ProductBinding()),
|
||||
];
|
||||
}
|
||||
|
||||
// Navigate — always use named routes
|
||||
Get.toNamed(Routes.product, arguments: product); // push
|
||||
Get.offAllNamed(Routes.home); // replace stack
|
||||
Get.back(); // pop
|
||||
```
|
||||
|
||||
## Bindings
|
||||
- Every route has a `Binding` class that creates and injects dependencies
|
||||
- **NEVER** use `Get.put()` in a widget — only in Bindings
|
||||
- Use `Get.lazyPut()` for deferred creation
|
||||
|
||||
## Rules
|
||||
- **NEVER** use `Navigator.push/pop`
|
||||
- All route strings in `lib/core/routing/routes.dart` as constants
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
description: "GoRouter conventions for {{PROJECT_NAME}}"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# GoRouter Standards — {{PROJECT_NAME}}
|
||||
|
||||
## Typed routes (mandatory)
|
||||
```dart
|
||||
// Define typed routes — never use string paths directly in navigation calls
|
||||
@TypedGoRoute<HomeRoute>(path: '/')
|
||||
class HomeRoute extends GoRouteData {
|
||||
const HomeRoute();
|
||||
@override Widget build(BuildContext ctx, GoRouterState state) => const HomeScreen();
|
||||
}
|
||||
|
||||
@TypedGoRoute<ProductRoute>(path: '/products/:id')
|
||||
class ProductRoute extends GoRouteData {
|
||||
final String id;
|
||||
const ProductRoute({required this.id});
|
||||
@override Widget build(BuildContext ctx, GoRouterState state) => ProductScreen(id: id);
|
||||
}
|
||||
|
||||
// Navigate with type safety
|
||||
const ProductRoute(id: product.id).go(context); // ✅
|
||||
context.go('/products/${product.id}'); // ❌ — don't do this
|
||||
```
|
||||
|
||||
## Auth guard
|
||||
```dart
|
||||
// Redirect logic in router config
|
||||
redirect: (context, state) {
|
||||
final isLoggedIn = ref.read(authProvider).isAuthenticated;
|
||||
final isLoginRoute = state.matchedLocation == '/login';
|
||||
if (!isLoggedIn && !isLoginRoute) return '/login';
|
||||
if (isLoggedIn && isLoginRoute) return '/';
|
||||
return null;
|
||||
},
|
||||
```
|
||||
|
||||
## Shell routes for bottom navigation
|
||||
```dart
|
||||
ShellRoute(
|
||||
builder: (ctx, state, child) => MainScaffold(child: child),
|
||||
routes: [
|
||||
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
||||
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
||||
GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
## Deep links
|
||||
- Register URL schemes in `Info.plist` (iOS) and `AndroidManifest.xml`
|
||||
- Test deep links with: `adb shell am start -a android.intent.action.VIEW -d "app://{{PACKAGE_ID}}/products/123"`
|
||||
- Handle `GoRouter.of(context).routerDelegate.currentConfiguration` for dynamic links
|
||||
|
||||
## Rules
|
||||
- **NEVER** use `Navigator.push/pop` — use `context.go()`, `context.push()`, `context.pop()`
|
||||
- All routes declared in one file: `lib/core/routing/app_router.dart`
|
||||
- `BlocProvider`s for route-level blocs created inside the `builder` of each route
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
description: "Security standards for {{PROJECT_NAME}} — ALWAYS APPLIED on every file write"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Security Standards — {{PROJECT_NAME}}
|
||||
> **Pillar 5**: Security is an always-on rule, not just a reactive agent.
|
||||
> These rules apply to EVERY file write, regardless of feature or context.
|
||||
|
||||
## Credential & secret management
|
||||
- **NEVER** hardcode API keys, tokens, or secrets in source code
|
||||
- **NEVER** commit `.env` files — use `.gitignore` and document required vars in `.env.example`
|
||||
- API keys belong in: flavored `--dart-define` build args, or a secrets manager (e.g. AWS Secrets)
|
||||
- Use `flutter_secure_storage` for tokens/credentials — **NEVER** `SharedPreferences` for sensitive data
|
||||
- Obfuscation: enable `--obfuscate --split-debug-info=build/debug-symbols/` for release builds
|
||||
|
||||
## Authentication & sessions
|
||||
- JWT/session tokens: stored in `flutter_secure_storage`, never in `SharedPreferences` or local DB
|
||||
- Implement token refresh with retry logic — never let a 401 show a raw error to the user
|
||||
- Certificate pinning: required for production builds on {{AUTH}} — use `dio_pinning_interceptor`
|
||||
- Biometric re-auth: require for any transaction > defined threshold
|
||||
|
||||
## Data & privacy
|
||||
- **NEVER** log PII (names, emails, phone numbers, addresses) to console or crash reporters
|
||||
- Sanitize all user inputs before sending to backend
|
||||
- Use `SensitiveWidget` wrapper to exclude screens from screenshots / app switcher thumbnails
|
||||
- Comply with platform privacy requirements: iOS `NSPrivacyAccessedAPITypes`, Android permissions
|
||||
|
||||
## Network security
|
||||
- All API calls use HTTPS — no http:// in production
|
||||
- Validate SSL certificates — never bypass with `badCertificateCallback: (_,_,_) => true`
|
||||
- Add a timeout to every HTTP call: `connectTimeout: Duration(seconds: 10)`
|
||||
- Rate-limit sensitive endpoints: auth, OTP, password reset
|
||||
|
||||
## Dependency security
|
||||
- Run `dart pub outdated` monthly — flag packages with known CVEs
|
||||
- Never use a package with <100 pub points without explicit tech lead approval
|
||||
- Pin critical security packages to exact versions in `pubspec.yaml`
|
||||
|
||||
## Secure coding patterns
|
||||
```dart
|
||||
// ✅ Correct: secure token storage
|
||||
final storage = FlutterSecureStorage();
|
||||
await storage.write(key: 'access_token', value: token);
|
||||
|
||||
// ❌ Wrong: SharedPreferences for sensitive data
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
prefs.setString('access_token', token); // NEVER do this
|
||||
|
||||
// ✅ Correct: PII-safe logging
|
||||
logger.info('User authenticated: userId=${user.id}'); // ok — id, not email
|
||||
logger.debug('Payment processed: orderId=$orderId'); // ok — no card data
|
||||
|
||||
// ❌ Wrong: PII in logs
|
||||
logger.info('Login: email=${user.email}, phone=${user.phone}'); // NEVER
|
||||
|
||||
// ✅ Correct: --dart-define for secrets (build arg, not in code)
|
||||
// flutter build apk --dart-define=API_KEY=$API_KEY
|
||||
const apiKey = String.fromEnvironment('API_KEY'); // acceptable
|
||||
|
||||
// ✅ Correct: certificate pinning with Dio
|
||||
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
|
||||
final client = HttpClient();
|
||||
client.badCertificateCallback = (cert, host, port) => false; // strict
|
||||
return client;
|
||||
};
|
||||
```
|
||||
|
||||
## Deep linking & intent security
|
||||
- Validate all incoming deep link parameters — never trust raw URL params
|
||||
- Use `app_links` package for verified deep link handling
|
||||
- Restrict URL schemes to known patterns — reject unknown schemes
|
||||
- For OAuth callbacks: validate `state` parameter to prevent CSRF
|
||||
|
||||
## Storage security
|
||||
- Encrypt locally cached sensitive data using `hive` with `HiveAesCipher`
|
||||
- Clear sensitive data from memory after use (set to null, trigger GC)
|
||||
- Do NOT cache auth tokens in network layer (no `dio_cache_interceptor` for auth endpoints)
|
||||
- Use `SecureRandom` for nonce/token generation — never `Random()`
|
||||
|
||||
## Code obfuscation & binary protection
|
||||
```yaml
|
||||
# android/app/build.gradle — release config
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
```
|
||||
```bash
|
||||
# Flutter release build with obfuscation
|
||||
flutter build apk --release --obfuscate --split-debug-info=build/debug-symbols/
|
||||
flutter build ipa --release --obfuscate --split-debug-info=build/debug-symbols/
|
||||
```
|
||||
|
||||
## Release checklist
|
||||
Before every production release, verify:
|
||||
- [ ] No hardcoded secrets (`grep -r "api_key\|secret\|password\|token" lib/`)
|
||||
- [ ] Debug flags disabled (`kDebugMode` guards on all debug-only paths)
|
||||
- [ ] Obfuscation enabled in release build config
|
||||
- [ ] Certificate pinning active and tested on both platforms
|
||||
- [ ] Crash reporter PII filters configured (Sentry `beforeSend`, Firebase `setConsentType`)
|
||||
- [ ] `flutter_secure_storage` used for all tokens — no `SharedPreferences` for sensitive keys
|
||||
- [ ] Network calls all HTTPS — scan for `http://` in lib/
|
||||
- [ ] Dependencies audited: `dart pub outdated --json | jq '.packages[] | select(.isDiscontinued)'`
|
||||
- [ ] Deep link parameters validated
|
||||
- [ ] App transport security / network security config reviewed
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
description: "BLoC / Cubit conventions for {{PROJECT_NAME}}"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# BLoC / Cubit Standards — {{PROJECT_NAME}}
|
||||
|
||||
## When to use BLoC vs Cubit
|
||||
- **Cubit**: simple state with no meaningful event history (toggle, counter, pagination)
|
||||
- **BLoC**: event-driven flows where event history or transitions matter (auth, checkout, form wizard)
|
||||
|
||||
## Event and State classes
|
||||
```dart
|
||||
// Events — sealed class (exhaustive switch)
|
||||
sealed class AuthEvent { const AuthEvent(); }
|
||||
final class AuthLoginRequested extends AuthEvent {
|
||||
final String email, password;
|
||||
const AuthLoginRequested({required this.email, required this.password});
|
||||
}
|
||||
final class AuthLogoutRequested extends AuthEvent { const AuthLogoutRequested(); }
|
||||
|
||||
// States — sealed class, immutable
|
||||
sealed class AuthState { const AuthState(); }
|
||||
final class AuthInitial extends AuthState { const AuthInitial(); }
|
||||
final class AuthLoading extends AuthState { const AuthLoading(); }
|
||||
final class AuthAuthenticated extends AuthState {
|
||||
final User user;
|
||||
const AuthAuthenticated(this.user);
|
||||
}
|
||||
final class AuthUnauthenticated extends AuthState { const AuthUnauthenticated(); }
|
||||
final class AuthFailure extends AuthState {
|
||||
final String message;
|
||||
const AuthFailure(this.message);
|
||||
}
|
||||
```
|
||||
|
||||
## BlocProvider placement
|
||||
- Create `BlocProvider` at the **route level** (in {{ROUTING}} route definitions)
|
||||
- `MultiBlocProvider` at route level for screens needing multiple blocs
|
||||
- **NEVER** create `BlocProvider` inside a widget's `build()` method
|
||||
|
||||
## Usage rules
|
||||
- **NEVER** call `bloc.add()` inside `build()` — only in gesture callbacks or `initState()`
|
||||
- Use `BlocConsumer` only when BOTH `listen` + `build` logic are needed
|
||||
- Use `BlocSelector` when only a subset of state triggers a rebuild
|
||||
- Every BLoC must override `close()` and cancel `StreamSubscription`s
|
||||
|
||||
## BlocBuilder patterns
|
||||
```dart
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) => switch (state) {
|
||||
AuthInitial() => const SizedBox.shrink(),
|
||||
AuthLoading() => const LoadingIndicator(),
|
||||
AuthAuthenticated(user: final u) => HomeScreen(user: u),
|
||||
AuthUnauthenticated() => const LoginScreen(),
|
||||
AuthFailure(message: final m) => ErrorScreen(message: m),
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
## File locations in {{PROJECT_NAME}}
|
||||
- `lib/features/[feature]/presentation/bloc/[feature]_bloc.dart`
|
||||
- `lib/features/[feature]/presentation/bloc/[feature]_event.dart`
|
||||
- `lib/features/[feature]/presentation/bloc/[feature]_state.dart`
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
description: "GetX conventions for {{PROJECT_NAME}} (legacy — migration available)"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# GetX Standards — {{PROJECT_NAME}}
|
||||
> ⚠️ This project uses GetX. See `migration-agent` for incremental migration to Riverpod.
|
||||
|
||||
## Controller structure
|
||||
```dart
|
||||
class ProductsController extends GetxController {
|
||||
final ProductRepository _repo;
|
||||
ProductsController(this._repo);
|
||||
|
||||
final RxList<Product> products = <Product>[].obs;
|
||||
final RxBool isLoading = false.obs;
|
||||
final Rx<String?> error = Rx(null);
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
fetchProducts();
|
||||
}
|
||||
|
||||
Future<void> fetchProducts() async {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
products.value = await _repo.getProducts();
|
||||
} catch (e) {
|
||||
error.value = e.toString();
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## View pattern
|
||||
```dart
|
||||
// Views extend GetView<Controller> — never GetWidget or raw StatelessWidget
|
||||
class ProductsView extends GetView<ProductsController> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Obx(() {
|
||||
if (controller.isLoading.value) return const ProductShimmer();
|
||||
if (controller.error.value != null) return ErrorWidget(controller.error.value!);
|
||||
return ProductList(controller.products);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
- **NEVER** pass `BuildContext` into a controller
|
||||
- Use `Binding` classes for dependency injection — never `Get.put()` in a widget
|
||||
- Use `.obs` for all reactive state — never call `update()` on non-observable state
|
||||
- Use `Get.find<Controller>()` only in `Binding` classes, not in widgets
|
||||
- **No business logic in Views** — controllers handle all logic
|
||||
|
||||
## File locations in {{PROJECT_NAME}}
|
||||
- `lib/features/[feature]/views/[feature]_view.dart`
|
||||
- `lib/features/[feature]/controllers/[feature]_controller.dart`
|
||||
- `lib/features/[feature]/bindings/[feature]_binding.dart`
|
||||
- `lib/features/[feature]/models/[feature]_model.dart`
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
description: "Hooks + Riverpod conventions for {{PROJECT_NAME}}"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Hooks + Riverpod Standards — {{PROJECT_NAME}}
|
||||
|
||||
## Widget base classes
|
||||
- `HookConsumerWidget` — when you need BOTH hooks and Riverpod providers
|
||||
- `HookWidget` — when you need ONLY hooks (no Riverpod)
|
||||
- `ConsumerWidget` — when you need ONLY Riverpod (no hooks)
|
||||
|
||||
## Hook rules
|
||||
```dart
|
||||
class ProductSearchWidget extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Hooks at the TOP of build() — never inside conditions or loops
|
||||
final searchQuery = useState('');
|
||||
final searchCtrl = useTextEditingController();
|
||||
final focusNode = useFocusNode();
|
||||
final debounced = useDebounced(searchQuery.value, const Duration(milliseconds: 300));
|
||||
|
||||
// Riverpod below hooks
|
||||
final results = ref.watch(searchResultsProvider(debounced));
|
||||
|
||||
return // ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## What goes in hooks vs providers
|
||||
| Concern | Tool |
|
||||
|---------|------|
|
||||
| Local UI state (text controller, animation, focus) | `useState`, `useAnimationController` |
|
||||
| Server/async state | Riverpod `AsyncNotifier` |
|
||||
| Cross-widget/feature state | Riverpod providers |
|
||||
| Lifecycle side effects | `useEffect` |
|
||||
|
||||
## Rules
|
||||
- **NEVER** call hooks inside `if`, `for`, or callbacks
|
||||
- `useEffect` cleanup MUST return a dispose function
|
||||
- Custom hooks: prefix with `use`, live in `lib/core/hooks/`
|
||||
- Do not use `flutter_riverpod` `ref.watch` inside `useEffect` — use `useRef` + `ref.listen`
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
description: "Riverpod conventions for {{PROJECT_NAME}}"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Riverpod Standards — {{PROJECT_NAME}}
|
||||
|
||||
## Provider types
|
||||
| Type | Use case |
|
||||
|------|----------|
|
||||
| `AsyncNotifier` | Async state from {{BACKEND}} |
|
||||
| `Notifier` | Synchronous derived/local UI state |
|
||||
| `StreamNotifier` | Real-time subscriptions |
|
||||
| `@riverpod` function | Simple computed/derived values |
|
||||
|
||||
## Code generation (mandatory)
|
||||
```dart
|
||||
// ✅ Always use @riverpod annotation
|
||||
@riverpod
|
||||
class AuthNotifier extends _$AuthNotifier {
|
||||
@override
|
||||
Future<User?> build() => ref.watch(authRepositoryProvider).currentUser();
|
||||
|
||||
Future<void> login(String email, String password) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(
|
||||
() => ref.read(authRepositoryProvider).login(email, password),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Never write manual provider declarations
|
||||
final authProvider = StateNotifierProvider<AuthNotifier, AsyncValue<User?>>(
|
||||
(ref) => AuthNotifier(),
|
||||
); // DON'T DO THIS
|
||||
```
|
||||
|
||||
## Rules
|
||||
- `ref.watch()` inside `build()` ONLY — **never** `ref.read()` inside `build()`
|
||||
- `ref.read(provider.notifier).method()` for mutations in gesture handlers
|
||||
- `ref.invalidate(provider)` to refresh — never manually reset state to `AsyncLoading()`
|
||||
- Family providers for parameterized data: `productDetailsProvider(productId)`
|
||||
- Providers scoped at feature level; core providers in `lib/core/di/`
|
||||
|
||||
## AsyncValue in widgets
|
||||
Every `AsyncValue` MUST handle all three states:
|
||||
```dart
|
||||
ref.watch(productsProvider).when(
|
||||
data: (products) => ProductList(products: products),
|
||||
loading: () => const ProductListShimmer(), // required
|
||||
error: (e, _) => ErrorWidget(error: e), // required
|
||||
)
|
||||
```
|
||||
|
||||
## File locations in {{PROJECT_NAME}}
|
||||
- `lib/features/[feature]/[feature]_provider.dart` (generated: `[feature]_provider.g.dart`)
|
||||
- `lib/features/[feature]/[feature]_repository.dart`
|
||||
- Run `dart run build_runner watch` during development
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
description: "BLoC testing conventions for {{PROJECT_NAME}}"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# BLoC Testing Standards — {{PROJECT_NAME}}
|
||||
|
||||
## Test pattern (bloc_test)
|
||||
```dart
|
||||
// {{TEST_PATTERN}}
|
||||
|
||||
void main() {
|
||||
late AuthBloc authBloc;
|
||||
late MockAuthRepository mockRepo;
|
||||
|
||||
setUp(() {
|
||||
mockRepo = MockAuthRepository();
|
||||
authBloc = AuthBloc(repository: mockRepo);
|
||||
});
|
||||
|
||||
tearDown(() => authBloc.close());
|
||||
|
||||
group('AuthBloc', () {
|
||||
blocTest<AuthBloc, AuthState>(
|
||||
'emits [Loading, Authenticated] when login succeeds',
|
||||
build: () {
|
||||
when(() => mockRepo.login(any(), any()))
|
||||
.thenAnswer((_) async => const Right(User(id: '1', email: 'test@test.com')));
|
||||
return authBloc;
|
||||
},
|
||||
act: (bloc) => bloc.add(const AuthLoginRequested(email: 'test@test.com', password: 'pass')),
|
||||
expect: () => [
|
||||
const AuthLoading(),
|
||||
isA<AuthAuthenticated>(),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<AuthBloc, AuthState>(
|
||||
'emits [Loading, Failure] when login fails',
|
||||
build: () {
|
||||
when(() => mockRepo.login(any(), any()))
|
||||
.thenAnswer((_) async => const Left(AuthError('Invalid credentials')));
|
||||
return authBloc;
|
||||
},
|
||||
act: (bloc) => bloc.add(const AuthLoginRequested(email: 'bad', password: 'bad')),
|
||||
expect: () => [
|
||||
const AuthLoading(),
|
||||
const AuthFailure('Invalid credentials'),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
- Use `mocktail` for mocking — never `mockito`
|
||||
- Every BLoC test file: `test/features/[feature]/[feature]_bloc_test.dart`
|
||||
- Coverage requirement: all state transitions must be tested
|
||||
- Use `Given/When/Then` naming in test descriptions
|
||||
- Test error paths as thoroughly as success paths
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
description: "Patrol E2E testing conventions for {{PROJECT_NAME}}"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Patrol E2E Testing — {{PROJECT_NAME}}
|
||||
|
||||
## Test structure
|
||||
```dart
|
||||
void main() {
|
||||
patrolTest('User can complete checkout flow', ($) async {
|
||||
await $.pumpWidgetAndSettle(const App());
|
||||
|
||||
// Login
|
||||
await $(LoginScreen).waitUntilVisible();
|
||||
await $(#emailField).enterText('test@example.com');
|
||||
await $(#passwordField).enterText('password123');
|
||||
await $('Sign In').tap();
|
||||
|
||||
// Add to cart
|
||||
await $(ProductCard).at(0).tap();
|
||||
await $('Add to Cart').tap();
|
||||
|
||||
// Checkout
|
||||
await $('Cart').tap();
|
||||
await $('Checkout').tap();
|
||||
await $(CheckoutSuccessScreen).waitUntilVisible();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
- E2E tests in `integration_test/` — separate from unit tests
|
||||
- Use `patrolTest` not `testWidgets` for E2E scenarios
|
||||
- Tag tests with `@Tags(['slow'])` so CI can skip on PRs
|
||||
- Run against real emulators/simulators, not mocked environments
|
||||
- Test on minimum supported OS version for each platform
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
description: "GetX testing conventions for {{PROJECT_NAME}}"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# GetX Testing Standards — {{PROJECT_NAME}}
|
||||
|
||||
## Test pattern
|
||||
```dart
|
||||
void main() {
|
||||
late ProductsController controller;
|
||||
late MockProductRepository mockRepo;
|
||||
|
||||
setUp(() {
|
||||
mockRepo = MockProductRepository();
|
||||
Get.testMode = true;
|
||||
controller = Get.put(ProductsController(mockRepo));
|
||||
});
|
||||
|
||||
tearDown(() => Get.deleteAll());
|
||||
|
||||
test('loads products on init', () async {
|
||||
when(() => mockRepo.getProducts()).thenAnswer((_) async => [fakeProduct]);
|
||||
await controller.fetchProducts();
|
||||
expect(controller.products, [fakeProduct]);
|
||||
expect(controller.isLoading.value, false);
|
||||
});
|
||||
|
||||
testWidgets('ProductsView shows shimmer while loading', (tester) async {
|
||||
controller.isLoading.value = true;
|
||||
await tester.pumpWidget(GetMaterialApp(home: ProductsView()));
|
||||
expect(find.byType(ProductShimmer), findsOneWidget);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
- Use `Get.testMode = true` in setUp
|
||||
- Always call `Get.deleteAll()` in tearDown
|
||||
- Wrap widget tests in `GetMaterialApp`, not `MaterialApp`
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
description: "Riverpod testing conventions for {{PROJECT_NAME}}"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Riverpod Testing Standards — {{PROJECT_NAME}}
|
||||
|
||||
## Test pattern
|
||||
```dart
|
||||
// {{TEST_PATTERN}}
|
||||
|
||||
void main() {
|
||||
late ProviderContainer container;
|
||||
late MockProductRepository mockRepo;
|
||||
|
||||
setUp(() {
|
||||
mockRepo = MockProductRepository();
|
||||
container = ProviderContainer(overrides: [
|
||||
productRepositoryProvider.overrideWithValue(mockRepo),
|
||||
]);
|
||||
});
|
||||
|
||||
tearDown(() => container.dispose()); // ALWAYS dispose
|
||||
|
||||
test('ProductsNotifier loads products successfully', () async {
|
||||
when(() => mockRepo.getProducts()).thenAnswer((_) async => [fakeProduct]);
|
||||
|
||||
final notifier = container.read(productsProvider.notifier);
|
||||
await notifier.loadProducts();
|
||||
|
||||
final state = container.read(productsProvider);
|
||||
expect(state, isA<AsyncData<List<Product>>>());
|
||||
expect(state.value, [fakeProduct]);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Widget tests with Riverpod
|
||||
```dart
|
||||
testWidgets('ProductScreen shows shimmer while loading', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
productsProvider.overrideWith((ref) => const AsyncLoading()),
|
||||
],
|
||||
child: const MaterialApp(home: ProductScreen()),
|
||||
),
|
||||
);
|
||||
expect(find.byType(ProductShimmer), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
## Rules
|
||||
- **Never** use a real `ProviderScope` in unit tests — always use `ProviderContainer` with overrides
|
||||
- `addTearDown(container.dispose)` in every test that creates a container
|
||||
- Test all three `AsyncValue` states: loading, data, error
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
description: "Core Flutter conventions for {{PROJECT_NAME}} — always applied"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Flutter Core Standards — {{PROJECT_NAME}}
|
||||
|
||||
## Const and performance
|
||||
- Use `const` constructors wherever possible — compile-time guarantee of no rebuild
|
||||
- Prefer `const` widgets at the leaf level: `const SizedBox.shrink()`, `const Padding(...)`
|
||||
- Never use `const` with mutable values; lint: `prefer_const_constructors` is enabled
|
||||
|
||||
## Null safety
|
||||
- Never use `!` (bang operator) unless you have a 100% safe runtime guarantee with a comment
|
||||
- Prefer `??`, `?.`, and `if (x != null)` guards
|
||||
- Use `required` for all non-nullable named parameters
|
||||
- Never use `late` without a guarantee of initialisation before first access
|
||||
|
||||
## Widget lifecycle
|
||||
- Override `dispose()` in every `StatefulWidget` that uses controllers, streams, or timers
|
||||
- Cancel `StreamSubscription` in `dispose()`, not in `didUpdateWidget`
|
||||
- Use `WidgetsBinding.instance.addPostFrameCallback` for post-build logic, not `Future.delayed(Duration.zero)`
|
||||
|
||||
## Naming conventions
|
||||
- Files: `snake_case.dart`
|
||||
- Classes: `PascalCase`
|
||||
- Variables/functions: `camelCase`
|
||||
- Constants: `kCamelCase` or `SCREAMING_SNAKE` for true compile-time constants
|
||||
- Private members: `_camelCase`
|
||||
|
||||
## Imports
|
||||
- Order: dart: → package: → relative
|
||||
- Use relative imports within a feature; absolute for cross-feature
|
||||
- Never import a feature's internal files from outside that feature
|
||||
|
||||
## Code quality
|
||||
- Max function length: 40 lines. Extract widgets and helpers aggressively
|
||||
- No `print()` in production code — use a logging package
|
||||
- All `TODO:` comments must include a ticket number: `// TODO: PROJ-123 — fix this`
|
||||
- Run `dart format` before every commit
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
description: "Project context for {{PROJECT_NAME}} — always applied"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Project Context — {{PROJECT_NAME}}
|
||||
|
||||
## Project identity
|
||||
- **Name:** {{PROJECT_NAME}}
|
||||
- **Package:** {{PACKAGE_ID}}
|
||||
- **Description:** {{DESCRIPTION}}
|
||||
- **Scale:** {{SCALE}}
|
||||
|
||||
## Technology stack
|
||||
- **State management:** {{STATE_MANAGEMENT}}
|
||||
- **Architecture:** {{ARCHITECTURE}}
|
||||
- **Routing:** {{ROUTING}}
|
||||
- **Backend:** {{BACKEND}}
|
||||
- **Auth:** {{AUTH}}
|
||||
- **Platforms:** {{PLATFORMS_LIST}}
|
||||
- **Code generation:** {{CODEGEN_LIST}}
|
||||
|
||||
## Feature modules
|
||||
{{FEATURES_LIST}}
|
||||
|
||||
## Special capabilities
|
||||
{{SPECIAL_FEATURES}}
|
||||
|
||||
## Environments / flavors
|
||||
- Flavors: {{FLAVORS_LIST}}
|
||||
- CI/CD: {{CICD_TOOL}}
|
||||
|
||||
## Design & API references
|
||||
- Design source: {{DESIGN_SOURCE}}
|
||||
- API docs: {{API_DOCS_FORMAT}} at `{{API_DOCS_PATH}}`
|
||||
|
||||
## Architecture boundaries
|
||||
{{ARCH_IMPORT_RULES}}
|
||||
|
||||
## When generating code for this project
|
||||
1. Always use {{STATE_MANAGEMENT}} patterns — never suggest alternatives
|
||||
2. Always follow {{ARCHITECTURE}} folder structure
|
||||
3. Always use {{ROUTING}} for navigation — never `Navigator.push` directly
|
||||
4. Always target platforms: {{PLATFORMS_LIST}}
|
||||
5. If code generation tools are used ({{CODEGEN_LIST}}), follow their conventions
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
description: "UI/UX standards for {{PROJECT_NAME}} — always applied"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# UI / UX Standards — {{PROJECT_NAME}}
|
||||
|
||||
## Loading states
|
||||
- Every async operation MUST show a loading skeleton (shimmer), NOT a spinner unless < 300ms
|
||||
- Use `shimmer` package with a shimmer that matches the final layout shape
|
||||
- Never show a blank screen during loading — skeleton must fill the same space as the content
|
||||
|
||||
## Empty states
|
||||
- Every list/grid MUST have a distinct empty state widget: illustration + headline + CTA
|
||||
- Empty state is different from error state — never reuse the same widget for both
|
||||
- Empty state copy: positive framing ("No items yet — add your first one")
|
||||
|
||||
## Error states
|
||||
- Every async failure MUST show: error message + retry button
|
||||
- Never swallow errors silently
|
||||
- Error text: user-friendly, never expose stack traces or raw API messages
|
||||
|
||||
## Navigation & transitions
|
||||
- Use `IndexedStack` for bottom nav tabs — preserves scroll position
|
||||
- Named routes only — never `Navigator.push(context, MaterialPageRoute(...))`
|
||||
- Page transitions: use `CustomTransitionPage` with `FadeTransition` for modal sheets
|
||||
|
||||
## Responsive layout
|
||||
- Use `LayoutBuilder` or `MediaQuery` for breakpoints, not hardcoded pixel values
|
||||
- Minimum touch target: 48×48 logical pixels (Material guideline)
|
||||
- Test on 375px (iPhone SE) and 414px (iPhone Pro Max) widths minimum
|
||||
|
||||
## Haptics
|
||||
- Use `HapticFeedback.lightImpact()` on primary CTAs
|
||||
- Use `HapticFeedback.selectionClick()` on toggle/checkbox interactions
|
||||
- Never add haptics to destructive actions without confirmation
|
||||
|
||||
## Accessibility
|
||||
- All interactive widgets must have a `Semantics` label or `tooltip`
|
||||
- Minimum contrast ratio: 4.5:1 (WCAG AA)
|
||||
- Test with TalkBack / VoiceOver before each release
|
||||
Reference in New Issue
Block a user