- Architecture & Flow
- State Management
- Code Quality
- Dependency Injection
- UI Patterns
- Data Layer
- Testing
- Error Handling
- Workflow & Commands
- Premium Features
- File Structure
Rule: Follow strict flow: UI → Cubit → UseCase → Repository → DataSource
Explanation: This unidirectional data flow ensures clear separation of concerns, making the codebase testable, maintainable, and scalable. Each layer has a specific responsibility and dependencies only point inward toward the domain layer.
Good Pattern:
// UI Layer - login_screen.dart
class LoginScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<LoginCubit, LoginState>(
builder: (context, state) {
return ElevatedButton(
onPressed: () => context.read<LoginCubit>().login(email, password),
child: const Text('Login'),
);
},
);
}
}
// Cubit Layer - login_cubit.dart
@injectable
class LoginCubit extends Cubit<LoginState> {
final LoginUseCase _loginUseCase;
LoginCubit(this._loginUseCase) : super(const LoginState.initial());
Future<void> login(String email, String password) async {
emit(const LoginState.loading());
final result = await _loginUseCase(email: email, password: password);
result.fold(
(failure) => emit(LoginState.error(failure)),
(user) => emit(LoginState.success(user)),
);
}
}
// UseCase Layer - login_use_case.dart
@injectable
class LoginUseCase {
final AuthRepository _authRepository;
LoginUseCase(this._authRepository);
Future<Either<Failure, User>> call({
required String email,
required String password,
}) async {
return await _authRepository.login(email, password);
}
}
// Repository Layer - auth_repository_impl.dart
@LazySingleton(as: AuthRepository)
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource _remoteDataSource;
AuthRepositoryImpl(this._remoteDataSource);
@override
Future<Either<Failure, User>> login(String email, String password) async {
try {
final userDto = await _remoteDataSource.login(email, password);
return Right(userDto.toEntity());
} catch (e) {
return Left(ServerFailure(e.toString()));
}
}
}
Anti-Pattern:
// ❌ BAD: UI directly calling repository
class LoginScreen extends StatelessWidget {
final AuthRepository authRepository; // Direct dependency
Future<void> _login() async {
// Business logic in UI layer
if (email.isEmpty || password.isEmpty) {
showError('Invalid input');
return;
}
// Direct repository call from UI
final result = await authRepository.login(email, password);
// ... handle result
}
}
// ❌ BAD: Cubit with business logic
class LoginCubit extends Cubit<LoginState> {
final AuthRemoteDataSource dataSource; // Skipping layers
Future<void> login(String email, String password) async {
// Business logic in Cubit
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(email)) {
emit(LoginState.error('Invalid email'));
return;
}
// Direct data source call
final user = await dataSource.login(email, password);
}
}
Key Considerations:
- Each layer should only know about the layer immediately below it
- Business logic belongs in UseCases, not in Cubits or UI
- Data transformation happens in repositories, not in Cubits
- UI should be as dumb as possible, only handling presentation logic
Rule: Repositories MUST NOT call other repositories, Cubits MUST NOT inject other Cubits
Explanation: This rule prevents circular dependencies and maintains clear architectural boundaries. When a repository needs data from another domain, it should be orchestrated at the UseCase level. Similarly, Cubits should communicate through events and listeners, not direct injection.
Good Pattern:
// ✅ GOOD: UseCase orchestrating multiple repositories
@injectable
class CreateTodoWithUserInfo {
final TodoRepository _todoRepository;
final UserRepository _userRepository;
CreateTodoWithUserInfo(this._todoRepository, this._userRepository);
Future<Either<Failure, Todo>> call(String title) async {
// Get user info first
final userResult = await _userRepository.getCurrentUser();
return userResult.fold(
(failure) => Left(failure),
(user) async {
// Then create todo with user info
return await _todoRepository.createTodo(
title: title,
userId: user.id,
userName: user.name,
);
},
);
}
}
// ✅ GOOD: Cubits communicating via BlocListener
class TodoListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiBlocListener(
listeners: [
BlocListener<TodoWriteCubit, TodoWriteState>(
listener: (context, state) {
if (state is TodoWriteSuccess) {
// Trigger refresh in read cubit
context.read<TodoReadCubit>().refresh();
}
},
),
],
child: // ... UI
);
}
}
Anti-Pattern:
// ❌ BAD: Repository calling another repository
@LazySingleton(as: TodoRepository)
class TodoRepositoryImpl implements TodoRepository {
final TodoDataSource _dataSource;
final UserRepository _userRepository; // Repository dependency!
@override
Future<Either<Failure, Todo>> createTodo(String title) async {
// Repository orchestrating business logic
final userResult = await _userRepository.getCurrentUser();
return userResult.fold(
(failure) => Left(failure),
(user) async {
final todo = await _dataSource.create(title, user.id);
return Right(todo.toEntity());
},
);
}
}
// ❌ BAD: Cubit injecting another Cubit
@injectable
class TodoWriteCubit extends Cubit<TodoWriteState> {
final CreateTodo _createTodo;
final TodoReadCubit _readCubit; // Cubit dependency!
Future<void> createTodo(String title) async {
emit(const TodoWriteState.loading());
final result = await _createTodo(title);
result.fold(
(failure) => emit(TodoWriteState.error(failure)),
(todo) {
emit(TodoWriteState.success(todo));
_readCubit.refresh(); // Direct cubit call
},
);
}
}
Key Considerations:
- If you need data from multiple domains, create a UseCase that orchestrates the repositories
- Use MultiBlocListener or BlocListener for Cubit communication
- Repository interfaces should be focused on a single domain
- Consider using event buses or streams for complex inter-Cubit communication
Rule: Use BlocListener for inter-Cubit communication to prevent circular dependencies
Explanation: BlocListener provides a reactive way for Cubits to respond to state changes in other Cubits without creating direct dependencies. This maintains loose coupling and prevents circular dependency issues that can make the codebase hard to maintain and test.
Good Pattern:
// ✅ GOOD: Using BlocListener for coordination
class TodoScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(create: (context) => getIt<TodoWriteCubit>()),
BlocProvider(create: (context) => getIt<TodoReadCubit>()..loadTodos()),
],
child: MultiBlocListener(
listeners: [
// Listen to write operations
BlocListener<TodoWriteCubit, TodoWriteState>(
listener: (context, state) {
switch (state) {
case TodoCreated():
context.read<TodoReadCubit>().refresh();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.todoCreated)),
);
case TodoDeleted():
context.read<TodoReadCubit>().refresh();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.todoDeleted)),
);
case TodoWriteError(failure: final failure):
// Error handled in dialog, no snackbar
break;
default:
break;
}
},
),
// Listen to user state changes
BlocListener<UserCubit, UserState>(
listener: (context, state) {
if (state is UserLoggedOut) {
// Clear todos when user logs out
context.read<TodoReadCubit>().clearTodos();
}
},
),
],
child: TodoScreenContent(),
),
);
}
}
// Alternative: Using stream subscription for complex scenarios
class AdvancedTodoReadCubit extends Cubit<TodoReadState> {
final GetTodoStream _getTodoStream;
StreamSubscription? _todoStreamSubscription;
AdvancedTodoReadCubit(this._getTodoStream) : super(const TodoReadState.initial());
void watchTodos() {
emit(const TodoReadState.loading());
_todoStreamSubscription?.cancel();
_todoStreamSubscription = _getTodoStream().listen(
(todos) => emit(TodoReadState.loaded(todos)),
onError: (error) => emit(TodoReadState.error(Failure(error.toString()))),
);
}
@override
Future<void> close() {
_todoStreamSubscription?.cancel();
return super.close();
}
}
Anti-Pattern:
// ❌ BAD: Direct Cubit-to-Cubit communication
@injectable
class TodoWriteCubit extends Cubit<TodoWriteState> {
final TodoReadCubit _readCubit; // Direct dependency
final UserCubit _userCubit; // Another direct dependency
TodoWriteCubit(this._readCubit, this._userCubit);
Future<void> createTodo(String title) async {
// Checking state of another cubit directly
if (_userCubit.state is! UserAuthenticated) {
emit(TodoWriteState.error(AuthFailure()));
return;
}
// ... create todo
// Directly calling another cubit's method
_readCubit.addTodoToList(newTodo);
}
}
// ❌ BAD: Circular dependency
class CubitA extends Cubit<StateA> {
final CubitB cubitB; // A depends on B
void doSomething() {
cubitB.triggerAction();
}
}
class CubitB extends Cubit<StateB> {
final CubitA cubitA; // B depends on A - CIRCULAR!
void triggerAction() {
cubitA.doSomething();
}
}
Key Considerations:
- BlocListener runs in the UI layer, maintaining proper separation
- Use listenWhen to filter state changes and prevent unnecessary rebuilds
- For complex scenarios, consider event buses or dedicated coordination services
- Always cancel stream subscriptions in the close() method
- MultiBlocListener is more efficient than nested BlocListeners
Rule: ReadCubits: global state in app.dart; WriteCubits: local per feature
Explanation: This CQRS (Command Query Responsibility Segregation) pattern separates read and write concerns. ReadCubits manage cached data and are available globally for any screen to access. WriteCubits handle create/update/delete operations and are scoped to specific features to prevent memory leaks.
Good Pattern:
// app.dart - Global ReadCubits registration
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
// Global ReadCubits - available throughout the app
BlocProvider(
create: (context) => getIt<UserReadCubit>()..loadCurrentUser(),
),
BlocProvider(
create: (context) => getIt<TodoReadCubit>()..watchTodos(),
),
BlocProvider(
create: (context) => getIt<SubscriptionCubit>()..checkSubscription(),
),
],
child: MaterialApp(
// ... app configuration
),
);
}
}
// Feature screen with local WriteCubit
class TodoDetailScreen extends StatelessWidget {
final String todoId;
const TodoDetailScreen({required this.todoId});
@override
Widget build(BuildContext context) {
return BlocProvider(
// Local WriteCubit - disposed when screen is popped
create: (context) => getIt<TodoWriteCubit>(),
child: BlocListener<TodoWriteCubit, TodoWriteState>(
listener: (context, state) {
if (state is TodoUpdateSuccess) {
// Trigger refresh in global ReadCubit
context.read<TodoReadCubit>().refreshTodo(todoId);
Navigator.of(context).pop();
}
},
child: TodoDetailContent(todoId: todoId),
),
);
}
}
// ReadCubit implementation
@injectable
class TodoReadCubit extends Cubit<TodoReadState> {
final WatchTodos _watchTodos;
StreamSubscription? _todosSubscription;
TodoReadCubit(this._watchTodos) : super(const TodoReadState.initial());
void watchTodos() {
emit(const TodoReadState.loading());
_todosSubscription?.cancel();
_todosSubscription = _watchTodos().listen(
(todos) => emit(TodoReadState.loaded(todos)),
onError: (error) => emit(TodoReadState.error(Failure(error.toString()))),
);
}
void refreshTodo(String todoId) {
if (state is TodoReadState.loaded) {
// Trigger refresh for specific todo
watchTodos();
}
}
@override
Future<void> close() {
_todosSubscription?.cancel();
return super.close();
}
}
// WriteCubit implementation
@injectable
class TodoWriteCubit extends Cubit<TodoWriteState> {
final UpdateTodo _updateTodo;
final DeleteTodo _deleteTodo;
TodoWriteCubit(this._updateTodo, this._deleteTodo)
: super(const TodoWriteState.initial());
Future<void> updateTodo(String id, String title) async {
emit(const TodoWriteState.updating());
final result = await _updateTodo(id: id, title: title);
result.fold(
(failure) => emit(TodoWriteState.error(failure)),
(_) => emit(const TodoWriteState.updateSuccess()),
);
}
}
Anti-Pattern:
// ❌ BAD: WriteCubit registered globally
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(create: (context) => getIt<TodoReadCubit>()),
// WriteCubit at app level - memory leak risk!
BlocProvider(create: (context) => getIt<TodoWriteCubit>()),
],
child: MaterialApp(),
);
}
}
// ❌ BAD: ReadCubit created locally
class TodoListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
// ReadCubit created per screen - loses cached data!
create: (context) => getIt<TodoReadCubit>()..loadTodos(),
child: TodoListContent(),
);
}
}
// ❌ BAD: Mixed responsibilities
@injectable
class TodoMixedCubit extends Cubit<TodoState> {
// Mixing read and write in one Cubit
final GetTodos _getTodos;
final CreateTodo _createTodo;
final UpdateTodo _updateTodo;
List<Todo> _cachedTodos = []; // Internal caching
Future<void> loadAndCreate() async {
// Mixed operations violate CQRS
final todos = await _getTodos();
await _createTodo('New Todo');
// ...
}
}
Key Considerations:
- ReadCubits should use streams for real-time updates
- WriteCubits should be stateless or have minimal state
- Global ReadCubits enable data sharing across features
- Local WriteCubits prevent memory leaks and state pollution
- Consider lazy loading for ReadCubits that aren't immediately needed
Rule: Use cases must be self-contained with internal dependencies
Explanation: Use cases encapsulate business logic and should contain all dependencies needed to complete their operation. This makes them reusable, testable, and ensures business rules are consistently applied regardless of where the use case is called from.
Good Pattern:
// ✅ GOOD: Self-contained use case with all needed dependencies
@injectable
class CreateTodoWithNotification {
final TodoRepository _todoRepository;
final UserRepository _userRepository;
final NotificationService _notificationService;
final DateTimeProvider _dateTimeProvider;
CreateTodoWithNotification(
this._todoRepository,
this._userRepository,
this._notificationService,
this._dateTimeProvider,
);
Future<Either<Failure, Todo>> call({
required String title,
required String description,
DateTime? dueDate,
bool sendNotification = true,
}) async {
// Get current user
final userResult = await _userRepository.getCurrentUser();
return userResult.fold(
(failure) => Left(failure),
(user) async {
// Validate business rules
if (title.length < 3) {
return Left(ValidationFailure('Title too short'));
}
// Calculate due date if not provided
final finalDueDate = dueDate ?? _dateTimeProvider.now().add(const Duration(days: 7));
// Create todo
final todoResult = await _todoRepository.createTodo(
title: title,
description: description,
userId: user.id,
dueDate: finalDueDate,
);
// Send notification if requested and todo created successfully
if (sendNotification) {
todoResult.fold(
(_) => null,
(todo) => _notificationService.scheduleReminder(
todoId: todo.id,
title: todo.title,
dateTime: todo.dueDate,
),
);
}
return todoResult;
},
);
}
}
// Use case with validation and business logic
@injectable
class TransferPremiumFeature {
final UserRepository _userRepository;
final SubscriptionRepository _subscriptionRepository;
final AuditLogRepository _auditLogRepository;
TransferPremiumFeature(
this._userRepository,
this._subscriptionRepository,
this._auditLogRepository,
);
Future<Either<Failure, void>> call({
required String fromUserId,
required String toUserId,
}) async {
// Validate users exist
final fromUserResult = await _userRepository.getUser(fromUserId);
if (fromUserResult.isLeft()) {
return Left(ValidationFailure('Source user not found'));
}
final toUserResult = await _userRepository.getUser(toUserId);
if (toUserResult.isLeft()) {
return Left(ValidationFailure('Target user not found'));
}
// Check subscription status
final subscriptionResult = await _subscriptionRepository.getSubscription(fromUserId);
return subscriptionResult.fold(
(failure) => Left(failure),
(subscription) async {
// Business rule: Only premium/vip can be transferred
if (subscription.tier == SubscriptionTier.free) {
return Left(BusinessRuleFailure('Cannot transfer free tier'));
}
// Perform transfer
final transferResult = await _subscriptionRepository.transferSubscription(
fromUserId: fromUserId,
toUserId: toUserId,
subscription: subscription,
);
// Log the action
await _auditLogRepository.logAction(
action: 'subscription_transfer',
fromUserId: fromUserId,
toUserId: toUserId,
metadata: {'tier': subscription.tier.name},
);
return transferResult;
},
);
}
}
Anti-Pattern:
// ❌ BAD: Use case depending on external state
@injectable
class CreateTodoIncomplete {
final TodoRepository _todoRepository;
CreateTodoIncomplete(this._todoRepository);
// Missing user context - expects caller to provide
Future<Either<Failure, Todo>> call({
required String title,
required String userId, // Expecting caller to know user ID
}) async {
// No validation, no business rules
return await _todoRepository.createTodo(
title: title,
userId: userId,
);
}
}
// ❌ BAD: Use case with external dependencies
@injectable
class CreateTodoWithExternalDeps {
final TodoRepository _todoRepository;
CreateTodoWithExternalDeps(this._todoRepository);
// Expecting services to be passed in
Future<Either<Failure, Todo>> call({
required String title,
required UserRepository userRepo, // External dependency!
required NotificationService notificationService, // External dependency!
}) async {
final user = await userRepo.getCurrentUser();
// ...
}
}
// ❌ BAD: Cubit handling business logic instead of use case
@injectable
class TodoCubitWithLogic extends Cubit<TodoState> {
final CreateTodoIncomplete _createTodo;
final UserRepository _userRepository;
final NotificationService _notificationService;
// Cubit orchestrating business logic that belongs in use case
Future<void> createTodo(String title) async {
// Business logic in Cubit
if (title.length < 3) {
emit(TodoState.error('Title too short'));
return;
}
final user = await _userRepository.getCurrentUser();
final result = await _createTodo(title: title, userId: user.id);
if (result.isRight()) {
await _notificationService.notify('Todo created');
}
}
}
Key Considerations:
- Use cases should encapsulate complete business operations
- All required services should be injected via constructor
- Business validation belongs in use cases, not in Cubits or UI
- Use cases can call other use cases for complex operations
- Consider using a Result/Either type for better error handling
Rule: Use switch
expressions with Freezed 3.0+, NEVER .when()
or .map()
Explanation: Dart 3's pattern matching with switch expressions provides exhaustive checking at compile time, better performance, and clearer syntax. The .when()
and .map()
methods are legacy approaches that lack compile-time exhaustiveness checking and can lead to runtime errors.
Good Pattern:
// ✅ GOOD: Using switch expressions with pattern matching
@freezed
sealed class TodoState with _$TodoState {
const factory TodoState.initial() = Initial;
const factory TodoState.loading() = Loading;
const factory TodoState.loaded(List<Todo> todos) = Loaded;
const factory TodoState.error(Failure failure) = Error;
}
// In UI
@override
Widget build(BuildContext context) {
return BlocBuilder<TodoCubit, TodoState>(
builder: (context, state) {
return switch (state) {
Initial() => const SizedBox.shrink(),
Loading() => const Center(child: CircularProgressIndicator()),
Loaded(:final todos) => TodoList(todos: todos),
Error(:final failure) => ErrorWidget(
message: failure.getUserFriendlyMessage(context),
onRetry: () => context.read<TodoCubit>().loadTodos(),
),
};
},
);
}
// With guard patterns for more complex logic
Widget buildTodoItem(Todo todo) {
return switch (todo) {
Todo(:final dueDate) when dueDate.isBefore(DateTime.now()) =>
OverdueTodoTile(todo: todo),
Todo(:final priority) when priority == Priority.high =>
HighPriorityTodoTile(todo: todo),
Todo(:final isCompleted) when isCompleted =>
CompletedTodoTile(todo: todo),
_ => RegularTodoTile(todo: todo),
};
}
// In Cubit with multiple state checks
void updateTodoStatus(String todoId, bool isCompleted) {
switch (state) {
case Loaded(todos: final todos):
final updatedTodos = todos.map((todo) {
if (todo.id == todoId) {
return todo.copyWith(isCompleted: isCompleted);
}
return todo;
}).toList();
emit(TodoState.loaded(updatedTodos));
case Initial() || Loading() || Error():
// Cannot update in these states
return;
}
}
Anti-Pattern:
// ❌ BAD: Using .when() - no compile-time exhaustiveness
@override
Widget build(BuildContext context) {
return BlocBuilder<TodoCubit, TodoState>(
builder: (context, state) {
return state.when(
initial: () => const SizedBox.shrink(),
loading: () => const CircularProgressIndicator(),
loaded: (todos) => TodoList(todos: todos),
error: (failure) => ErrorWidget(failure: failure),
// If new state added, this compiles but crashes at runtime!
);
},
);
}
// ❌ BAD: Using .map() - verbose and error-prone
@override
Widget build(BuildContext context) {
return state.map(
initial: (_) => const SizedBox.shrink(),
loading: (_) => const CircularProgressIndicator(),
loaded: (state) => TodoList(todos: state.todos),
error: (state) => ErrorWidget(failure: state.failure),
);
}
// ❌ BAD: Using if-else chains instead of switch
@override
Widget build(BuildContext context) {
if (state is Initial) {
return const SizedBox.shrink();
} else if (state is Loading) {
return const CircularProgressIndicator();
} else if (state is Loaded) {
return TodoList(todos: state.todos);
} else if (state is Error) {
return ErrorWidget(failure: state.failure);
}
// Missing return statement if new state added!
throw UnimplementedError();
}
Key Considerations:
- Switch expressions are checked at compile time for exhaustiveness
- Pattern destructuring (
:final field
) provides clean field access - Guard patterns (
when
clause) enable conditional matching - Default case (
_
) should be used sparingly - prefer exhaustive matching - Switch expressions can return values, making them perfect for UI building
Rule: State classes: sealed class
for unions, abstract class
for data classes
Explanation: Freezed 3.0+ requires explicit class modifiers. Use sealed class
for union types (states with multiple variants) and abstract class
for simple data classes. This ensures proper code generation and enables exhaustive pattern matching.
Good Pattern:
// ✅ GOOD: Sealed class for union types (multiple states)
@freezed
sealed class AuthState with _$AuthState {
const factory AuthState.initial() = Initial;
const factory AuthState.authenticating() = Authenticating;
const factory AuthState.authenticated(User user) = Authenticated;
const factory AuthState.unauthenticated() = Unauthenticated;
const factory AuthState.error(String message) = Error;
}
// ✅ GOOD: Abstract class for data classes (single type)
@freezed
abstract class User with _$User {
const factory User({
required String id,
required String email,
required String name,
DateTime? emailVerifiedAt,
@Default(false) bool isPremium,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
// ✅ GOOD: Complex state with nested unions
@freezed
sealed class TodoListState with _$TodoListState {
const factory TodoListState.initial() = _Initial;
const factory TodoListState.loading() = _Loading;
const factory TodoListState.loaded({
required List<Todo> todos,
required FilterState filter,
}) = _Loaded;
const factory TodoListState.error(Failure failure) = _Error;
}
@freezed
sealed class FilterState with _$FilterState {
const factory FilterState.all() = _All;
const factory FilterState.active() = _Active;
const factory FilterState.completed() = _Completed;
}
// ✅ GOOD: Entity with computed properties
@freezed
abstract class Todo with _$Todo {
const Todo._(); // Private constructor for custom methods
const factory Todo({
required String id,
required String title,
required String userId,
@Default('') String description,
DateTime? dueDate,
@Default(false) bool isCompleted,
required DateTime createdAt,
required DateTime updatedAt,
}) = _Todo;
// Custom getters
bool get isOverdue =>
dueDate != null && !isCompleted && dueDate!.isBefore(DateTime.now());
factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
}
Anti-Pattern:
// ❌ BAD: Using @freezed without class modifier
@freezed
class AuthState with _$AuthState { // Missing sealed/abstract!
const factory AuthState.initial() = Initial;
// ... won't compile with Freezed 3.0+
}
// ❌ BAD: Using sealed for data classes
@freezed
sealed class User with _$User { // Should be abstract!
const factory User({
required String id,
required String email,
}) = _User;
}
// ❌ BAD: Not using Freezed for state classes
class AuthState {
AuthState();
factory AuthState.authenticated(User user) = AuthenticatedState;
// No immutability, no copyWith, no equality!
}
// ❌ BAD: Mutable state classes
class TodoState {
List<Todo> todos; // Mutable field!
bool isLoading; // Mutable field!
TodoState({this.todos = const [], this.isLoading = false});
void addTodo(Todo todo) {
todos.add(todo); // Mutating state!
}
}
Key Considerations:
sealed class
enables exhaustive pattern matching in switch expressionsabstract class
is for simple data models without variants- Always use
const factory
constructors for immutability - Add private constructor
const ClassName._()
for custom methods - Freezed automatically generates copyWith, equality, toString, and JSON serialization
Rule: Cancel all streams/timers in Cubit's close()
method
Explanation: Failing to cancel streams and timers leads to memory leaks and potential crashes when disposed Cubits try to emit states. Always override the close()
method to clean up resources properly.
Good Pattern:
// ✅ GOOD: Proper stream cleanup
@injectable
class TodoStreamCubit extends Cubit<TodoState> {
final WatchTodos _watchTodos;
StreamSubscription<List<Todo>>? _todosSubscription;
Timer? _refreshTimer;
TodoStreamCubit(this._watchTodos) : super(const TodoState.initial());
void watchTodos() {
emit(const TodoState.loading());
// Cancel previous subscription
_todosSubscription?.cancel();
// Start new subscription
_todosSubscription = _watchTodos().listen(
(todos) => emit(TodoState.loaded(todos)),
onError: (error) => emit(TodoState.error(Failure(error.toString()))),
);
// Set up periodic refresh
_refreshTimer?.cancel();
_refreshTimer = Timer.periodic(
const Duration(minutes: 5),
(_) => _refreshTodos(),
);
}
void _refreshTodos() {
// Refresh logic
}
@override
Future<void> close() {
_todosSubscription?.cancel();
_refreshTimer?.cancel();
return super.close();
}
}
// ✅ GOOD: Multiple subscriptions management
@injectable
class DashboardCubit extends Cubit<DashboardState> {
final GetUserStream _getUserStream;
final GetNotificationsStream _getNotificationsStream;
final ConnectivityService _connectivityService;
StreamSubscription<User>? _userSubscription;
StreamSubscription<List<Notification>>? _notificationsSubscription;
StreamSubscription<ConnectivityStatus>? _connectivitySubscription;
Timer? _analyticsTimer;
DashboardCubit(
this._getUserStream,
this._getNotificationsStream,
this._connectivityService,
) : super(const DashboardState.initial());
void initialize() {
_userSubscription = _getUserStream().listen(
(user) => _updateUser(user),
onError: (e) => _handleError(e),
);
_notificationsSubscription = _getNotificationsStream().listen(
(notifications) => _updateNotifications(notifications),
);
_connectivitySubscription = _connectivityService.onStatusChange.listen(
(status) => _updateConnectivity(status),
);
_analyticsTimer = Timer.periodic(
const Duration(minutes: 1),
(_) => _sendAnalytics(),
);
}
@override
Future<void> close() {
// Cancel all subscriptions and timers
_userSubscription?.cancel();
_notificationsSubscription?.cancel();
_connectivitySubscription?.cancel();
_analyticsTimer?.cancel();
return super.close();
}
}
// ✅ GOOD: Debounce timer cleanup
@injectable
class SearchCubit extends Cubit<SearchState> {
final SearchRepository _repository;
Timer? _debounceTimer;
SearchCubit(this._repository) : super(const SearchState.initial());
void search(String query) {
// Cancel previous timer
_debounceTimer?.cancel();
if (query.isEmpty) {
emit(const SearchState.initial());
return;
}
emit(const SearchState.searching());
// Debounce search
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
_performSearch(query);
});
}
@override
Future<void> close() {
_debounceTimer?.cancel();
return super.close();
}
}
Anti-Pattern:
// ❌ BAD: No cleanup - memory leak!
@injectable
class LeakyTodoCubit extends Cubit<TodoState> {
final WatchTodos _watchTodos;
StreamSubscription<List<Todo>>? _todosSubscription;
void watchTodos() {
_todosSubscription = _watchTodos().listen(
(todos) => emit(TodoState.loaded(todos)),
);
}
// Missing close() method - subscription never cancelled!
}
// ❌ BAD: Forgetting to clean up timers
@injectable
class TimerLeakCubit extends Cubit<TimerState> {
Timer? _timer;
void startTimer() {
_timer = Timer.periodic(
const Duration(seconds: 1),
(_) => emit(TimerState.tick(DateTime.now())),
);
}
@override
Future<void> close() {
// Forgot to cancel timer!
return super.close();
}
}
// ❌ BAD: Not cancelling previous subscriptions
@injectable
class MultipleLeaksCubit extends Cubit<DataState> {
StreamSubscription? _subscription;
void watchData(String id) {
// Not cancelling previous subscription before creating new one!
_subscription = getDataStream(id).listen(
(data) => emit(DataState.loaded(data)),
);
}
@override
Future<void> close() {
_subscription?.cancel();
return super.close();
}
}
Key Considerations:
- Always cancel subscriptions before creating new ones
- Use
?.cancel()
to safely handle null subscriptions - Consider using
async*
streams for simpler cases - Document what resources each Cubit manages
- Test cleanup by checking for active subscriptions after disposal
Rule: Use BlocSelector for granular UI updates, const widgets everywhere
Explanation: BlocSelector prevents unnecessary widget rebuilds by only rebuilding when specific parts of the state change. Combined with const widgets, this significantly improves performance, especially in lists and complex UIs.
Good Pattern:
// ✅ GOOD: BlocSelector for specific state selection
class UserProfileHeader extends StatelessWidget {
const UserProfileHeader({super.key}); // const widget
@override
Widget build(BuildContext context) {
return Row(
children: [
// Only rebuilds when avatar URL changes
BlocSelector<UserCubit, UserState, String?>(
selector: (state) => switch (state) {
UserLoaded(:final user) => user.avatarUrl,
_ => null,
},
builder: (context, avatarUrl) {
return CircleAvatar(
backgroundImage: avatarUrl != null
? NetworkImage(avatarUrl)
: null,
child: avatarUrl == null
? const Icon(Icons.person) // const widget
: null,
);
},
),
const SizedBox(width: 16), // const widget
// Only rebuilds when name changes
BlocSelector<UserCubit, UserState, String>(
selector: (state) => switch (state) {
UserLoaded(:final user) => user.name,
_ => 'Loading...',
},
builder: (context, name) {
return Text(
name,
style: Theme.of(context).textTheme.headlineSmall,
);
},
),
],
);
}
}
// ✅ GOOD: Multiple selectors for complex state
class TodoListItem extends StatelessWidget {
final String todoId;
const TodoListItem({super.key, required this.todoId}); // const constructor
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
onTap: () => _navigateToDetail(context),
child: Padding(
padding: const EdgeInsets.all(16), // const EdgeInsets
child: Row(
children: [
// Checkbox only rebuilds when completion status changes
BlocSelector<TodosCubit, TodosState, bool>(
selector: (state) => switch (state) {
TodosLoaded(:final todos) =>
todos.firstWhere((t) => t.id == todoId).isCompleted,
_ => false,
},
builder: (context, isCompleted) {
return Checkbox(
value: isCompleted,
onChanged: (value) => context
.read<TodosCubit>()
.toggleTodo(todoId),
);
},
),
const SizedBox(width: 16),
// Title only rebuilds when title changes
Expanded(
child: BlocSelector<TodosCubit, TodosState, String>(
selector: (state) => switch (state) {
TodosLoaded(:final todos) =>
todos.firstWhere((t) => t.id == todoId).title,
_ => '',
},
builder: (context, title) {
return Text(
title,
style: const TextStyle(fontSize: 16), // const TextStyle
);
},
),
),
const _TodoMenuButton(), // const private widget
],
),
),
),
);
}
}
// ✅ GOOD: Extracted const widgets
class _TodoMenuButton extends StatelessWidget {
const _TodoMenuButton();
@override
Widget build(BuildContext context) {
return const IconButton(
icon: Icon(Icons.more_vert),
onPressed: null, // Will be overridden by parent
);
}
}
// ✅ GOOD: BlocSelector with complex computations
class TodoStatistics extends StatelessWidget {
const TodoStatistics({super.key});
@override
Widget build(BuildContext context) {
return BlocSelector<TodosCubit, TodosState, TodoStats?>(
selector: (state) => switch (state) {
TodosLoaded(:final todos) => TodoStats(
total: todos.length,
completed: todos.where((t) => t.isCompleted).length,
active: todos.where((t) => !t.isCompleted).length,
),
_ => null,
},
builder: (context, stats) {
if (stats == null) return const SizedBox.shrink();
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_StatItem(label: 'Total', value: stats.total),
_StatItem(label: 'Active', value: stats.active),
_StatItem(label: 'Completed', value: stats.completed),
],
);
},
);
}
}
Anti-Pattern:
// ❌ BAD: Using BlocBuilder when only part of state is needed
class UserProfileHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<UserCubit, UserState>(
builder: (context, state) {
// Entire widget rebuilds on any state change!
if (state is UserLoaded) {
return Row(
children: [
CircleAvatar(
backgroundImage: NetworkImage(state.user.avatarUrl),
),
SizedBox(width: 16), // Not const!
Text(state.user.name),
],
);
}
return CircularProgressIndicator(); // Not const!
},
);
}
}
// ❌ BAD: Not using const constructors
class TodoItem extends StatelessWidget {
final Todo todo;
TodoItem({required this.todo}); // Missing const!
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: EdgeInsets.all(16), // Not const!
child: Row(
children: [
Icon(Icons.check_circle), // Not const!
SizedBox(width: 8), // Not const!
Text(todo.title),
],
),
),
);
}
}
// ❌ BAD: Building complex widgets inline
class TodoList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<TodosCubit, TodosState>(
builder: (context, state) {
if (state is TodosLoaded) {
return ListView.builder(
itemCount: state.todos.length,
itemBuilder: (context, index) {
// Complex widget built inline - not reusable or const!
return Card(
child: ListTile(
leading: Checkbox(
value: state.todos[index].isCompleted,
onChanged: (_) {},
),
title: Text(state.todos[index].title),
subtitle: Text(state.todos[index].description),
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () {},
),
),
);
},
);
}
return CircularProgressIndicator();
},
);
}
}
Key Considerations:
- BlocSelector only rebuilds when selected value changes (using equality)
- Always use const constructors where possible
- Extract complex widgets into separate const classes
- Use const for EdgeInsets, TextStyle, and other immutable objects
- Consider performance implications in lists with many items
Rule: Emit loading states before async operations, handle all state transitions
Explanation: Always emit a loading state before starting async operations to provide immediate user feedback. Handle all possible state transitions including success, error, and edge cases to ensure the UI always reflects the current operation status.
Good Pattern:
// ✅ GOOD: Complete state transition handling
@injectable
class LoginCubit extends Cubit<LoginState> {
final LoginUseCase _loginUseCase;
final ValidateEmail _validateEmail;
LoginCubit(this._loginUseCase, this._validateEmail)
: super(const LoginState.initial());
Future<void> login({
required String email,
required String password,
}) async {
// Immediate loading feedback
emit(const LoginState.loading());
// Validate input first
final emailValidation = _validateEmail(email);
if (emailValidation.isLeft()) {
emit(LoginState.error(emailValidation.getLeft()));
return;
}
// Perform login
final result = await _loginUseCase(
email: email,
password: password,
);
// Handle all outcomes
result.fold(
(failure) => emit(LoginState.error(failure)),
(user) => emit(LoginState.success(user)),
);
}
void resetState() {
emit(const LoginState.initial());
}
}
// ✅ GOOD: Multi-step operation with progress states
@freezed
sealed class FileUploadState with _$FileUploadState {
const factory FileUploadState.initial() = _Initial;
const factory FileUploadState.selecting() = _Selecting;
const factory FileUploadState.validating(File file) = _Validating;
const factory FileUploadState.uploading({
required File file,
required double progress,
}) = _Uploading;
const factory FileUploadState.processing(String fileId) = _Processing;
const factory FileUploadState.completed(String fileUrl) = _Completed;
const factory FileUploadState.error(Failure failure) = _Error;
}
@injectable
class FileUploadCubit extends Cubit<FileUploadState> {
final SelectFile _selectFile;
final ValidateFile _validateFile;
final UploadFile _uploadFile;
FileUploadCubit(
this._selectFile,
this._validateFile,
this._uploadFile,
) : super(const FileUploadState.initial());
Future<void> uploadFile() async {
// Step 1: File selection
emit(const FileUploadState.selecting());
final fileResult = await _selectFile();
if (fileResult.isLeft()) {
emit(FileUploadState.error(fileResult.getLeft()));
return;
}
final file = fileResult.getRight();
// Step 2: Validation
emit(FileUploadState.validating(file));
final validationResult = await _validateFile(file);
if (validationResult.isLeft()) {
emit(FileUploadState.error(validationResult.getLeft()));
return;
}
// Step 3: Upload with progress
await _uploadFile(
file: file,
onProgress: (progress) {
emit(FileUploadState.uploading(file: file, progress: progress));
},
onComplete: (fileId) {
emit(FileUploadState.processing(fileId));
},
onProcessed: (fileUrl) {
emit(FileUploadState.completed(fileUrl));
},
onError: (failure) {
emit(FileUploadState.error(failure));
},
);
}
}
// ✅ GOOD: Handling concurrent operations
@injectable
class SearchCubit extends Cubit<SearchState> {
final SearchRepository _repository;
CancelableOperation<Either<Failure, List<SearchResult>>>? _searchOperation;
SearchCubit(this._repository) : super(const SearchState.initial());
Future<void> search(String query) async {
// Cancel previous search
_searchOperation?.cancel();
if (query.isEmpty) {
emit(const SearchState.initial());
return;
}
// Show loading immediately
emit(SearchState.searching(query));
// Perform cancellable search
_searchOperation = CancelableOperation.fromFuture(
_repository.search(query),
);
try {
final result = await _searchOperation!.value;
// Only emit if not cancelled
if (!_searchOperation!.isCanceled) {
result.fold(
(failure) => emit(SearchState.error(failure)),
(results) => emit(SearchState.results(results)),
);
}
} on CanceledException {
// Search was cancelled, don't emit
}
}
@override
Future<void> close() {
_searchOperation?.cancel();
return super.close();
}
}
Anti-Pattern:
// ❌ BAD: No loading state before async operation
@injectable
class BadLoginCubit extends Cubit<LoginState> {
final LoginUseCase _loginUseCase;
Future<void> login(String email, String password) async {
// No loading state - UI doesn't show progress!
final result = await _loginUseCase(email: email, password: password);
if (result.isRight()) {
emit(LoginState.success(result.getRight()));
} else {
emit(LoginState.error(result.getLeft()));
}
}
}
// ❌ BAD: Incomplete state handling
@injectable
class IncompleteTodoCubit extends Cubit<TodoState> {
final CreateTodo _createTodo;
Future<void> createTodo(String title) async {
emit(const TodoState.loading());
final result = await _createTodo(title);
// Only handling success case!
if (result.isRight()) {
emit(TodoState.success(result.getRight()));
}
// Error case ignored - UI stuck in loading!
}
}
// ❌ BAD: Not handling edge cases
@injectable
class BadSearchCubit extends Cubit<SearchState> {
final SearchRepository _repository;
Future<void> search(String query) async {
// Not handling empty query
// Not cancelling previous searches
// Not handling rapid successive calls
emit(const SearchState.loading());
final results = await _repository.search(query);
emit(SearchState.results(results));
// What if search fails?
// What if widget is disposed during search?
}
}
// ❌ BAD: Async operations without state updates
@injectable
class SilentOperationCubit extends Cubit<DataState> {
final DataRepository _repository;
Future<void> refreshData() async {
// No state change - user doesn't know refresh is happening!
final data = await _repository.getData();
// Only emitting at the end
emit(DataState.loaded(data));
}
}
Key Considerations:
- Always emit loading state before starting async work
- Handle both success and error cases from async operations
- Consider edge cases like empty input, cancellation, and concurrent operations
- Emit appropriate states for multi-step operations
- Use debouncing for operations triggered by user input
- Cancel ongoing operations when starting new ones
Rule: flutter analyze
must show ZERO issues before completion
Explanation: Flutter's analyzer enforces best practices, catches potential bugs, and ensures code consistency. Zero analyzer issues means the code meets Dart and Flutter standards, preventing technical debt and maintaining code quality across the team.
Good Pattern:
// ✅ GOOD: Code that passes flutter analyze
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/di/service_locator.dart';
import '../../domain/entities/todo_entity.dart';
import '../cubits/todo_cubit.dart';
import '../widgets/todo_item.dart';
class TodoListScreen extends StatelessWidget {
const TodoListScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.todos),
),
body: BlocProvider(
create: (context) => getIt<TodoCubit>()..loadTodos(),
child: const _TodoListView(),
),
);
}
}
class _TodoListView extends StatelessWidget {
const _TodoListView();
@override
Widget build(BuildContext context) {
return BlocBuilder<TodoCubit, TodoState>(
builder: (context, state) {
return switch (state) {
TodoInitial() => const SizedBox.shrink(),
TodoLoading() => const Center(
child: CircularProgressIndicator(),
),
TodoLoaded(:final todos) => _buildTodoList(todos),
TodoError(:final message) => Center(
child: Text(message),
),
};
},
);
}
Widget _buildTodoList(List<Todo> todos) {
if (todos.isEmpty) {
return const Center(
child: Text('No todos yet'),
);
}
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
return TodoItem(todo: todos[index]);
},
);
}
}
// analysis_options.yaml configuration
// include: package:flutter_lints/flutter.yaml
//
// linter:
// rules:
// - always_declare_return_types
// - avoid_dynamic_calls
// - avoid_empty_else
// - avoid_print
// - avoid_relative_lib_imports
// - avoid_types_as_parameter_names
// - await_only_futures
// - camel_case_types
// - cancel_subscriptions
// - close_sinks
// - constant_identifier_names
// - control_flow_in_finally
// - empty_statements
// - hash_and_equals
// - non_constant_identifier_names
// - prefer_const_constructors
// - prefer_const_declarations
// - prefer_final_fields
// - prefer_final_locals
// - prefer_is_empty
// - prefer_is_not_empty
// - prefer_single_quotes
// - require_trailing_commas
// - type_annotate_public_apis
// - unnecessary_const
// - unnecessary_new
// - unnecessary_null_aware_assignments
// - use_build_context_synchronously
// - use_key_in_widget_constructors
Anti-Pattern:
// ❌ BAD: Code with analyzer warnings
import 'package:flutter/material.dart';
// Unused import - analyzer warning
import 'dart:async';
// Missing type annotation - analyzer warning
var globalVariable = 'bad practice';
// Missing const constructor - analyzer warning
class TodoScreen extends StatelessWidget {
TodoScreen(); // Missing key parameter
@override
Widget build(BuildContext context) {
// Not using const for constant widgets
return Scaffold(
body: Center(
child: Column(
children: [
// Using print instead of proper logging
ElevatedButton(
onPressed: () {
print('Button pressed'); // Avoid print
},
child: Text('Press me'),
),
// Missing const
SizedBox(height: 20),
// Unnecessary new keyword
new Container(
child: new Text('Hello'),
),
],
),
),
);
}
}
// ❌ BAD: Async issues
class BadAsyncWidget extends StatefulWidget {
@override
_BadAsyncWidgetState createState() => _BadAsyncWidgetState();
}
class _BadAsyncWidgetState extends State<BadAsyncWidget> {
@override
void initState() {
super.initState();
// use_build_context_synchronously warning
Future.delayed(Duration(seconds: 1), () {
Navigator.of(context).pop(); // Context used after async
});
}
// Missing return type
_buildContent() { // Should be Widget _buildContent()
return Container();
}
@override
Widget build(BuildContext context) {
// Dynamic type usage
dynamic someData = {'key': 'value'};
return Container(
// Accessing dynamic - avoid_dynamic_calls
child: Text(someData['key']),
);
}
}
// ❌ BAD: Not handling analyzer suggestions
class MyWidget extends StatelessWidget {
final String? title;
MyWidget({this.title}); // Missing super.key
@override
Widget build(BuildContext context) {
// Unnecessary null aware assignment
String displayTitle = title ??= 'Default';
// Could be const
return Container(
padding: EdgeInsets.all(16), // Missing const
child: Text(displayTitle),
);
}
}
Key Considerations:
- Run
flutter analyze
frequently during development - Configure
analysis_options.yaml
with strict rules - Use IDE integration for real-time analyzer feedback
- Fix all errors, warnings, and info messages
- Consider using
very_good_analysis
or custom rule sets - Set up CI/CD to fail on analyzer issues
Rule: All user-facing text MUST use context.l10n.key
localization
Explanation: Hardcoded strings prevent internationalization and make the app difficult to translate. Using localization from the start ensures the app can support multiple languages and makes text changes centralized and manageable.
Good Pattern:
// ✅ GOOD: All text localized
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.loginTitle),
),
body: BlocListener<LoginCubit, LoginState>(
listener: (context, state) {
if (state is LoginError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
state.failure.getLocalizedMessage(context),
),
),
);
}
},
child: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextFormField(
controller: _emailController,
decoration: InputDecoration(
labelText: context.l10n.emailLabel,
hintText: context.l10n.emailHint,
),
validator: (value) {
if (value == null || value.isEmpty) {
return context.l10n.emailRequired;
}
if (!_isValidEmail(value)) {
return context.l10n.emailInvalid;
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
labelText: context.l10n.passwordLabel,
hintText: context.l10n.passwordHint,
),
validator: (value) {
if (value == null || value.isEmpty) {
return context.l10n.passwordRequired;
}
if (value.length < 8) {
return context.l10n.passwordTooShort;
}
return null;
},
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _onLoginPressed,
child: Text(context.l10n.loginButton),
),
TextButton(
onPressed: _onForgotPasswordPressed,
child: Text(context.l10n.forgotPassword),
),
],
),
),
),
),
);
}
}
// Localized error messages
extension FailureLocalization on Failure {
String getLocalizedMessage(BuildContext context) {
return switch (this) {
NetworkFailure() => context.l10n.errorNoInternet,
ServerFailure() => context.l10n.errorServer,
AuthFailure(:final code) => switch (code) {
'invalid-email' => context.l10n.errorInvalidEmail,
'user-not-found' => context.l10n.errorUserNotFound,
'wrong-password' => context.l10n.errorWrongPassword,
_ => context.l10n.errorGeneric,
},
ValidationFailure(:final field) => switch (field) {
'email' => context.l10n.errorEmailValidation,
'password' => context.l10n.errorPasswordValidation,
_ => context.l10n.errorValidation,
},
_ => context.l10n.errorUnknown,
};
}
}
// Localized formatters
class LocalizedFormatters {
static String formatCurrency(BuildContext context, double amount) {
final locale = Localizations.localeOf(context);
return NumberFormat.currency(
locale: locale.toString(),
symbol: context.l10n.currencySymbol,
).format(amount);
}
static String formatDate(BuildContext context, DateTime date) {
final locale = Localizations.localeOf(context);
return DateFormat.yMMMd(locale.toString()).format(date);
}
static String formatRelativeTime(BuildContext context, DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inDays > 7) {
return formatDate(context, dateTime);
} else if (difference.inDays > 0) {
return context.l10n.daysAgo(difference.inDays);
} else if (difference.inHours > 0) {
return context.l10n.hoursAgo(difference.inHours);
} else if (difference.inMinutes > 0) {
return context.l10n.minutesAgo(difference.inMinutes);
} else {
return context.l10n.justNow;
}
}
}
// ARB file example (lib/l10n/app_en.arb)
// {
// "loginTitle": "Login",
// "emailLabel": "Email",
// "emailHint": "Enter your email",
// "emailRequired": "Email is required",
// "emailInvalid": "Please enter a valid email",
// "passwordLabel": "Password",
// "passwordHint": "Enter your password",
// "passwordRequired": "Password is required",
// "passwordTooShort": "Password must be at least 8 characters",
// "loginButton": "Login",
// "forgotPassword": "Forgot password?",
// "errorNoInternet": "No internet connection",
// "errorServer": "Server error occurred",
// "errorInvalidEmail": "Invalid email address",
// "errorUserNotFound": "User not found",
// "errorWrongPassword": "Incorrect password",
// "errorGeneric": "An error occurred",
// "errorUnknown": "Unknown error",
// "currencySymbol": "$",
// "daysAgo": "{count, plural, =1{1 day ago} other{{count} days ago}}",
// "@daysAgo": {
// "placeholders": {
// "count": {"type": "int"}
// }
// },
// "hoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}",
// "@hoursAgo": {
// "placeholders": {
// "count": {"type": "int"}
// }
// }
// }
Anti-Pattern:
// ❌ BAD: Hardcoded strings everywhere
class BadLoginScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Login'), // Hardcoded!
),
body: Column(
children: [
TextField(
decoration: InputDecoration(
labelText: 'Email', // Hardcoded!
hintText: 'Enter your email address', // Hardcoded!
),
),
ElevatedButton(
onPressed: () {},
child: Text('Sign In'), // Hardcoded!
),
// Hardcoded error messages
Text('Invalid email or password'), // Hardcoded!
],
),
);
}
}
// ❌ BAD: Mixing localized and hardcoded strings
class MixedStringsWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(context.l10n.welcome), // Good
Text('But this is hardcoded'), // Bad!
Text('Error: ${error.message}'), // Bad - error messages should be localized
],
);
}
}
// ❌ BAD: Building strings programmatically
class BadStringBuilding extends StatelessWidget {
final int count;
const BadStringBuilding({required this.count});
@override
Widget build(BuildContext context) {
// Bad - doesn't handle pluralization properly
return Text('You have ' + count.toString() + ' items');
// Also bad - concatenating translations
return Text(context.l10n.you + ' ' + context.l10n.have + ' $count ' + context.l10n.items);
}
}
// ❌ BAD: Hardcoded formats
class BadFormatting extends StatelessWidget {
final double price;
final DateTime date;
@override
Widget build(BuildContext context) {
return Column(
children: [
// Hardcoded currency format
Text('\$$price'),
// Hardcoded date format
Text('${date.month}/${date.day}/${date.year}'),
// Hardcoded time format
Text('${date.hour}:${date.minute}'),
],
);
}
}
Key Considerations:
- Use ARB files for managing translations
- Include placeholders for dynamic content
- Use proper pluralization rules
- Localize date, time, and number formats
- Localize error messages from backend
- Test with different locales and text lengths
- Consider RTL language support
Rule: Check context.mounted
after EVERY async operation before using context
Explanation: After an async operation completes, the widget might have been disposed. Using context without checking mounted
can cause crashes. This is especially important for navigation, showing dialogs, or accessing inherited widgets.
Good Pattern:
// ✅ GOOD: Always checking context.mounted
class SaveButtonWidget extends StatefulWidget {
const SaveButtonWidget({super.key});
@override
State<SaveButtonWidget> createState() => _SaveButtonWidgetState();
}
class _SaveButtonWidgetState extends State<SaveButtonWidget> {
bool _isLoading = false;
Future<void> _saveData() async {
setState(() => _isLoading = true);
try {
// Async operation
final result = await context.read<DataCubit>().saveData();
// Check mounted before using context
if (!context.mounted) return;
if (result.isSuccess) {
// Safe to use context
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.saveSuccess)),
);
// Check again before navigation
if (!context.mounted) return;
Navigator.of(context).pop();
} else {
// Check before showing error
if (!context.mounted) return;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.error),
content: Text(result.error.getMessage(context)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.l10n.ok),
),
],
),
);
}
} finally {
// Check before setState
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: _isLoading ? null : _saveData,
child: _isLoading
? const CircularProgressIndicator()
: Text(context.l10n.save),
);
}
}
// ✅ GOOD: Multiple async operations with checks
class DataFetcherWidget extends StatefulWidget {
const DataFetcherWidget({super.key});
@override
State<DataFetcherWidget> createState() => _DataFetcherWidgetState();
}
class _DataFetcherWidgetState extends State<DataFetcherWidget> {
@override
void initState() {
super.initState();
_fetchData();
}
Future<void> _fetchData() async {
// First async call
final user = await _fetchUser();
if (!mounted) return;
// Update UI
setState(() {
_user = user;
});
// Second async call
final permissions = await _fetchPermissions(user.id);
if (!mounted) return;
// Update UI again
setState(() {
_permissions = permissions;
});
// Third async call with context usage
final theme = await _fetchUserTheme(user.id);
if (!context.mounted) return;
// Safe to use context
context.read<ThemeCubit>().updateTheme(theme);
}
Future<void> _refreshData() async {
final scaffoldMessenger = ScaffoldMessenger.of(context);
final l10n = context.l10n;
try {
await _fetchData();
// Check before using scaffoldMessenger
if (!mounted) return;
scaffoldMessenger.showSnackBar(
SnackBar(content: Text(l10n.refreshSuccess)),
);
} catch (e) {
// Check before showing error
if (!mounted) return;
scaffoldMessenger.showSnackBar(
SnackBar(content: Text(l10n.refreshError)),
);
}
}
@override
Widget build(BuildContext context) {
// Widget build implementation
}
}
// ✅ GOOD: Extension method for safer async operations
extension BuildContextAsyncExtensions on BuildContext {
Future<T?> safeAsync<T>(Future<T> Function() operation) async {
try {
final result = await operation();
if (!mounted) return null;
return result;
} catch (e) {
if (!mounted) return null;
rethrow;
}
}
}
// Usage
Future<void> _handleSubmit() async {
final result = await context.safeAsync(() => submitForm());
if (result == null) return; // Widget was disposed
// Safe to use context
Navigator.of(context).pushNamed('/success');
}
Anti-Pattern:
// ❌ BAD: Not checking context.mounted
class BadAsyncWidget extends StatefulWidget {
@override
State<BadAsyncWidget> createState() => _BadAsyncWidgetState();
}
class _BadAsyncWidgetState extends State<BadAsyncWidget> {
Future<void> _badAsync() async {
// Async operation
final data = await fetchData();
// BAD: No mounted check - might crash!
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => DetailScreen(data: data)),
);
}
Future<void> _badMultipleAsync() async {
final result1 = await operation1();
// BAD: No check between operations
setState(() {
_data1 = result1;
});
final result2 = await operation2();
// BAD: Still no check
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Done')),
);
}
@override
void initState() {
super.initState();
// BAD: Async in initState without checks
Future.delayed(Duration(seconds: 2), () {
// Widget might be disposed!
Navigator.of(context).pushNamed('/home');
});
}
Future<void> _badErrorHandling() async {
try {
await riskyOperation();
} catch (e) {
// BAD: No mounted check in catch block
showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text('Error'),
content: Text(e.toString()),
),
);
}
}
Future<void> _badChainedOperations() async {
await fetchUser()
.then((user) => fetchProfile(user.id)) // BAD: chaining without checks
.then((profile) {
// No mounted check!
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ProfileScreen(profile: profile)),
);
});
}
}
// ❌ BAD: Using BuildContext in static/global functions
Future<void> badGlobalFunction(BuildContext context) async {
await someAsyncOperation();
// BAD: Can't check mounted on a passed context
Navigator.of(context).pop();
}
Key Considerations:
- Check
mounted
for StatefulWidget,context.mounted
for StatelessWidget - Check after EVERY await, not just the last one
- Store context-dependent values before async operations if needed
- Be especially careful with navigation and dialogs
- Consider using a state management solution to avoid context usage
- Test with widget disposal scenarios
Rule: Use Theme colors only: Theme.of(context).colorScheme.xxx
Explanation: Hardcoded colors break theming, prevent dark mode support, and create inconsistent UI. Using theme colors ensures the app respects the user's theme preferences and maintains visual consistency across all screens.
Good Pattern:
// ✅ GOOD: Using theme colors exclusively
class ThemedCard extends StatelessWidget {
final String title;
final String subtitle;
final VoidCallback? onTap;
final bool isSelected;
const ThemedCard({
super.key,
required this.title,
required this.subtitle,
this.onTap,
this.isSelected = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card(
elevation: isSelected ? 8 : 2,
color: isSelected
? colorScheme.primaryContainer
: colorScheme.surface,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Text(
subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: isSelected
? colorScheme.onPrimaryContainer.withOpacity(0.8)
: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
}
// ✅ GOOD: Status indicators with semantic colors
class StatusIndicator extends StatelessWidget {
final StatusType status;
const StatusIndicator({super.key, required this.status});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final (color, icon) = switch (status) {
StatusType.success => (colorScheme.tertiary, Icons.check_circle),
StatusType.warning => (colorScheme.error, Icons.warning),
StatusType.error => (colorScheme.error, Icons.error),
StatusType.info => (colorScheme.primary, Icons.info),
};
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: color, size: 20),
const SizedBox(width: 8),
Text(
status.label,
style: TextStyle(color: color),
),
],
),
);
}
}
// ✅ GOOD: Custom painter using theme colors
class ThemedProgressPainter extends CustomPainter {
final double progress;
final ColorScheme colorScheme;
ThemedProgressPainter({
required this.progress,
required this.colorScheme,
});
@override
void paint(Canvas canvas, Size size) {
final backgroundPaint = Paint()
..color = colorScheme.surfaceVariant
..style = PaintingStyle.stroke
..strokeWidth = 4;
final progressPaint = Paint()
..color = colorScheme.primary
..style = PaintingStyle.stroke
..strokeWidth = 4
..strokeCap = StrokeCap.round;
// Draw background
canvas.drawArc(
Rect.fromLTWH(0, 0, size.width, size.height),
-pi / 2,
2 * pi,
false,
backgroundPaint,
);
// Draw progress
canvas.drawArc(
Rect.fromLTWH(0, 0, size.width, size.height),
-pi / 2,
2 * pi * progress,
false,
progressPaint,
);
}
@override
bool shouldRepaint(ThemedProgressPainter oldDelegate) {
return oldDelegate.progress != progress;
}
}
// ✅ GOOD: Theme extensions for custom colors
@immutable
class CustomColors extends ThemeExtension<CustomColors> {
final Color? brandColor;
final Color? successColor;
final Color? warningColor;
const CustomColors({
this.brandColor,
this.successColor,
this.warningColor,
});
@override
CustomColors copyWith({
Color? brandColor,
Color? successColor,
Color? warningColor,
}) {
return CustomColors(
brandColor: brandColor ?? this.brandColor,
successColor: successColor ?? this.successColor,
warningColor: warningColor ?? this.warningColor,
);
}
@override
CustomColors lerp(ThemeExtension<CustomColors>? other, double t) {
if (other is! CustomColors) return this;
return CustomColors(
brandColor: Color.lerp(brandColor, other.brandColor, t),
successColor: Color.lerp(successColor, other.successColor, t),
warningColor: Color.lerp(warningColor, other.warningColor, t),
);
}
// Light theme colors
static const light = CustomColors(
brandColor: Color(0xFF1976D2),
successColor: Color(0xFF4CAF50),
warningColor: Color(0xFFFF9800),
);
// Dark theme colors
static const dark = CustomColors(
brandColor: Color(0xFF90CAF9),
successColor: Color(0xFF81C784),
warningColor: Color(0xFFFFB74D),
);
}
// Usage
Widget build(BuildContext context) {
final customColors = Theme.of(context).extension<CustomColors>()!;
return Container(
color: customColors.brandColor,
// ...
);
}
Anti-Pattern:
// ❌ BAD: Hardcoded colors everywhere
class BadColorWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
// Hardcoded color!
color: Colors.blue,
child: Column(
children: [
Text(
'Title',
style: TextStyle(
color: Colors.white, // Hardcoded!
),
),
Container(
decoration: BoxDecoration(
// Hardcoded hex color!
color: Color(0xFF2196F3),
border: Border.all(
color: Colors.blueAccent, // Hardcoded!
),
),
),
Icon(
Icons.star,
color: Colors.yellow, // Hardcoded!
),
],
),
);
}
}
// ❌ BAD: Using specific material colors
class BadMaterialColors extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
color: Colors.red[500], // Bad!
),
Container(
color: Colors.grey.shade200, // Bad!
),
Container(
color: Colors.blueGrey[700], // Bad!
),
],
);
}
}
// ❌ BAD: Conditional colors without theme
class BadConditionalColors extends StatelessWidget {
final bool isActive;
final bool hasError;
@override
Widget build(BuildContext context) {
return Container(
// Hardcoded conditional colors
color: isActive ? Colors.green : Colors.grey,
child: Text(
'Status',
style: TextStyle(
// More hardcoded colors
color: hasError ? Colors.red : Colors.black,
),
),
);
}
}
// ❌ BAD: Opacity on hardcoded colors
class BadOpacityColors extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
color: Colors.blue.withOpacity(0.5), // Bad!
),
Container(
color: Color(0xFF2196F3).withAlpha(128), // Bad!
),
Container(
color: Colors.black54, // Bad!
),
],
);
}
}
Key Considerations:
- Use
colorScheme
for all colors - Support both light and dark themes
- Test UI with different theme configurations
- Use theme extensions for custom colors
- Consider color accessibility (contrast ratios)
- Use semantic color names (primary, error, surface)
- Avoid Colors.xxx constants except in theme definition
Rule: Private widget classes _MyWidget
, NOT _buildMethod()
functions
Explanation: Widget classes are more performant, testable, and maintainable than builder methods. They have their own context, can be const, properly handle keys, and show up correctly in the widget inspector. Builder methods can cause unnecessary rebuilds and make debugging harder.
Good Pattern:
// ✅ GOOD: Private widget classes
class TodoListScreen extends StatelessWidget {
const TodoListScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.todos),
),
body: const _TodoListBody(),
);
}
}
// Separate widget class - can be const
class _TodoListBody extends StatelessWidget {
const _TodoListBody();
@override
Widget build(BuildContext context) {
return BlocBuilder<TodosCubit, TodosState>(
builder: (context, state) {
return switch (state) {
TodosInitial() => const _EmptyState(),
TodosLoading() => const _LoadingState(),
TodosLoaded(:final todos) => _TodosList(todos: todos),
TodosError(:final failure) => _ErrorState(failure: failure),
};
},
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inbox,
size: 64,
color: Theme.of(context).colorScheme.surfaceVariant,
),
const SizedBox(height: 16),
Text(
context.l10n.noTodosYet,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
context.l10n.tapPlusToAdd,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
class _LoadingState extends StatelessWidget {
const _LoadingState();
@override
Widget build(BuildContext context) {
return const Center(
child: CircularProgressIndicator(),
);
}
}
class _TodosList extends StatelessWidget {
final List<Todo> todos;
const _TodosList({required this.todos});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return _TodoListItem(
key: ValueKey(todo.id),
todo: todo,
onTap: () => _onTodoTapped(context, todo),
onToggle: () => _onTodoToggled(context, todo),
);
},
);
}
void _onTodoTapped(BuildContext context, Todo todo) {
Navigator.of(context).pushNamed(
'/todo-detail',
arguments: todo.id,
);
}
void _onTodoToggled(BuildContext context, Todo todo) {
context.read<TodosCubit>().toggleTodo(todo.id);
}
}
// Reusable, testable widget with clear interface
class _TodoListItem extends StatelessWidget {
final Todo todo;
final VoidCallback onTap;
final VoidCallback onToggle;
const _TodoListItem({
super.key,
required this.todo,
required this.onTap,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
_TodoCheckbox(
isCompleted: todo.isCompleted,
onChanged: (_) => onToggle(),
),
const SizedBox(width: 16),
Expanded(
child: _TodoContent(todo: todo),
),
if (todo.isPriority) const _PriorityBadge(),
],
),
),
),
);
}
}
// Even small components get their own widget
class _TodoCheckbox extends StatelessWidget {
final bool isCompleted;
final ValueChanged<bool?> onChanged;
const _TodoCheckbox({
required this.isCompleted,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Checkbox(
value: isCompleted,
onChanged: onChanged,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
);
}
}
// Complex widget with internal state
class _ErrorState extends StatefulWidget {
final Failure failure;
const _ErrorState({required this.failure});
@override
State<_ErrorState> createState() => _ErrorStateState();
}
class _ErrorStateState extends State<_ErrorState> {
bool _showDetails = false;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
widget.failure.getUserFriendlyMessage(context),
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
if (_showDetails) ...[
const SizedBox(height: 8),
Text(
widget.failure.technicalMessage,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton(
onPressed: () => setState(() => _showDetails = !_showDetails),
child: Text(_showDetails ? context.l10n.hideDetails : context.l10n.showDetails),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: () => context.read<TodosCubit>().retry(),
child: Text(context.l10n.retry),
),
],
),
],
),
),
);
}
}
Anti-Pattern:
// ❌ BAD: Using builder methods instead of widgets
class BadTodoScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Todos'),
),
body: _buildBody(context), // Bad pattern!
);
}
// Builder method - can't be const, poor performance
Widget _buildBody(BuildContext context) {
return BlocBuilder<TodosCubit, TodosState>(
builder: (context, state) {
if (state is TodosLoading) {
return _buildLoadingState(); // Another builder method!
} else if (state is TodosLoaded) {
return _buildLoadedState(state.todos); // And another!
} else if (state is TodosError) {
return _buildErrorState(state.failure); // More builders!
}
return _buildEmptyState();
},
);
}
Widget _buildLoadingState() {
return Center(
child: CircularProgressIndicator(), // Can't be const
);
}
Widget _buildLoadedState(List<Todo> todos) {
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
// Complex widget built inline
return Card(
child: ListTile(
leading: Checkbox(
value: todos[index].isCompleted,
onChanged: (value) {
// Logic mixed with UI
context.read<TodosCubit>().toggleTodo(todos[index].id);
},
),
title: Text(todos[index].title),
subtitle: Text(todos[index].description),
trailing: _buildTrailingWidget(todos[index]), // More builders!
),
);
},
);
}
Widget _buildTrailingWidget(Todo todo) {
// Can't optimize this separately
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (todo.isPriority) Icon(Icons.star, color: Colors.amber),
IconButton(
icon: Icon(Icons.delete),
onPressed: () {
// More logic in UI
},
),
],
);
}
Widget _buildErrorState(Failure failure) {
// Not reusable, not testable
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, size: 64, color: Colors.red),
SizedBox(height: 16),
Text(failure.message),
ElevatedButton(
onPressed: () {
// Retry logic
},
child: Text('Retry'),
),
],
),
);
}
Widget _buildEmptyState() {
// Can't be independently tested
return Center(
child: Text('No todos yet'),
);
}
}
// ❌ BAD: Mixing builder methods and widget classes
class BadMixedApproach extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
_HeaderWidget(), // Good - widget class
_buildDivider(), // Bad - builder method
Expanded(
child: _buildContent(context), // Bad - builder method
),
_FooterWidget(), // Good - widget class
],
);
}
Widget _buildDivider() => Divider(height: 1);
Widget _buildContent(BuildContext context) {
// Complex logic in builder method
final user = context.read<UserCubit>().state.user;
if (user == null) {
return _buildLoginPrompt();
}
return _buildUserContent(user);
}
}
Key Considerations:
- Widget classes can be const for better performance
- Each widget has its own BuildContext
- Widgets are testable in isolation
- Better widget tree inspection in DevTools
- Clear separation of concerns
- Reusable across different screens
- Type-safe parameters instead of passing data around
Rule: Use @injectable annotations ONLY, NO manual get_it registration
Explanation: The project uses injectable code generation to automatically register dependencies. Manual registration leads to maintenance overhead, potential registration errors, and makes it harder to track dependencies. The annotation-based approach provides compile-time safety and automatic dependency graph resolution.
Good Pattern:
// ✅ GOOD: Using annotations for automatic registration
// Use case with @injectable
@injectable
class SaveTodoUseCase {
final TodoRepository _repository;
final AuthRepository _authRepository;
SaveTodoUseCase(this._repository, this._authRepository);
Future<Either<Failure, TodoEntity>> call(TodoParams params) async {
// Implementation
}
}
// Cubit with @injectable
@injectable
class TodoWriteCubit extends Cubit<TodoWriteState> {
final SaveTodoUseCase _saveTodoUseCase;
final DeleteTodoUseCase _deleteTodoUseCase;
TodoWriteCubit(
this._saveTodoUseCase,
this._deleteTodoUseCase,
) : super(const TodoWriteState.initial());
}
// Repository implementation with @LazySingleton
@LazySingleton(as: TodoRepository)
class TodoRepositoryImpl implements TodoRepository {
final TodoFirestoreDataSource _remoteDataSource;
final TodoLocalDataSource _localDataSource;
TodoRepositoryImpl(
this._remoteDataSource,
this._localDataSource,
);
}
// Data source with @lazySingleton
@lazySingleton
class TodoFirestoreDataSource {
final FirebaseFirestore _firestore;
TodoFirestoreDataSource(this._firestore);
}
Anti-Pattern:
// ❌ BAD: Manual registration in dependency_injection.dart
void configureDependencies() {
// Don't do this!
getIt.registerLazySingleton<TodoRepository>(
() => TodoRepositoryImpl(
getIt<TodoFirestoreDataSource>(),
getIt<TodoLocalDataSource>(),
),
);
getIt.registerFactory<SaveTodoUseCase>(
() => SaveTodoUseCase(
getIt<TodoRepository>(),
getIt<AuthRepository>(),
),
);
getIt.registerFactory<TodoWriteCubit>(
() => TodoWriteCubit(
getIt<SaveTodoUseCase>(),
getIt<DeleteTodoUseCase>(),
),
);
}
// ❌ BAD: Mixing manual and automatic registration
@injectable
class SomeService {
// ...
}
// Then manually registering elsewhere
getIt.registerSingleton(SomeService());
Key Considerations:
- Always use appropriate annotations: @injectable, @lazySingleton, @singleton
- Let code generation handle the dependency graph
- Run build_runner after adding new injectable classes
- Use @module for external dependencies only
- Avoid any manual getIt.register* calls
Rule: Repositories: @LazySingleton(as: Interface), Use Cases: @injectable
Explanation: Repositories should be registered as lazy singletons because they maintain connections to data sources and may have expensive initialization. They must be registered against their interface to enable dependency inversion. Use cases are stateless operations that should be created fresh for each injection, hence @injectable (factory pattern).
Good Pattern:
// ✅ GOOD: Repository interface definition
abstract interface class TodoRepository {
Future<Either<Failure, List<TodoEntity>>> getTodos();
Future<Either<Failure, TodoEntity>> saveTodo(TodoEntity todo);
Future<Either<Failure, void>> deleteTodo(String id);
Stream<List<TodoEntity>> watchTodos();
}
// ✅ GOOD: Repository implementation with correct annotation
@LazySingleton(as: TodoRepository)
class TodoRepositoryImpl implements TodoRepository {
final TodoFirestoreDataSource _remoteDataSource;
final TodoLocalDataSource _localDataSource;
final NetworkInfo _networkInfo;
TodoRepositoryImpl(
this._remoteDataSource,
this._localDataSource,
this._networkInfo,
);
@override
Future<Either<Failure, List<TodoEntity>>> getTodos() async {
if (await _networkInfo.isConnected) {
try {
final remoteTodos = await _remoteDataSource.getTodos();
await _localDataSource.cacheTodos(remoteTodos);
return Right(remoteTodos.map((dto) => dto.toEntity()).toList());
} catch (e) {
return Left(ServerFailure(e.toString()));
}
} else {
try {
final localTodos = await _localDataSource.getCachedTodos();
return Right(localTodos.map((dto) => dto.toEntity()).toList());
} catch (e) {
return Left(CacheFailure('No cached data available'));
}
}
}
}
// ✅ GOOD: Use case with @injectable
@injectable
class GetTodosUseCase {
final TodoRepository _repository;
GetTodosUseCase(this._repository);
Future<Either<Failure, List<TodoEntity>>> call() {
return _repository.getTodos();
}
}
// ✅ GOOD: Use case with parameters
@injectable
class SaveTodoUseCase {
final TodoRepository _repository;
final AuthRepository _authRepository;
SaveTodoUseCase(this._repository, this._authRepository);
Future<Either<Failure, TodoEntity>> call(TodoParams params) async {
final userResult = await _authRepository.getCurrentUser();
return userResult.fold(
(failure) => Left(failure),
(user) => _repository.saveTodo(
params.todo.copyWith(userId: user.id),
),
);
}
}
Anti-Pattern:
// ❌ BAD: Repository without interface registration
@lazySingleton // Missing 'as: TodoRepository'
class TodoRepositoryImpl implements TodoRepository {
// Implementation
}
// ❌ BAD: Repository registered as factory instead of singleton
@Injectable(as: TodoRepository) // Should be @LazySingleton
class TodoRepositoryImpl implements TodoRepository {
// Implementation
}
// ❌ BAD: Use case registered as singleton
@singleton // Use cases should be @injectable (factory)
class GetTodosUseCase {
// Implementation
}
// ❌ BAD: Direct implementation registration
@LazySingleton(as: TodoRepositoryImpl) // Should register against interface
class TodoRepositoryImpl implements TodoRepository {
// Implementation
}
// ❌ BAD: Repository accessing another repository directly
@LazySingleton(as: TodoRepository)
class TodoRepositoryImpl implements TodoRepository {
final UserRepository _userRepository; // BAD - repositories shouldn't depend on each other
TodoRepositoryImpl(this._userRepository);
}
Key Considerations:
- Repositories are expensive to create (database connections, caches)
- Use cases are lightweight and stateless
- Always register implementations against interfaces
- Repositories should never depend on other repositories
- Use cases can depend on multiple repositories
- This pattern enables easy testing with mocks
Rule: Repository interfaces: abstract interface class (Dart 3+)
Explanation: Dart 3 introduced the interface
class modifier to explicitly declare that a class is intended to be an interface. Combined with abstract
, it creates a clear contract that cannot be instantiated and should only be implemented, not extended. This prevents accidental inheritance and makes the architectural intent explicit.
Good Pattern:
// ✅ GOOD: Using abstract interface class for repository contracts
abstract interface class AuthRepository {
Future<Either<Failure, User>> login(String email, String password);
Future<Either<Failure, User>> register(String email, String password);
Future<Either<Failure, void>> logout();
Future<Either<Failure, User>> getCurrentUser();
Stream<User?> watchAuthState();
}
abstract interface class TodoRepository {
Future<Either<Failure, List<TodoEntity>>> getTodos();
Future<Either<Failure, TodoEntity>> getTodoById(String id);
Future<Either<Failure, TodoEntity>> saveTodo(TodoEntity todo);
Future<Either<Failure, void>> deleteTodo(String id);
Stream<List<TodoEntity>> watchTodos();
Stream<TodoEntity?> watchTodoById(String id);
}
abstract interface class UserProfileRepository {
Future<Either<Failure, UserProfile>> getUserProfile(String userId);
Future<Either<Failure, void>> updateUserProfile(UserProfile profile);
Stream<UserProfile?> watchUserProfile(String userId);
}
// ✅ GOOD: Implementation correctly implements the interface
@LazySingleton(as: TodoRepository)
class TodoRepositoryImpl implements TodoRepository {
// Implementation details
@override
Future<Either<Failure, List<TodoEntity>>> getTodos() async {
// Implementation
}
// Other method implementations...
}
// ✅ GOOD: Multiple implementations of the same interface
@LazySingleton(as: TodoRepository)
@Environment('dev')
class MockTodoRepositoryImpl implements TodoRepository {
// Mock implementation for development
}
@LazySingleton(as: TodoRepository)
@Environment('prod')
class FirestoreTodoRepositoryImpl implements TodoRepository {
// Production implementation
}
Anti-Pattern:
// ❌ BAD: Using just abstract class without interface modifier
abstract class TodoRepository {
// This can be extended, which we don't want
}
// ❌ BAD: Using regular class as interface
class TodoRepository {
// This can be instantiated and extended
}
// ❌ BAD: Extending instead of implementing
abstract interface class BaseRepository {
// Some base functionality
}
// Don't extend repository interfaces
class TodoRepository extends BaseRepository {
// Should implement, not extend
}
// ❌ BAD: Concrete implementation in interface
abstract interface class UserRepository {
// Interfaces should not have implementation
String formatUserName(String name) {
return name.trim().toLowerCase();
}
}
// ❌ BAD: Using typedef instead of proper interface
typedef TodoRepository = Future<List<Todo>> Function();
// ❌ BAD: Not using the interface at all
@lazySingleton
class TodoRepositoryImpl {
// No interface to implement
}
Key Considerations:
abstract interface class
is Dart 3+ syntax- Interfaces define contracts without implementation
- Cannot be extended, only implemented
- Cannot be instantiated
- Enables multiple implementations (mock, real, etc.)
- Makes dependency inversion principle explicit
- Better IDE support and error messages
Rule: External deps in @module class: Firebase, AppDatabase, Dio, etc.
Explanation: External dependencies (third-party libraries, platform services, databases) cannot be annotated with @injectable because you don't own their source code. These must be provided through a @module class that acts as a factory for external dependencies. This centralizes configuration and makes it easy to swap implementations.
Good Pattern:
// ✅ GOOD: Module for external dependencies
@module
abstract class AppModule {
// Firebase services
@lazySingleton
FirebaseAuth get firebaseAuth => FirebaseAuth.instance;
@lazySingleton
FirebaseFirestore get firebaseFirestore => FirebaseFirestore.instance;
@lazySingleton
FirebaseFunctions get firebaseFunctions => FirebaseFunctions.instance;
@lazySingleton
FirebaseStorage get firebaseStorage => FirebaseStorage.instance;
// Dio configuration
@lazySingleton
Dio get dio {
final dio = Dio();
dio.options = BaseOptions(
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
},
);
// Add interceptors
dio.interceptors.addAll([
LogInterceptor(
requestBody: true,
responseBody: true,
logPrint: (object) => log(object.toString()),
),
// Add more interceptors as needed
]);
return dio;
}
// SQLite database
@preResolve
@lazySingleton
Future<AppDatabase> get database async {
return AppDatabase();
}
// SharedPreferences
@preResolve
@lazySingleton
Future<SharedPreferences> get sharedPreferences async {
return SharedPreferences.getInstance();
}
// PackageInfo
@preResolve
@lazySingleton
Future<PackageInfo> get packageInfo async {
return PackageInfo.fromPlatform();
}
// RevenueCat
@lazySingleton
Purchases get purchases => Purchases.instance;
}
// ✅ GOOD: Environment-specific modules
@module
abstract class DevModule {
@Environment('dev')
@lazySingleton
String get apiBaseUrl => 'https://dev-api.example.com';
@Environment('dev')
@lazySingleton
Duration get cacheTimeout => const Duration(minutes: 1);
}
@module
abstract class ProdModule {
@Environment('prod')
@lazySingleton
String get apiBaseUrl => 'https://api.example.com';
@Environment('prod')
@lazySingleton
Duration get cacheTimeout => const Duration(hours: 1);
}
// ✅ GOOD: Using module dependencies in services
@lazySingleton
class AuthService {
final FirebaseAuth _firebaseAuth;
final FirebaseFirestore _firestore;
final Dio _dio;
AuthService(
this._firebaseAuth,
this._firestore,
this._dio,
);
// Service implementation
}
Anti-Pattern:
// ❌ BAD: Manually creating instances in classes
@injectable
class AuthRepository {
// Don't create instances directly
final firebaseAuth = FirebaseAuth.instance;
final dio = Dio();
}
// ❌ BAD: Module with non-external dependencies
@module
abstract class BadModule {
// Don't put your own classes in modules
@lazySingleton
TodoRepository get todoRepository => TodoRepositoryImpl();
}
// ❌ BAD: Module methods with parameters
@module
abstract class BadModule {
// Module methods cannot have parameters
@lazySingleton
Dio getDio(String baseUrl) => Dio();
}
// ❌ BAD: Concrete module class
@module
class ConcreteModule {
// Modules must be abstract
}
// ❌ BAD: Forgetting @preResolve for async dependencies
@module
abstract class BadModule {
@lazySingleton
// Missing @preResolve
Future<SharedPreferences> get prefs => SharedPreferences.getInstance();
}
// ❌ BAD: Creating new instances on each call
@module
abstract class BadModule {
// This creates a new instance every time - use @lazySingleton
Dio get dio => Dio();
}
Key Considerations:
- Module classes must be abstract
- Use @lazySingleton for singleton instances
- Use @preResolve for async initialization
- Configure external services in the module
- Use Environment annotations for different configs
- Module methods cannot have parameters
- Don't put your own classes in modules
Rule: Run dart run build_runner build -d after DI changes
Explanation: The injectable package uses code generation to create the dependency injection configuration. After adding, modifying, or removing any @injectable annotations, you must run build_runner to regenerate the DI configuration. The -d
flag deletes conflicting outputs, ensuring a clean build.
Good Pattern:
// ✅ GOOD: Typical workflow for adding a new injectable class
// 1. Create your class with appropriate annotation
@injectable
class UserPreferencesService {
final SharedPreferences _prefs;
UserPreferencesService(this._prefs);
String? getThemeMode() => _prefs.getString('theme_mode');
Future<void> setThemeMode(String mode) async {
await _prefs.setString('theme_mode', mode);
}
}
// 2. Run build_runner to generate DI code
// Terminal:
// dart run build_runner build -d
// 3. The generated file (dependency_injection.config.dart) will include:
// gh.factory<UserPreferencesService>(
// () => UserPreferencesService(gh<SharedPreferences>())
// );
// ✅ GOOD: Adding a new repository
// 1. Define interface
abstract interface class SettingsRepository {
Future<Either<Failure, Settings>> getSettings();
Future<Either<Failure, void>> updateSettings(Settings settings);
}
// 2. Implement with annotation
@LazySingleton(as: SettingsRepository)
class SettingsRepositoryImpl implements SettingsRepository {
final SettingsFirestoreDataSource _remoteDataSource;
SettingsRepositoryImpl(this._remoteDataSource);
// Implementation...
}
// 3. Run build_runner
// dart run build_runner build -d
// ✅ GOOD: Common build_runner commands
// Full rebuild (recommended):
// dart run build_runner build -d
// Watch mode for development:
// dart run build_runner watch -d
// Build without deleting (faster but may have conflicts):
// dart run build_runner build
// Clean and rebuild:
// dart run build_runner clean
// dart run build_runner build -d
Anti-Pattern:
// ❌ BAD: Forgetting to run build_runner after changes
@injectable
class NewService {
// Added this class but didn't run build_runner
// getIt<NewService>() will fail at runtime!
}
// ❌ BAD: Manually editing generated files
// Never edit dependency_injection.config.dart directly
// Your changes will be lost on next build
// ❌ BAD: Committing without running build_runner
// Always run build_runner before committing:
// 1. Make DI changes
// 2. Run: dart run build_runner build -d
// 3. Test the app
// 4. Commit both source and generated files
// ❌ BAD: Ignoring build errors
// If build_runner fails, fix the errors before proceeding
// Common errors:
// - Circular dependencies
// - Missing dependencies
// - Invalid annotations
// - Duplicate registrations
// ❌ BAD: Not including generated files in version control
// *.config.dart files should be committed
// *.g.dart files should be committed
// Don't add them to .gitignore
Key Considerations:
- Always run after adding/modifying @injectable annotations
- Use
-d
flag to delete conflicting outputs - Commit generated files to version control
- Run before testing to ensure DI is configured
- Fix any build errors before proceeding
- Consider using watch mode during development
- Build_runner also generates code for Freezed, JSON serialization, etc.
Rule: Use PremiumWrapper for premium gating (see: §10)
Explanation: PremiumWrapper provides a consistent, declarative way to gate features based on subscription tiers. It handles the logic of checking user permissions and showing upgrade dialogs, keeping your UI code clean and focused on presentation. This pattern ensures consistent behavior across the app and makes it easy to adjust feature availability.
Good Pattern:
// ✅ GOOD: Using PremiumWrapper for feature gating
class TodoListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Todos'),
actions: [
// Premium feature: Export todos
PremiumWrapper(
feature: PremiumFeature.exportData,
action: () => _exportTodos(context),
builder: (context, onPressed) => IconButton(
onPressed: onPressed,
icon: const Icon(Icons.download),
tooltip: 'Export Todos',
),
),
],
),
body: Column(
children: [
// Free feature: View todos
Expanded(child: _TodoList()),
// Premium feature: Add unlimited todos
PremiumWrapper(
feature: PremiumFeature.unlimitedTodos,
action: () => _addNewTodo(context),
builder: (context, onPressed) => FloatingActionButton(
onPressed: onPressed,
child: const Icon(Icons.add),
),
),
],
),
);
}
}
// ✅ GOOD: Premium wrapper with custom button styles
PremiumWrapper(
feature: PremiumFeature.advancedThemes,
action: () => _openThemeSelector(context),
builder: (context, onPressed) => Card(
child: ListTile(
leading: const Icon(Icons.palette),
title: const Text('Advanced Themes'),
subtitle: const Text('Customize your experience'),
trailing: const Icon(Icons.chevron_right),
onTap: onPressed,
),
),
)
// ✅ GOOD: Conditional UI based on subscription tier
BlocBuilder<SubscriptionCubit, SubscriptionState>(
builder: (context, state) {
final isPremium = state.maybeWhen(
loaded: (info) => info.currentTier.index >= SubscriptionTier.premium.index,
orElse: () => false,
);
return Column(
children: [
Text('Todo Limit: ${isPremium ? "Unlimited" : "5"}'),
if (!isPremium)
const Text(
'Upgrade to Premium for unlimited todos',
style: TextStyle(fontSize: 12),
),
],
);
},
)
Anti-Pattern:
// ❌ BAD: Manual subscription checking in UI
ElevatedButton(
onPressed: () {
// Don't check subscription manually in UI
final subscription = context.read<SubscriptionCubit>().state;
if (subscription.currentTier == SubscriptionTier.premium) {
_exportData();
} else {
showDialog(
context: context,
builder: (_) => UpgradeDialog(),
);
}
},
child: const Text('Export'),
)
// ❌ BAD: Hardcoded tier checks scattered in UI
if (userTier >= 2) { // What does 2 mean?
return PremiumFeatureWidget();
}
// ❌ BAD: Not using PremiumWrapper for gated features
IconButton(
onPressed: () {
// Missing subscription check
_performPremiumAction();
},
icon: const Icon(Icons.star),
)
// ❌ BAD: Inconsistent upgrade messaging
ElevatedButton(
onPressed: isPremium ? _action : () {
// Custom dialog instead of standard PremiumWrapper
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Subscribe to use this')),
);
},
child: const Text('Premium Feature'),
)
Key Considerations:
- Always use PremiumWrapper for premium features
- Define features in PremiumFeature enum
- Keep upgrade messaging consistent
- PremiumWrapper handles all subscription logic
- Works with any widget that has an onPressed callback
- See Section 10 for detailed premium feature implementation
Rule: const widgets wherever possible, cache widget instances
Explanation: Using const constructors prevents unnecessary widget rebuilds, improving performance. Flutter can optimize const widgets by creating them once and reusing the same instance. For dynamic widgets that can't be const, consider caching instances to avoid recreation on every build.
Good Pattern:
// ✅ GOOD: Using const for static widgets
class MyScreen extends StatelessWidget {
const MyScreen({super.key}); // Const constructor
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My App'), // Const widget
),
body: Column(
children: const [
_HeaderSection(), // Const child widget
SizedBox(height: 16), // Const spacing
_StaticContent(), // Const content
],
),
);
}
}
// ✅ GOOD: Const widget with const constructor
class _HeaderSection extends StatelessWidget {
const _HeaderSection();
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.all(16.0), // Const EdgeInsets
child: Text(
'Welcome',
style: TextStyle(fontSize: 24), // Const TextStyle
),
);
}
}
// ✅ GOOD: Caching widget instances for dynamic content
class _OptimizedList extends StatefulWidget {
@override
State<_OptimizedList> createState() => _OptimizedListState();
}
class _OptimizedListState extends State<_OptimizedList> {
// Cache expensive widgets
late final Widget _header = const _ListHeader();
late final Widget _footer = const _ListFooter();
// Cache widgets that depend on non-changing data
Widget? _cachedEmptyState;
Widget _buildEmptyState() {
return _cachedEmptyState ??= const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.inbox, size: 64),
SizedBox(height: 16),
Text('No items yet'),
],
),
);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
_header, // Reuse cached instance
Expanded(
child: items.isEmpty
? _buildEmptyState() // Reuse cached empty state
: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ListTile(
title: Text(items[index].title),
),
),
),
_footer, // Reuse cached instance
],
);
}
}
// ✅ GOOD: Using const constructors in collections
final List<Widget> actions = const [
IconButton(
onPressed: null, // Will be overridden
icon: Icon(Icons.search),
),
IconButton(
onPressed: null,
icon: Icon(Icons.more_vert),
),
];
Anti-Pattern:
// ❌ BAD: Missing const on static widgets
class MyScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Text('Static Title'), // Missing const
SizedBox(height: 16), // Missing const
Icon(Icons.home), // Missing const
],
),
);
}
}
// ❌ BAD: Creating new instances on every build
class _BadList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
// Creates new widget every build
_buildHeader(),
// Creates new padding every build
Padding(
padding: EdgeInsets.all(16.0), // Missing const
child: Text('Content'),
),
],
);
}
Widget _buildHeader() => Container(
height: 60,
color: Colors.blue,
child: Center(child: Text('Header')),
);
}
// ❌ BAD: Not using const for EdgeInsets, TextStyle, etc.
Container(
padding: EdgeInsets.symmetric(horizontal: 16), // Missing const
child: Text(
'Hello',
style: TextStyle(fontSize: 16), // Missing const
),
)
// ❌ BAD: Rebuilding complex widgets unnecessarily
class _BadState extends State<MyWidget> {
@override
Widget build(BuildContext context) {
return Column(
children: [
// Rebuilds entire complex widget tree
ComplexWidget(
child: AnotherComplexWidget(
data: 'static data',
),
),
],
);
}
}
Key Considerations:
- Use const wherever possible for static widgets
- Add const to EdgeInsets, TextStyle, and other value objects
- Enable the prefer_const_constructors lint rule
- Cache expensive widgets that don't change
- Consider extracting static parts into const widgets
- Const widgets must have all const children
- Can't use const with dynamic/computed values
Rule: Use context.mounted after async operations before using context
Explanation: After an async operation completes, the widget might have been removed from the widget tree. Using BuildContext after the widget is unmounted can cause crashes. Always check context.mounted before using context after any async gap to ensure the widget is still in the tree.
Good Pattern:
// ✅ GOOD: Checking mounted before using context
class _MyWidgetState extends State<MyWidget> {
Future<void> _loadData() async {
final data = await repository.fetchData();
// Check if widget is still mounted
if (!context.mounted) return;
// Safe to use context now
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Data loaded: ${data.length} items')),
);
}
Future<void> _saveAndNavigate() async {
try {
await repository.saveData(formData);
// Check mounted before navigation
if (!context.mounted) return;
Navigator.of(context).pop();
} catch (e) {
// Check mounted before showing error
if (!context.mounted) return;
showDialog(
context: context,
builder: (_) => ErrorDialog(message: e.toString()),
);
}
}
}
// ✅ GOOD: Early return pattern with mounted check
Future<void> _performAction() async {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const LoadingDialog(),
);
try {
final result = await someAsyncOperation();
if (!context.mounted) return;
Navigator.of(context).pop(); // Close loading
if (!context.mounted) return;
if (result.isSuccess) {
Navigator.of(context).pushNamed('/success');
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Operation failed')),
);
}
} catch (e) {
if (!context.mounted) return;
Navigator.of(context).pop(); // Close loading
if (!context.mounted) return;
showDialog(
context: context,
builder: (_) => ErrorDialog(error: e),
);
}
}
// ✅ GOOD: Multiple async operations with mounted checks
Future<void> _complexFlow() async {
final userId = await _authenticateUser();
if (!context.mounted) return;
final profile = await _loadUserProfile(userId);
if (!context.mounted) return;
final settings = await _loadUserSettings(userId);
if (!context.mounted) return;
// All async operations complete, safe to update UI
setState(() {
_userProfile = profile;
_userSettings = settings;
});
}
Anti-Pattern:
// ❌ BAD: Using context after async without checking mounted
Future<void> _badAsync() async {
final data = await fetchData();
// Widget might be unmounted here!
Navigator.of(context).pushNamed('/details', arguments: data);
}
// ❌ BAD: No mounted check before showing SnackBar
Future<void> _badSave() async {
await saveData();
// Crash if widget unmounted!
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Saved!')),
);
}
// ❌ BAD: setState after async without mounted check
Future<void> _badSetState() async {
final result = await compute();
// Will throw if unmounted!
setState(() {
_result = result;
});
}
// ❌ BAD: Chained async operations without checks
Future<void> _badChain() async {
final token = await getToken();
final user = await getUser(token);
final profile = await getProfile(user.id);
// Multiple chances for widget to unmount!
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ProfileScreen(profile),
),
);
}
// ❌ BAD: Using context in catch block without check
Future<void> _badErrorHandling() async {
try {
await riskyOperation();
} catch (e) {
// Widget might be unmounted when error occurs!
showDialog(
context: context,
builder: (_) => ErrorDialog(error: e),
);
}
}
Key Considerations:
- Always check context.mounted after await
- Check before each context usage after async gaps
- Early return pattern keeps code clean
- Applies to Navigation, SnackBars, Dialogs, setState
- Not needed for synchronous code
- Essential for preventing crashes in production
- Required even in catch/finally blocks
Rule: Use Theme colors only: Theme.of(context).colorScheme
Explanation: Always use colors from the app's theme instead of hardcoding color values. This ensures consistency across the app, supports dark mode automatically, and makes it easy to update the app's color scheme. The Material 3 colorScheme provides semantic color roles that adapt to light/dark themes.
Good Pattern:
// ✅ GOOD: Using theme colors
class _ThemedWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
color: colorScheme.surface,
child: Column(
children: [
Text(
'Primary Action',
style: TextStyle(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Important Info',
style: TextStyle(color: colorScheme.onPrimaryContainer),
),
),
if (hasError)
Text(
'Error occurred',
style: TextStyle(color: colorScheme.error),
),
],
),
);
}
}
// ✅ GOOD: Using semantic colors for states
class _StatusIndicator extends StatelessWidget {
final Status status;
const _StatusIndicator({required this.status});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final color = switch (status) {
Status.success => colorScheme.primary,
Status.warning => colorScheme.tertiary,
Status.error => colorScheme.error,
Status.info => colorScheme.secondary,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
border: Border.all(color: color),
borderRadius: BorderRadius.circular(16),
),
child: Text(
status.label,
style: TextStyle(color: color),
),
);
}
}
// ✅ GOOD: Using surface variants for elevation
class _CardWithElevation extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
color: colorScheme.surfaceContainerHighest,
child: ListTile(
tileColor: colorScheme.surface,
title: Text(
'Card Title',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Subtitle text',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
leading: Icon(
Icons.folder,
color: colorScheme.primary,
),
),
);
}
}
// ✅ GOOD: Proper opacity usage with theme colors
Container(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
child: Text(
'Highlighted',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
),
),
)
Anti-Pattern:
// ❌ BAD: Hardcoded colors
Container(
color: Colors.blue, // Hardcoded color
child: Text(
'Title',
style: TextStyle(color: Colors.white), // Hardcoded color
),
)
// ❌ BAD: Using specific color values
Container(
color: const Color(0xFF2196F3), // Hardcoded hex color
child: Text(
'Content',
style: TextStyle(color: Color.fromRGBO(255, 255, 255, 1)), // Hardcoded RGBA
),
)
// ❌ BAD: Using Material 2 colors
Container(
color: Theme.of(context).primaryColor, // Deprecated Material 2
child: Text(
'Old way',
style: TextStyle(
color: Theme.of(context).accentColor, // Deprecated
),
),
)
// ❌ BAD: Custom colors without theme integration
class BadColors {
static const blue = Color(0xFF1976D2);
static const grey = Color(0xFF9E9E9E);
static const error = Colors.red;
}
Container(
color: BadColors.blue, // Not theme-aware
)
// ❌ BAD: Hardcoded colors for states
Icon(
Icons.check_circle,
color: Colors.green, // Won't adapt to dark mode
)
// ❌ BAD: Using Colors.black/white directly
Text(
'Title',
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black, // Manual theme handling
),
)
Key Considerations:
- Always use Theme.of(context).colorScheme
- Understand Material 3 color roles (primary, secondary, tertiary, error, surface)
- Use onX colors for content on X backgrounds (e.g., onPrimary, onSurface)
- Surface variants provide elevation levels
- Colors automatically adapt to light/dark mode
- Use withOpacity() for transparent variants
- Never hardcode hex values or Colors.X directly
Rule: Use LayoutBuilder for responsive UI constraints, not MediaQuery
Explanation: LayoutBuilder provides the actual available space for a widget, while MediaQuery gives screen dimensions. For responsive layouts, you need to know how much space your widget has, not the total screen size. This is especially important when widgets are constrained by parent containers, navigation drawers, or multi-pane layouts.
Good Pattern:
// ✅ GOOD: Using LayoutBuilder for responsive design
class _ResponsiveGrid extends StatelessWidget {
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// Get actual available width
final width = constraints.maxWidth;
// Determine column count based on available space
final columnCount = switch (width) {
< 600 => 2,
< 900 => 3,
< 1200 => 4,
_ => 5,
};
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columnCount,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemBuilder: (context, index) => _GridItem(index: index),
);
},
);
}
}
// ✅ GOOD: Adaptive layout based on available space
class _AdaptiveLayout extends StatelessWidget {
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// Different layouts based on actual available space
if (constraints.maxWidth < 600) {
// Mobile layout
return Column(
children: [
_Header(),
Expanded(child: _Content()),
_Footer(),
],
);
} else {
// Desktop layout
return Row(
children: [
SizedBox(width: 250, child: _Sidebar()),
Expanded(
child: Column(
children: [
_Header(),
Expanded(child: _Content()),
_Footer(),
],
),
),
],
);
}
},
);
}
}
// ✅ GOOD: Responsive text scaling
class _ResponsiveText extends StatelessWidget {
final String text;
const _ResponsiveText(this.text);
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// Scale font size based on available width
final fontSize = switch (constraints.maxWidth) {
< 300 => 14.0,
< 600 => 16.0,
< 900 => 18.0,
_ => 20.0,
};
return Text(
text,
style: TextStyle(fontSize: fontSize),
);
},
);
}
}
// ✅ GOOD: Constrained layouts with LayoutBuilder
class _ConstrainedContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 800),
child: LayoutBuilder(
builder: (context, constraints) {
// Now we know the actual width (max 800)
final isNarrow = constraints.maxWidth < 600;
return Padding(
padding: EdgeInsets.symmetric(
horizontal: isNarrow ? 16 : 32,
),
child: _ContentBody(isNarrow: isNarrow),
);
},
),
),
);
}
}
Anti-Pattern:
// ❌ BAD: Using MediaQuery for layout decisions
class _BadResponsive extends StatelessWidget {
@override
Widget build(BuildContext context) {
// This gives screen size, not available space!
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth < 600) {
return MobileLayout();
} else {
return DesktopLayout();
}
}
}
// ❌ BAD: MediaQuery in constrained widget
class _BadGrid extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Wrong! Parent might constrain this widget
final width = MediaQuery.of(context).size.width;
final columns = width ~/ 200;
return GridView.count(
crossAxisCount: columns,
children: items,
);
}
}
// ❌ BAD: Not considering parent constraints
Container(
width: 300, // Parent constrains width
child: Builder(
builder: (context) {
// This returns full screen width, not 300!
final width = MediaQuery.of(context).size.width;
return Text('Width: $width'); // Misleading
},
),
)
// ❌ BAD: Hardcoded breakpoints without actual space check
if (MediaQuery.of(context).size.width > 768) {
// Assumes widget has full screen width
return TabletLayout();
}
// ❌ BAD: Using MediaQuery for component sizing
Container(
// Trying to take 80% of screen width
width: MediaQuery.of(context).size.width * 0.8,
child: Content(),
)
Key Considerations:
- LayoutBuilder provides actual available space
- MediaQuery provides device/screen information
- Parent constraints affect available space
- Essential for responsive designs
- Works correctly in constrained environments
- Better for component-based responsive design
- Use MediaQuery only for device-level info (padding, orientation)
Rule: Event-driven repos with StreamController broadcasts
Explanation: Repositories should expose streams for real-time data updates using StreamController.broadcast(). This enables reactive UI updates across multiple screens without tight coupling. When data changes in one place, all listeners automatically receive updates, maintaining consistency throughout the app.
Good Pattern:
// ✅ GOOD: Event-driven repository with broadcast streams
@LazySingleton(as: TodoRepository)
class TodoRepositoryImpl implements TodoRepository {
final TodoFirestoreDataSource _remoteDataSource;
final TodoLocalDataSource _localDataSource;
// Broadcast stream controller for real-time updates
final _todosStreamController = StreamController<List<TodoEntity>>.broadcast();
final _todoChangesController = StreamController<TodoChangeEvent>.broadcast();
TodoRepositoryImpl(
this._remoteDataSource,
this._localDataSource,
) {
// Initialize stream with remote data
_initializeStream();
}
void _initializeStream() {
_remoteDataSource.watchTodos().listen(
(dtos) {
final entities = dtos.map((dto) => dto.toEntity()).toList();
_todosStreamController.add(entities);
},
onError: (error) {
_todosStreamController.addError(error);
},
);
}
@override
Stream<List<TodoEntity>> watchTodos() => _todosStreamController.stream;
@override
Stream<TodoChangeEvent> watchTodoChanges() => _todoChangesController.stream;
@override
Future<Either<Failure, TodoEntity>> saveTodo(TodoEntity todo) async {
try {
final dto = todo.toDto();
final savedDto = await _remoteDataSource.saveTodo(dto);
final savedEntity = savedDto.toEntity();
// Emit change event
_todoChangesController.add(
TodoChangeEvent(
type: todo.id == null ? ChangeType.created : ChangeType.updated,
todo: savedEntity,
),
);
return Right(savedEntity);
} catch (e) {
return Left(ServerFailure(e.toString()));
}
}
@override
Future<Either<Failure, void>> deleteTodo(String id) async {
try {
await _remoteDataSource.deleteTodo(id);
// Emit delete event
_todoChangesController.add(
TodoChangeEvent(
type: ChangeType.deleted,
todoId: id,
),
);
return const Right(null);
} catch (e) {
return Left(ServerFailure(e.toString()));
}
}
void dispose() {
_todosStreamController.close();
_todoChangesController.close();
}
}
// ✅ GOOD: Change event for granular updates
@freezed
sealed class TodoChangeEvent with _$TodoChangeEvent {
const factory TodoChangeEvent({
required ChangeType type,
TodoEntity? todo,
String? todoId,
}) = _TodoChangeEvent;
}
enum ChangeType { created, updated, deleted }
// ✅ GOOD: Using streams in Cubits
@injectable
class TodoReadCubit extends Cubit<TodoReadState> {
final TodoRepository _repository;
StreamSubscription? _todosSubscription;
StreamSubscription? _changesSubscription;
TodoReadCubit(this._repository) : super(const TodoReadState.initial()) {
_initializeStreams();
}
void _initializeStreams() {
// Listen to todos stream
_todosSubscription = _repository.watchTodos().listen(
(todos) => emit(TodoReadState.loaded(todos)),
onError: (error) => emit(TodoReadState.error(error.toString())),
);
// Listen to change events for optimistic updates
_changesSubscription = _repository.watchTodoChanges().listen(
(event) {
if (state is Loaded) {
final currentTodos = (state as Loaded).todos;
switch (event.type) {
case ChangeType.created:
// Optimistically add new todo
emit(TodoReadState.loaded([...currentTodos, event.todo!]));
case ChangeType.updated:
// Optimistically update todo
final updated = currentTodos.map((t) =>
t.id == event.todo!.id ? event.todo! : t
).toList();
emit(TodoReadState.loaded(updated));
case ChangeType.deleted:
// Optimistically remove todo
final filtered = currentTodos.where((t) => t.id != event.todoId).toList();
emit(TodoReadState.loaded(filtered));
}
}
},
);
}
@override
Future<void> close() {
_todosSubscription?.cancel();
_changesSubscription?.cancel();
return super.close();
}
}
Anti-Pattern:
// ❌ BAD: Repository without streams
@LazySingleton(as: TodoRepository)
class BadTodoRepository implements TodoRepository {
// No streams - requires manual refresh
Future<List<TodoEntity>> getTodos() async {
return await _remoteDataSource.getTodos();
}
}
// ❌ BAD: Using regular StreamController (not broadcast)
class BadRepository {
// Only one listener allowed!
final _controller = StreamController<List<Todo>>();
Stream<List<Todo>> get todos => _controller.stream;
}
// ❌ BAD: Not closing StreamControllers
class BadRepository {
final _controller = StreamController<Data>.broadcast();
// Missing dispose method to close controller!
}
// ❌ BAD: Polling instead of streams
class BadCubit extends Cubit<State> {
Timer? _timer;
void startPolling() {
// Inefficient polling every 5 seconds
_timer = Timer.periodic(Duration(seconds: 5), (_) {
_fetchLatestData();
});
}
}
// ❌ BAD: Manual state synchronization
class BadWriteCubit extends Cubit<WriteState> {
final BadReadCubit _readCubit; // Cubit depending on cubit!
Future<void> save(Todo todo) async {
await _repository.save(todo);
_readCubit.refresh(); // Manual sync
}
}
Key Considerations:
- Use StreamController.broadcast() for multiple listeners
- Emit events for all data changes
- Close StreamControllers in dispose()
- Leverage Firestore's real-time capabilities
- Enable optimistic UI updates
- Avoid polling or manual refreshing
- Keep repositories reactive, not imperative
Rule: TimestampConverter for Firestore ↔ DateTime conversion
Explanation: Firestore uses its own Timestamp type for date/time values. To seamlessly work with Dart's DateTime in your entities while maintaining Firestore compatibility, use a TimestampConverter with json_serializable. This ensures automatic conversion during serialization/deserialization without manual handling.
Good Pattern:
// ✅ GOOD: TimestampConverter implementation
class TimestampConverter implements JsonConverter<DateTime?, Timestamp?> {
const TimestampConverter();
@override
DateTime? fromJson(Timestamp? timestamp) {
return timestamp?.toDate();
}
@override
Timestamp? toJson(DateTime? date) {
return date != null ? Timestamp.fromDate(date) : null;
}
}
// ✅ GOOD: Using TimestampConverter in DTOs
@freezed
class TodoDto with _$TodoDto {
const factory TodoDto({
@JsonKey(includeToJson: false) String? id,
required String title,
String? description,
@Default(false) bool isCompleted,
@TimestampConverter() DateTime? dueDate,
@JsonKey(includeToJson: false) @TimestampConverter() DateTime? createdAt,
@JsonKey(includeToJson: false) @TimestampConverter() DateTime? updatedAt,
@Default(false) bool isDeleted,
}) = _TodoDto;
const TodoDto._();
factory TodoDto.fromJson(Map<String, dynamic> json) =>
_$TodoDtoFromJson(json);
}
// ✅ GOOD: Firestore operations with automatic conversion
class TodoFirestoreDataSource {
final FirebaseFirestore _firestore;
TodoFirestoreDataSource(this._firestore);
Future<TodoDto> saveTodo(TodoDto todo) async {
final docRef = _firestore.collection('todos').doc(todo.id);
final data = todo.toJson();
// Add server timestamps
if (todo.id == null) {
data['createdAt'] = FieldValue.serverTimestamp();
}
data['updatedAt'] = FieldValue.serverTimestamp();
await docRef.set(data);
// Fetch to get server-generated values
final snapshot = await docRef.get();
return TodoDto.fromDocument(snapshot);
}
Stream<List<TodoDto>> watchTodos() {
return _firestore
.collection('todos')
.where('isDeleted', isEqualTo: false)
.orderBy('createdAt', descending: true)
.snapshots()
.map((snapshot) => snapshot.docs
.map((doc) => TodoDto.fromDocument(doc))
.toList());
}
}
// ✅ GOOD: Factory method for document conversion
extension TodoDtoX on TodoDto {
static TodoDto fromDocument(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>?;
if (data == null) {
throw Exception('Document data is null');
}
// id comes from document, not data
return TodoDto.fromJson(data).copyWith(id: doc.id);
}
}
// ✅ GOOD: Using DateTime in entities (no Firestore dependency)
@freezed
abstract class TodoEntity with _$TodoEntity {
const factory TodoEntity({
String? id,
required String title,
String? description,
required bool isCompleted,
DateTime? dueDate,
DateTime? createdAt,
DateTime? updatedAt,
}) = _TodoEntity;
}
Anti-Pattern:
// ❌ BAD: Manual timestamp conversion in every method
class BadDataSource {
Future<TodoDto> getTodo(String id) async {
final doc = await _firestore.collection('todos').doc(id).get();
final data = doc.data()!;
// Manual conversion everywhere
data['createdAt'] = (data['createdAt'] as Timestamp?)?.toDate();
data['updatedAt'] = (data['updatedAt'] as Timestamp?)?.toDate();
data['dueDate'] = (data['dueDate'] as Timestamp?)?.toDate();
return TodoDto.fromJson(data);
}
}
// ❌ BAD: Timestamp in entity layer
@freezed
class BadEntity with _$BadEntity {
const factory BadEntity({
String? id,
Timestamp? createdAt, // Firestore type in domain!
}) = _BadEntity;
}
// ❌ BAD: Not handling null timestamps
class BadConverter implements JsonConverter<DateTime, Timestamp> {
@override
DateTime fromJson(Timestamp timestamp) {
// Crashes on null!
return timestamp.toDate();
}
}
// ❌ BAD: String conversion instead of Timestamp
@JsonKey(name: 'created_at')
String? createdAt; // Loses precision and timezone
// ❌ BAD: Manual toJson/fromJson instead of converter
Map<String, dynamic> toJson() {
return {
'createdAt': createdAt != null
? Timestamp.fromDate(createdAt!)
: null,
// Repeated for every timestamp field...
};
}
Key Considerations:
- Create a reusable TimestampConverter
- Apply to all DateTime fields in DTOs
- Handle nullable timestamps properly
- Keep Timestamp type out of entities
- Use server timestamps for consistency
- Automatic conversion during serialization
- No manual timestamp handling needed
Rule: @JsonKey(includeToJson: false) for server-managed fields
Explanation: Server-managed fields like document IDs, createdAt, and updatedAt should not be included when sending data to Firestore. Use @JsonKey(includeToJson: false) to exclude these fields from JSON serialization while still allowing them to be deserialized when reading from Firestore. This prevents accidentally overwriting server values.
Good Pattern:
// ✅ GOOD: Excluding server-managed fields from toJson
@freezed
class UserProfileDto with _$UserProfileDto {
const factory UserProfileDto({
// Document ID - managed by Firestore
@JsonKey(includeToJson: false) String? id,
// Server timestamps - managed by FieldValue.serverTimestamp()
@JsonKey(includeToJson: false) @TimestampConverter() DateTime? createdAt,
@JsonKey(includeToJson: false) @TimestampConverter() DateTime? updatedAt,
// User-editable fields
required String displayName,
String? bio,
String? avatarUrl,
@Default(false) bool isVerified,
// Soft delete flag
@Default(false) bool isDeleted,
}) = _UserProfileDto;
const UserProfileDto._();
factory UserProfileDto.fromJson(Map<String, dynamic> json) =>
_$UserProfileDtoFromJson(json);
// Factory for creating new profiles
factory UserProfileDto.forCreation({
required String displayName,
String? bio,
String? avatarUrl,
}) => UserProfileDto(
displayName: displayName,
bio: bio,
avatarUrl: avatarUrl,
);
// Convert to Firestore-ready JSON
Map<String, dynamic> toFirestoreJson({bool isUpdate = false}) {
final json = toJson();
if (isUpdate) {
json['updatedAt'] = FieldValue.serverTimestamp();
} else {
json['createdAt'] = FieldValue.serverTimestamp();
json['updatedAt'] = FieldValue.serverTimestamp();
}
return json;
}
}
// ✅ GOOD: Repository using proper field exclusion
@LazySingleton(as: UserProfileRepository)
class UserProfileRepositoryImpl implements UserProfileRepository {
final FirebaseFirestore _firestore;
UserProfileRepositoryImpl(this._firestore);
@override
Future<Either<Failure, UserProfileEntity>> createProfile(
UserProfileEntity profile,
) async {
try {
final dto = profile.toDto();
final docRef = _firestore.collection('profiles').doc();
// toFirestoreJson excludes id, createdAt, updatedAt
await docRef.set(dto.toFirestoreJson());
// Fetch to get server-generated values
final snapshot = await docRef.get();
final savedDto = UserProfileDto.fromDocument(snapshot);
return Right(savedDto.toEntity());
} catch (e) {
return Left(ServerFailure(e.toString()));
}
}
@override
Future<Either<Failure, UserProfileEntity>> updateProfile(
UserProfileEntity profile,
) async {
try {
if (profile.id == null) {
return Left(ValidationFailure('Profile ID is required for update'));
}
final dto = profile.toDto();
final docRef = _firestore.collection('profiles').doc(profile.id);
// Only user-editable fields + updatedAt timestamp
await docRef.update(dto.toFirestoreJson(isUpdate: true));
final snapshot = await docRef.get();
final updatedDto = UserProfileDto.fromDocument(snapshot);
return Right(updatedDto.toEntity());
} catch (e) {
return Left(ServerFailure(e.toString()));
}
}
}
// ✅ GOOD: Nested objects with server fields
@freezed
class CommentDto with _$CommentDto {
const factory CommentDto({
@JsonKey(includeToJson: false) String? id,
required String content,
required String authorId,
// Nested author info (denormalized)
required AuthorInfoDto authorInfo,
@JsonKey(includeToJson: false) @TimestampConverter() DateTime? createdAt,
@JsonKey(includeToJson: false) @TimestampConverter() DateTime? editedAt,
@Default(false) bool isDeleted,
}) = _CommentDto;
}
@freezed
class AuthorInfoDto with _$AuthorInfoDto {
const factory AuthorInfoDto({
required String displayName,
String? avatarUrl,
// No server fields in nested objects
}) = _AuthorInfoDto;
}
Anti-Pattern:
// ❌ BAD: Including server fields in toJson
@freezed
class BadDto with _$BadDto {
const factory BadDto({
String? id, // Will overwrite document ID!
DateTime? createdAt, // Will overwrite server timestamp!
DateTime? updatedAt, // Will overwrite server timestamp!
String? name,
}) = _BadDto;
}
// ❌ BAD: Manual exclusion in every operation
class BadRepository {
Future<void> save(BadDto dto) async {
final json = dto.toJson();
// Manual removal everywhere
json.remove('id');
json.remove('createdAt');
json.remove('updatedAt');
await firestore.collection('items').add(json);
}
}
// ❌ BAD: Trying to set server fields from client
final dto = BadDto(
id: 'custom-id', // Don't set IDs client-side
createdAt: DateTime.now(), // Use serverTimestamp
updatedAt: DateTime.now(), // Use serverTimestamp
);
// ❌ BAD: Not excluding computed fields
@freezed
class BadUserDto with _$BadUserDto {
const factory BadUserDto({
String? displayName,
int? age,
// Computed field that shouldn't be stored
String? ageGroup, // Should be @JsonKey(includeToJson: false)
}) = _BadUserDto;
}
// ❌ BAD: Including read-only aggregations
@freezed
class BadPostDto with _$BadPostDto {
const factory BadPostDto({
String? title,
// These should be excluded - they're maintained separately
int? likeCount,
int? commentCount,
}) = _BadPostDto;
}
Key Considerations:
- Always exclude id, createdAt, updatedAt
- Use FieldValue.serverTimestamp() for timestamps
- Exclude computed or derived fields
- Exclude read-only aggregation fields
- Document IDs come from DocumentSnapshot
- Server manages audit fields
- Clean separation of concerns
Rule: Extract document ID from DocumentSnapshot.id, not data
Explanation: Firestore document IDs are metadata, not part of the document data. Always extract the ID from DocumentSnapshot.id property, never store it within the document data itself. This prevents redundancy, ensures consistency, and follows Firestore best practices.
Good Pattern:
// ✅ GOOD: Extracting ID from DocumentSnapshot
extension DocumentSnapshotX on DocumentSnapshot {
/// Safely converts DocumentSnapshot to DTO with ID
T? toDto<T>(T Function(Map<String, dynamic> json, String id) fromJson) {
final data = this.data() as Map<String, dynamic>?;
if (data == null) return null;
// Pass document ID separately
return fromJson(data, id);
}
}
// ✅ GOOD: DTO factory that accepts ID as parameter
@freezed
class ProductDto with _$ProductDto {
const factory ProductDto({
@JsonKey(includeToJson: false) String? id,
required String name,
required double price,
String? description,
@Default([]) List<String> tags,
@JsonKey(includeToJson: false) @TimestampConverter() DateTime? createdAt,
@JsonKey(includeToJson: false) @TimestampConverter() DateTime? updatedAt,
}) = _ProductDto;
const ProductDto._();
factory ProductDto.fromJson(Map<String, dynamic> json) =>
_$ProductDtoFromJson(json);
// Standard pattern for document conversion
factory ProductDto.fromDocument(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>?;
if (data == null) {
throw StateError('Document ${doc.id} contains no data');
}
// ID comes from document metadata, not data
return ProductDto.fromJson(data).copyWith(id: doc.id);
}
}
// ✅ GOOD: Repository correctly handling document IDs
class ProductFirestoreDataSource {
final FirebaseFirestore _firestore;
ProductFirestoreDataSource(this._firestore);
Future<ProductDto> getProduct(String id) async {
final doc = await _firestore.collection('products').doc(id).get();
if (!doc.exists) {
throw ProductNotFoundException('Product $id not found');
}
return ProductDto.fromDocument(doc);
}
Stream<List<ProductDto>> watchProducts() {
return _firestore
.collection('products')
.snapshots()
.map((snapshot) => snapshot.docs
.map((doc) => ProductDto.fromDocument(doc))
.toList());
}
Future<ProductDto> createProduct(ProductDto product) async {
// Let Firestore generate the ID
final docRef = _firestore.collection('products').doc();
await docRef.set(product.toJson());
// Fetch to get the complete document with ID
final snapshot = await docRef.get();
return ProductDto.fromDocument(snapshot);
}
Future<ProductDto> updateProduct(ProductDto product) async {
if (product.id == null) {
throw ArgumentError('Product ID required for update');
}
final docRef = _firestore.collection('products').doc(product.id);
// Update without including the ID in data
await docRef.update(product.toJson());
final snapshot = await docRef.get();
return ProductDto.fromDocument(snapshot);
}
}
// ✅ GOOD: Batch operations with proper ID handling
Future<List<ProductDto>> batchCreate(List<ProductDto> products) async {
final batch = _firestore.batch();
final refs = <DocumentReference>[];
for (final product in products) {
final ref = _firestore.collection('products').doc();
batch.set(ref, product.toJson());
refs.add(ref);
}
await batch.commit();
// Fetch all created documents
final futures = refs.map((ref) => ref.get());
final snapshots = await Future.wait(futures);
return snapshots.map((snap) => ProductDto.fromDocument(snap)).toList();
}
Anti-Pattern:
// ❌ BAD: Storing ID in document data
class BadDataSource {
Future<void> createProduct(ProductDto product) async {
final id = _firestore.collection('products').doc().id;
// Don't store ID in the document!
final data = product.toJson();
data['id'] = id; // Redundant and error-prone
await _firestore.collection('products').doc(id).set(data);
}
}
// ❌ BAD: Reading ID from document data
ProductDto badFromDocument(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>;
// Wrong! ID should come from doc.id
final id = data['id'] as String?;
return ProductDto.fromJson(data);
}
// ❌ BAD: Including ID in queries
final query = _firestore
.collection('products')
.where('id', isEqualTo: productId); // Unnecessary!
// Should just use:
final doc = _firestore.collection('products').doc(productId);
// ❌ BAD: Manually managing IDs in data
@freezed
class BadDto with _$BadDto {
const factory BadDto({
required String id, // Should be nullable and excluded from toJson
required String name,
}) = _BadDto;
Map<String, dynamic> toJson() {
return {
'id': id, // Don't include in data!
'name': name,
};
}
}
// ❌ BAD: Not using Firestore-generated IDs
Future<void> badCreate(ProductDto product) async {
// Don't generate IDs client-side
final customId = DateTime.now().millisecondsSinceEpoch.toString();
await _firestore
.collection('products')
.doc(customId) // Use .doc() without ID to auto-generate
.set(product.toJson());
}
Key Considerations:
- Document ID is metadata, not data
- Use DocumentSnapshot.id property
- Let Firestore auto-generate IDs
- Don't store ID in document data
- Use copyWith to add ID to DTO
- Consistent pattern across all DTOs
- Saves storage and prevents inconsistencies
Rule: Use extensions for Entity ↔ DTO conversion
Explanation: Extensions provide a clean, discoverable way to convert between DTOs (Data Transfer Objects) and Entities. This pattern keeps conversion logic close to the types being converted, making the code more maintainable and preventing the proliferation of mapper classes. Extensions are type-safe and provide excellent IDE support.
Good Pattern:
// ✅ GOOD: Extension for DTO to Entity conversion
extension TodoDtoX on TodoDto {
TodoEntity toEntity() {
return TodoEntity(
id: id,
title: title,
description: description,
isCompleted: isCompleted,
priority: _mapPriority(priority),
tags: tags.map((t) => t.toEntity()).toList(),
dueDate: dueDate,
createdAt: createdAt,
updatedAt: updatedAt,
);
}
static TodoPriority _mapPriority(String? priority) {
return switch (priority) {
'high' => TodoPriority.high,
'medium' => TodoPriority.medium,
'low' => TodoPriority.low,
_ => TodoPriority.none,
};
}
}
// ✅ GOOD: Extension for Entity to DTO conversion
extension TodoEntityX on TodoEntity {
TodoDto toDto() {
return TodoDto(
id: id,
title: title,
description: description,
isCompleted: isCompleted,
priority: priority.name,
tags: tags.map((t) => t.toDto()).toList(),
dueDate: dueDate,
createdAt: createdAt,
updatedAt: updatedAt,
);
}
}
// ✅ GOOD: Nested object conversions
extension TagDtoX on TagDto {
TagEntity toEntity() => TagEntity(
id: id,
name: name,
color: color,
);
}
extension TagEntityX on TagEntity {
TagDto toDto() => TagDto(
id: id,
name: name,
color: color,
);
}
// ✅ GOOD: List extensions for convenience
extension TodoDtoListX on List<TodoDto> {
List<TodoEntity> toEntities() => map((dto) => dto.toEntity()).toList();
}
extension TodoEntityListX on List<TodoEntity> {
List<TodoDto> toDtos() => map((entity) => entity.toDto()).toList();
}
// ✅ GOOD: Complex conversions with business logic
extension UserProfileDtoX on UserProfileDto {
UserProfileEntity toEntity() {
return UserProfileEntity(
id: id,
displayName: displayName,
email: email,
avatarUrl: avatarUrl,
subscription: subscription?.toEntity(),
preferences: UserPreferences(
theme: _mapTheme(preferences['theme']),
language: preferences['language'] ?? 'en',
notifications: NotificationSettings(
email: preferences['notifications']?['email'] ?? true,
push: preferences['notifications']?['push'] ?? true,
),
),
stats: UserStats(
todosCreated: stats['todosCreated'] ?? 0,
todosCompleted: stats['todosCompleted'] ?? 0,
streak: stats['streak'] ?? 0,
),
createdAt: createdAt,
updatedAt: updatedAt,
);
}
static ThemeMode _mapTheme(String? theme) {
return switch (theme) {
'dark' => ThemeMode.dark,
'light' => ThemeMode.light,
_ => ThemeMode.system,
};
}
}
// ✅ GOOD: Using conversions in repository
@LazySingleton(as: TodoRepository)
class TodoRepositoryImpl implements TodoRepository {
final TodoFirestoreDataSource _remoteDataSource;
@override
Future<Either<Failure, TodoEntity>> saveTodo(TodoEntity todo) async {
try {
// Simple, clean conversion
final dto = todo.toDto();
final savedDto = await _remoteDataSource.saveTodo(dto);
return Right(savedDto.toEntity());
} catch (e) {
return Left(ServerFailure(e.toString()));
}
}
@override
Stream<List<TodoEntity>> watchTodos() {
return _remoteDataSource
.watchTodos()
.map((dtos) => dtos.toEntities()); // List extension
}
}
Anti-Pattern:
// ❌ BAD: Separate mapper classes
class TodoMapper {
static TodoEntity toEntity(TodoDto dto) {
// Separate class adds complexity
}
static TodoDto toDto(TodoEntity entity) {
// Hard to discover
}
}
// ❌ BAD: Conversion logic in repository
@LazySingleton(as: TodoRepository)
class BadRepository implements TodoRepository {
Future<Either<Failure, TodoEntity>> getTodo(String id) async {
final dto = await _dataSource.getTodo(id);
// Conversion logic doesn't belong here
return Right(TodoEntity(
id: dto.id,
title: dto.title,
// ... manual mapping
));
}
}
// ❌ BAD: Static methods on DTOs
@freezed
class BadDto with _$BadDto {
const factory BadDto({required String name}) = _BadDto;
// Static methods are less discoverable
static BadEntity toEntity(BadDto dto) {
return BadEntity(name: dto.name);
}
}
// ❌ BAD: Conversion in use cases
@injectable
class BadUseCase {
Future<Either<Failure, Entity>> call() async {
final dto = await _repository.getDto();
// Use case shouldn't handle conversions
final entity = Entity(
id: dto.id,
// ... manual conversion
);
return Right(entity);
}
}
// ❌ BAD: No type safety
class GenericMapper {
static dynamic convert(dynamic from, Type to) {
// Loss of type safety and IDE support
}
}
Key Considerations:
- Extensions are discoverable via IDE autocomplete
- Keep conversion logic close to the type
- Support nested object conversions
- Create list extensions for convenience
- Handle null values appropriately
- Extensions can contain private helper methods
- Clean, readable code at usage sites
Rule: Prioritize unit tests for business logic (Use Cases, Cubits)
Explanation: Focus testing efforts on business logic where bugs have the highest impact. Use Cases and Cubits contain the core application logic and must be thoroughly tested. Simple DTOs or UI widgets that primarily compose other widgets are lower priority.
Good Pattern:
// ✅ GOOD: Testing a Use Case with mocked dependencies
@GenerateMocks([TodoRepository, AuthRepository])
void main() {
late SaveTodoUseCase useCase;
late MockTodoRepository mockTodoRepository;
late MockAuthRepository mockAuthRepository;
setUp(() {
mockTodoRepository = MockTodoRepository();
mockAuthRepository = MockAuthRepository();
useCase = SaveTodoUseCase(mockTodoRepository, mockAuthRepository);
});
test('should save todo with current user ID', () async {
// Arrange
const userId = 'user123';
const user = UserEntity(id: userId, email: '[email protected]');
final todo = TodoEntity(title: 'Test Todo');
final savedTodo = todo.copyWith(id: '123', userId: userId);
when(mockAuthRepository.getCurrentUser())
.thenAnswer((_) async => const Right(user));
when(mockTodoRepository.saveTodo(any))
.thenAnswer((_) async => Right(savedTodo));
// Act
final result = await useCase(SaveTodoParams(todo: todo));
// Assert
expect(result, Right(savedTodo));
verify(mockAuthRepository.getCurrentUser()).called(1);
verify(mockTodoRepository.saveTodo(
argThat(predicate<TodoEntity>((t) => t.userId == userId))
)).called(1);
});
test('should return failure when user is not authenticated', () async {
// Arrange
final todo = TodoEntity(title: 'Test Todo');
when(mockAuthRepository.getCurrentUser())
.thenAnswer((_) async => Left(AuthFailure()));
// Act
final result = await useCase(SaveTodoParams(todo: todo));
// Assert
expect(result, isA<Left>());
verifyNever(mockTodoRepository.saveTodo(any));
});
}
// ✅ GOOD: Testing a Cubit with blocTest
void main() {
late TodoWriteCubit cubit;
late MockSaveTodoUseCase mockSaveTodo;
late MockDeleteTodoUseCase mockDeleteTodo;
setUp(() {
mockSaveTodo = MockSaveTodoUseCase();
mockDeleteTodo = MockDeleteTodoUseCase();
cubit = TodoWriteCubit(mockSaveTodo, mockDeleteTodo);
});
blocTest<TodoWriteCubit, TodoWriteState>(
'emits [saving, saved] when save succeeds',
build: () => cubit,
act: (cubit) async {
when(mockSaveTodo(any))
.thenAnswer((_) async => Right(testTodo));
await cubit.saveTodo('Test Todo');
},
expect: () => [
const TodoWriteState.saving(),
TodoWriteState.saved(testTodo),
],
verify: (_) {
verify(mockSaveTodo(any)).called(1);
},
);
}
Anti-Pattern:
// ❌ BAD: Testing implementation details instead of behavior
test('should set loading to true', () {
cubit.loading = true; // Testing private state
expect(cubit.loading, true);
});
// ❌ BAD: Testing simple DTOs without business logic
test('should create TodoDto', () {
final dto = TodoDto(title: 'Test');
expect(dto.title, 'Test'); // Low value test
});
// ❌ BAD: Testing UI widgets with complex setups
testWidgets('button changes color on tap', (tester) async {
// Complex widget tests are fragile and slow
});
Key Considerations:
- Test behavior, not implementation
- Mock external dependencies
- Focus on edge cases and error paths
- Keep tests fast and deterministic
- Higher ROI testing business logic vs UI
Rule: Test Use Cases with mocked repositories
Explanation: Use Cases orchestrate business logic and should be tested in isolation. Mock repository interfaces to control test scenarios and verify correct repository method calls without depending on actual data sources.
Good Pattern:
// ✅ GOOD: Comprehensive Use Case testing
@GenerateMocks([TodoRepository, UserRepository, NotificationService])
void main() {
group('CompleteTodoUseCase', () {
late CompleteTodoUseCase useCase;
late MockTodoRepository mockTodoRepository;
late MockUserRepository mockUserRepository;
late MockNotificationService mockNotificationService;
setUp(() {
mockTodoRepository = MockTodoRepository();
mockUserRepository = MockUserRepository();
mockNotificationService = MockNotificationService();
useCase = CompleteTodoUseCase(
mockTodoRepository,
mockUserRepository,
mockNotificationService,
);
});
test('should complete todo and update user stats', () async {
// Arrange
const todoId = 'todo123';
const userId = 'user123';
final todo = TodoEntity(
id: todoId,
userId: userId,
title: 'Test Todo',
isCompleted: false,
);
final completedTodo = todo.copyWith(isCompleted: true);
final userStats = UserStats(todosCompleted: 5);
when(mockTodoRepository.getTodoById(todoId))
.thenAnswer((_) async => Right(todo));
when(mockTodoRepository.updateTodo(any))
.thenAnswer((_) async => Right(completedTodo));
when(mockUserRepository.incrementCompletedCount(userId))
.thenAnswer((_) async => const Right(null));
when(mockUserRepository.getUserStats(userId))
.thenAnswer((_) async => Right(userStats));
when(mockNotificationService.scheduleStreakNotification(any))
.thenAnswer((_) async => const Right(null));
// Act
final result = await useCase(CompleteToDoParams(todoId: todoId));
// Assert
expect(result, Right(completedTodo));
// Verify correct sequence of calls
verifyInOrder([
mockTodoRepository.getTodoById(todoId),
mockTodoRepository.updateTodo(
argThat(predicate<TodoEntity>((t) =>
t.id == todoId && t.isCompleted == true
)),
),
mockUserRepository.incrementCompletedCount(userId),
mockUserRepository.getUserStats(userId),
mockNotificationService.scheduleStreakNotification(userStats),
]);
});
test('should return failure when todo not found', () async {
// Arrange
const todoId = 'nonexistent';
when(mockTodoRepository.getTodoById(todoId))
.thenAnswer((_) async => Left(NotFoundFailure()));
// Act
final result = await useCase(CompleteToDoParams(todoId: todoId));
// Assert
expect(result, isA<Left>());
verifyNever(mockTodoRepository.updateTodo(any));
verifyNever(mockUserRepository.incrementCompletedCount(any));
});
});
}
Anti-Pattern:
// ❌ BAD: Testing with real repositories
test('should save todo', () async {
final repository = TodoRepositoryImpl(firestore, localDb);
final useCase = SaveTodoUseCase(repository); // Real dependencies!
final result = await useCase(todo);
// This is an integration test, not a unit test
});
// ❌ BAD: Not verifying repository interactions
test('should complete todo', () async {
when(mockRepo.updateTodo(any)).thenAnswer((_) async => Right(todo));
final result = await useCase(params);
expect(result, isA<Right>());
// Missing: verify(mockRepo.updateTodo(any)).called(1);
});
Key Considerations:
- Use mockito's @GenerateMocks annotation
- Test both success and failure paths
- Verify the sequence of repository calls
- Use argThat for precise argument matching
- Keep tests focused on use case logic
Rule: Test Cubits with blocTest and mocked use cases
Explanation: Cubits manage UI state and should be tested using the bloc_test package. Mock use cases to control business logic outcomes and verify state transitions occur correctly.
Good Pattern:
// ✅ GOOD: Comprehensive Cubit testing with blocTest
@GenerateMocks([GetTodosUseCase, SaveTodoUseCase, DeleteTodoUseCase])
void main() {
group('TodoCubit', () {
late TodoCubit cubit;
late MockGetTodosUseCase mockGetTodos;
late MockSaveTodoUseCase mockSaveTodo;
late MockDeleteTodoUseCase mockDeleteTodo;
setUp(() {
mockGetTodos = MockGetTodosUseCase();
mockSaveTodo = MockSaveTodoUseCase();
mockDeleteTodo = MockDeleteTodoUseCase();
cubit = TodoCubit(
mockGetTodos,
mockSaveTodo,
mockDeleteTodo,
);
});
tearDown(() {
cubit.close();
});
blocTest<TodoCubit, TodoState>(
'emits [loading, loaded] when getTodos succeeds',
build: () {
when(mockGetTodos()).thenAnswer((_) async => Right(testTodos));
return cubit;
},
act: (cubit) => cubit.loadTodos(),
expect: () => [
const TodoState.loading(),
TodoState.loaded(testTodos),
],
);
blocTest<TodoCubit, TodoState>(
'emits [loading, error] when getTodos fails',
build: () {
when(mockGetTodos()).thenAnswer(
(_) async => Left(ServerFailure('Network error')),
);
return cubit;
},
act: (cubit) => cubit.loadTodos(),
expect: () => [
const TodoState.loading(),
const TodoState.error('Network error'),
],
);
blocTest<TodoCubit, TodoState>(
'adds optimistic todo then updates with server response',
build: () {
when(mockSaveTodo(any)).thenAnswer(
(_) async {
await Future.delayed(Duration(milliseconds: 100));
return Right(savedTodo);
},
);
return cubit;
},
seed: () => TodoState.loaded(existingTodos),
act: (cubit) => cubit.addTodo('New Todo'),
expect: () => [
// Optimistic update
TodoState.loaded([...existingTodos, optimisticTodo]),
// Server response
TodoState.loaded([...existingTodos, savedTodo]),
],
verify: (_) {
verify(mockSaveTodo(any)).called(1);
},
);
});
}
Anti-Pattern:
// ❌ BAD: Testing without blocTest
test('should emit loading state', () async {
final states = <TodoState>[];
cubit.stream.listen(states.add);
cubit.loadTodos();
await Future.delayed(Duration(milliseconds: 100));
expect(states, [/* ... */]); // Flaky timing
});
// ❌ BAD: Not mocking use cases
blocTest<TodoCubit, TodoState>(
'loads todos',
build: () => TodoCubit(
GetTodosUseCase(repository), // Real use case!
),
act: (cubit) => cubit.loadTodos(),
// This tests integration, not the cubit
);
Key Considerations:
- Always use blocTest for Cubit/Bloc testing
- Mock all use case dependencies
- Test state transitions, not implementation
- Use seed for testing from specific states
- Verify use case interactions when relevant
Rule: No Future.delayed in tests - use FakeAsync for time control
Explanation: Tests must be deterministic and fast. Future.delayed makes tests flaky and slow. Use FakeAsync to control time progression deterministically, ensuring reliable and quick test execution.
Good Pattern:
// ✅ GOOD: Using FakeAsync for deterministic time control
import 'package:fake_async/fake_async.dart';
test('should debounce search input', () {
fakeAsync((async) {
final cubit = SearchCubit(mockSearchUseCase);
final states = <SearchState>[];
cubit.stream.listen(states.add);
// Type multiple characters quickly
cubit.onSearchChanged('h');
cubit.onSearchChanged('he');
cubit.onSearchChanged('hel');
cubit.onSearchChanged('hell');
cubit.onSearchChanged('hello');
// No search triggered yet due to debounce
expect(states, isEmpty);
// Advance time by debounce duration
async.elapse(const Duration(milliseconds: 300));
// Now search should be triggered
expect(states, [
const SearchState.searching(),
const SearchState.results(['hello world']),
]);
verify(mockSearchUseCase('hello')).called(1);
});
});
// ✅ GOOD: Testing retry logic with FakeAsync
test('should retry failed requests with exponential backoff', () {
fakeAsync((async) {
int attemptCount = 0;
when(mockRepository.fetchData()).thenAnswer((_) async {
attemptCount++;
if (attemptCount < 3) {
throw NetworkException();
}
return testData;
});
final future = RetryService().fetchWithRetry(
() => mockRepository.fetchData(),
maxAttempts: 3,
);
// First attempt fails immediately
expect(attemptCount, 1);
// Advance time for first retry (1 second)
async.elapse(const Duration(seconds: 1));
expect(attemptCount, 2);
// Advance time for second retry (2 seconds)
async.elapse(const Duration(seconds: 2));
expect(attemptCount, 3);
// Verify successful result
expectLater(future, completion(equals(testData)));
});
});
// ✅ GOOD: Testing animation controllers
testWidgets('loading indicator fades in', (tester) async {
await tester.pumpWidget(const LoadingWidget());
// Initially transparent
final opacity = tester.widget<FadeTransition>(
find.byType(FadeTransition),
);
expect(opacity.opacity.value, 0.0);
// Pump frames to animate
await tester.pump(const Duration(milliseconds: 100));
expect(opacity.opacity.value, greaterThan(0.0));
// Complete animation
await tester.pumpAndSettle();
expect(opacity.opacity.value, 1.0);
});
Anti-Pattern:
// ❌ BAD: Using Future.delayed in tests
test('should timeout after 5 seconds', () async {
final future = serviceWithTimeout.fetchData();
await Future.delayed(const Duration(seconds: 5));
expect(future, throwsA(isA<TimeoutException>()));
// Slow test! Takes 5 real seconds
});
// ❌ BAD: Arbitrary delays to wait for async operations
test('should update after save', () async {
cubit.save(data);
await Future.delayed(Duration(milliseconds: 100)); // Flaky!
expect(cubit.state, SavedState());
});
// ❌ BAD: Testing periodic timers with real time
test('should poll every second', () async {
service.startPolling();
await Future.delayed(Duration(seconds: 3));
verify(mockApi.fetch()).called(3); // Flaky timing
});
Key Considerations:
- FakeAsync gives complete control over time
- Tests run instantly regardless of duration
- Deterministic behavior every run
- Essential for testing debouncing, throttling, timeouts
- Use tester.pump() for widget animations
Rule: Test file organization mirrors source structure
Explanation: Maintain a parallel test directory structure that mirrors your source code. This makes it easy to find tests, ensures nothing is missed, and establishes clear conventions for the team.
Good Pattern:
// ✅ GOOD: Test structure mirrors source structure
lib/
├── features/
│ └── todo/
│ ├── domain/
│ │ ├── entities/
│ │ │ └── todo_entity.dart
│ │ └── use_cases/
│ │ ├── get_todos.dart
│ │ └── save_todo.dart
│ ├── data/
│ │ └── repositories/
│ │ └── todo_repository_impl.dart
│ └── presentation/
│ └── cubits/
│ ├── todo_read_cubit.dart
│ └── todo_write_cubit.dart
test/
├── features/
│ └── todo/
│ ├── domain/
│ │ └── use_cases/
│ │ ├── get_todos_test.dart
│ │ └── save_todo_test.dart
│ ├── data/
│ │ └── repositories/
│ │ └── todo_repository_impl_test.dart
│ └── presentation/
│ └── cubits/
│ ├── todo_read_cubit_test.dart
│ └── todo_write_cubit_test.dart
// ✅ GOOD: Test helpers in parallel structure
test/
├── helpers/
│ ├── mock_helpers.dart // Shared mocks
│ ├── test_data.dart // Test fixtures
│ └── matchers.dart // Custom matchers
└── features/
└── todo/
└── helpers/
└── todo_test_helpers.dart // Feature-specific helpers
Anti-Pattern:
// ❌ BAD: Flat test structure
test/
├── todo_cubit_test.dart
├── user_repository_test.dart
├── save_todo_test.dart
└── all_tests.dart // Everything in one file!
// ❌ BAD: Inconsistent naming
test/
├── cubits/
│ └── todo_test.dart // Should be todo_cubit_test.dart
└── usecases/
└── save_todo_usecase_tests.dart // Should be save_todo_test.dart
// ❌ BAD: Missing test coverage
lib/features/todo/domain/use_cases/
├── get_todos.dart
├── save_todo.dart
└── delete_todo.dart // No corresponding test file!
Key Considerations:
- One test file per source file
- Test file names match source with _test suffix
- Group related test helpers
- Feature-specific test utilities stay local
- Makes missing tests obvious
- Simplifies test discovery and maintenance
Rule: Use Either<Failure, Success> for all operations
Explanation: Either type from dartz package provides functional error handling that forces you to handle both success and failure cases explicitly. This prevents runtime exceptions and makes error paths visible in the type system.
Good Pattern:
// ✅ GOOD: Either for explicit error handling
@injectable
class SaveTodo {
final TodoRepository _repository;
SaveTodo(this._repository);
Future<Either<Failure, TodoEntity>> call({
required String title,
required String description,
}) async {
// Validate input
if (title.trim().isEmpty) {
return Left(ValidationFailure('Title cannot be empty'));
}
// Perform operation
return await _repository.saveTodo(
title: title.trim(),
description: description.trim(),
);
}
}
// In Cubit
Future<void> saveTodo(String title, String description) async {
emit(const TodoWriteState.saving());
final result = await _saveTodo(
title: title,
description: description,
);
result.fold(
(failure) => emit(TodoWriteState.error(failure)),
(todo) => emit(TodoWriteState.saved(todo)),
);
}
// Repository implementation
@override
Future<Either<Failure, TodoEntity>> saveTodo({
required String title,
required String description,
}) async {
try {
final dto = await _dataSource.saveTodo(title, description);
return Right(dto.toEntity());
} on FirebaseException catch (e) {
return Left(ServerFailure(e.message ?? 'Failed to save'));
} on NetworkException {
return Left(NetworkFailure('No internet connection'));
} catch (e) {
return Left(UnknownFailure(e.toString()));
}
}
Anti-Pattern:
// ❌ BAD: Throwing exceptions
Future<TodoEntity> saveTodo(String title) async {
if (title.isEmpty) {
throw ValidationException('Title empty'); // Don't throw!
}
return await _repository.saveTodo(title); // Might throw!
}
// ❌ BAD: Nullable returns for errors
Future<TodoEntity?> saveTodo(String title) async {
try {
return await _repository.saveTodo(title);
} catch (e) {
print(e); // Error is lost!
return null; // Caller doesn't know why it failed
}
}
// ❌ BAD: Generic error types
Future<Either<String, TodoEntity>> saveTodo() async {
// String errors lack context and type safety
return Left('Something went wrong');
}
Key Considerations:
- Every async operation returns Either
- Define specific Failure types for different scenarios
- Use fold() to handle both paths
- Never throw exceptions in business logic
- Repository catches all exceptions and wraps in Failure
Rule: Display errors with GlobalSnackBarCubit, not just logs
Explanation: Users must be informed when operations fail. Logging errors helps developers debug, but users need visible feedback. GlobalSnackBarCubit provides a centralized way to show error messages consistently across the app.
Good Pattern:
// ✅ GOOD: User-visible error handling
// Global SnackBar Cubit
@injectable
class GlobalSnackBarCubit extends Cubit<GlobalSnackBarState> {
GlobalSnackBarCubit() : super(const GlobalSnackBarState.initial());
void showError(Failure failure, BuildContext context) {
emit(GlobalSnackBarState.show(
message: failure.getUserFriendlyMessage(context),
type: SnackBarType.error,
));
}
void showSuccess(String message) {
emit(GlobalSnackBarState.show(
message: message,
type: SnackBarType.success,
));
}
void dismiss() {
emit(const GlobalSnackBarState.initial());
}
}
// In app.dart
BlocListener<GlobalSnackBarCubit, GlobalSnackBarState>(
listener: (context, state) {
state.whenOrNull(
show: (message, type) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: type == SnackBarType.error
? Theme.of(context).colorScheme.error
: Theme.of(context).colorScheme.primary,
),
);
},
);
},
child: MaterialApp(...),
)
// In feature cubit
Future<void> deleteTodo(String id) async {
emit(const TodoWriteState.deleting());
final result = await _deleteTodo(id);
result.fold(
(failure) {
log('[TodoWriteCubit] Delete failed: $failure');
emit(TodoWriteState.error(failure));
// Also show to user!
_globalSnackBar.showError(failure, context);
},
(_) {
emit(const TodoWriteState.deleted());
_globalSnackBar.showSuccess('Todo deleted');
},
);
}
Anti-Pattern:
// ❌ BAD: Only logging errors
result.fold(
(failure) {
log('Error: $failure'); // User never sees this!
emit(ErrorState(failure));
},
(success) => emit(SuccessState(success)),
);
// ❌ BAD: Hardcoded error messages
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error occurred')), // Not helpful!
);
// ❌ BAD: Silent failures
try {
await doSomething();
} catch (e) {
// Error is swallowed, user thinks it worked!
}
Key Considerations:
- Always inform users of failures
- Log for developers AND show UI feedback
- Use centralized error display
- Provide actionable error messages
- Consider graceful degradation
Rule: User-friendly error messages with context.l10n
Explanation: Technical error messages confuse users. Translate exceptions into helpful, localized messages that guide users on what to do next. All user-facing text must be internationalized.
Good Pattern:
// ✅ GOOD: Failure types with user-friendly messages
sealed class Failure {
const Failure();
}
class NetworkFailure extends Failure {
const NetworkFailure();
}
class ServerFailure extends Failure {
final String? technicalMessage;
const ServerFailure([this.technicalMessage]);
}
class ValidationFailure extends Failure {
final String field;
final String reason;
const ValidationFailure(this.field, this.reason);
}
class PermissionFailure extends Failure {
final String permission;
const PermissionFailure(this.permission);
}
// Extension for user-friendly messages
extension FailureMessage on Failure {
String getUserFriendlyMessage(BuildContext context) {
return switch (this) {
NetworkFailure() => context.l10n.errorNoInternet,
ServerFailure() => context.l10n.errorServerProblem,
ValidationFailure(:final field, :final reason) => switch ((field, reason)) {
('email', 'invalid') => context.l10n.errorInvalidEmail,
('password', 'weak') => context.l10n.errorWeakPassword,
(_, _) => context.l10n.errorValidationGeneral,
},
PermissionFailure(:final permission) => switch (permission) {
'camera' => context.l10n.errorCameraPermission,
'storage' => context.l10n.errorStoragePermission,
_ => context.l10n.errorPermissionGeneral,
},
_ => context.l10n.errorUnknown,
};
}
// Developer info (for logs)
String get technicalDetails => switch (this) {
ServerFailure(:final technicalMessage) =>
'Server error: ${technicalMessage ?? 'Unknown'}',
NetworkFailure() => 'Network connection failed',
ValidationFailure(:final field, :final reason) =>
'Validation failed: $field - $reason',
PermissionFailure(:final permission) =>
'Permission denied: $permission',
_ => 'Unknown error: $runtimeType',
};
}
// In l10n/app_en.arb
{
"errorNoInternet": "No internet connection. Please check your network settings.",
"errorServerProblem": "Something went wrong on our end. Please try again later.",
"errorInvalidEmail": "Please enter a valid email address.",
"errorWeakPassword": "Password must be at least 8 characters with numbers and letters.",
"errorCameraPermission": "Camera access is needed to take photos. Enable it in Settings.",
"errorPermissionGeneral": "This feature requires additional permissions.",
"errorUnknown": "An unexpected error occurred. Please try again."
}
Anti-Pattern:
// ❌ BAD: Technical messages
return Left(Failure('FirebaseException: auth/user-not-found'));
// ❌ BAD: Hardcoded strings
showError('Error: Invalid credentials'); // Not localized!
// ❌ BAD: Generic unhelpful messages
context.l10n.errorGeneric; // "An error occurred"
// ❌ BAD: Exposing internals
showError(exception.toString()); // "Instance of '_TypeError'"
Key Considerations:
- Map technical errors to user actions
- Always use localization
- Provide guidance, not just error description
- Different messages for different failure types
- Keep technical details in logs only
Rule: Never hide errors - always inform users of failures
Explanation: When allowing apps to continue despite errors (graceful degradation), users must know functionality is limited. Hidden errors lead to confusion when features silently fail.
Good Pattern:
// ✅ GOOD: Graceful degradation with user notification
@injectable
class SubscriptionCubit extends Cubit<SubscriptionState> {
Future<void> initialize() async {
emit(const SubscriptionState.loading());
// Non-critical service - app works without it
final result = await _initializeRevenueCat();
result.fold(
(failure) {
log('[SubscriptionCubit] RevenueCat init failed: ${failure.technicalDetails}');
// Inform user but continue
emit(SubscriptionState.loadedWithWarning(
tier: SubscriptionTier.free, // Default to free
warning: failure.getUserFriendlyMessage(context),
));
// Show warning to user
_globalSnackBar.showWarning(
'Premium features unavailable. ${failure.getUserFriendlyMessage(context)}',
);
},
(customerInfo) => emit(SubscriptionState.loaded(
tier: _getTierFromCustomerInfo(customerInfo),
)),
);
}
}
// ✅ GOOD: Feature degradation with clear UI feedback
class PremiumFeatureButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<SubscriptionCubit, SubscriptionState>(
builder: (context, state) {
final isAvailable = state.maybeMap(
loaded: (_) => true,
loadedWithWarning: (_) => false, // Feature unavailable
orElse: () => false,
);
return ElevatedButton(
onPressed: isAvailable ? _onPressed : null,
child: Row(
children: [
Text('Export Data'),
if (!isAvailable) ...[
const SizedBox(width: 8),
Icon(
Icons.warning_amber,
size: 16,
color: Theme.of(context).colorScheme.error,
),
],
],
),
);
},
);
}
}
Anti-Pattern:
// ❌ BAD: Silent failures
Future<void> syncData() async {
try {
await _apiClient.sync();
} catch (e) {
// User has no idea sync failed!
log('Sync failed: $e');
}
}
// ❌ BAD: Hiding degraded functionality
if (subscriptionService.isAvailable) {
return PremiumButton();
} else {
return SizedBox.shrink(); // Feature just disappears!
}
// ❌ BAD: Continuing without informing user
final analyticsResult = await _initAnalytics();
if (analyticsResult.isLeft()) {
// Analytics broken but user doesn't know
// They might wonder why recommendations are bad
}
Key Considerations:
- Every error needs user visibility
- Degraded functionality must be indicated
- Use warning states for non-critical failures
- Disable features clearly, don't hide them
- Provide context about what's not working
Rule: Specific Failure types for different error scenarios
Explanation: Generic error types make it impossible to handle different failures appropriately. Specific failure types enable targeted error handling, better user messages, and appropriate recovery strategies.
Good Pattern:
// ✅ GOOD: Specific failure hierarchy
// Base failure class
sealed class Failure {
const Failure();
}
// Network-related failures
class NetworkFailure extends Failure {
const NetworkFailure();
}
class TimeoutFailure extends Failure {
final Duration timeout;
const TimeoutFailure(this.timeout);
}
// Server-related failures
class ServerFailure extends Failure {
final int? statusCode;
final String? message;
const ServerFailure({this.statusCode, this.message});
}
class MaintenanceFailure extends Failure {
final DateTime? estimatedEndTime;
const MaintenanceFailure({this.estimatedEndTime});
}
// Auth failures
class AuthFailure extends Failure {
const AuthFailure();
}
class InvalidCredentialsFailure extends AuthFailure {
const InvalidCredentialsFailure();
}
class TokenExpiredFailure extends AuthFailure {
const TokenExpiredFailure();
}
// Business logic failures
class ValidationFailure extends Failure {
final Map<String, String> fieldErrors;
const ValidationFailure(this.fieldErrors);
}
class QuotaExceededFailure extends Failure {
final int currentUsage;
final int limit;
final SubscriptionTier requiredTier;
const QuotaExceededFailure({
required this.currentUsage,
required this.limit,
required this.requiredTier,
});
}
// Usage in repository
@override
Future<Either<Failure, TodoEntity>> saveTodo(TodoDto dto) async {
try {
final response = await _apiClient.post('/todos', dto.toJson());
if (response.statusCode == 503) {
final maintenance = MaintenanceInfo.fromJson(response.data);
return Left(MaintenanceFailure(
estimatedEndTime: maintenance.estimatedEndTime,
));
}
if (response.statusCode == 402) {
final quota = QuotaInfo.fromJson(response.data);
return Left(QuotaExceededFailure(
currentUsage: quota.used,
limit: quota.limit,
requiredTier: quota.requiredTier,
));
}
if (response.statusCode != 200) {
return Left(ServerFailure(
statusCode: response.statusCode,
message: response.data['error'],
));
}
return Right(TodoDto.fromJson(response.data).toEntity());
} on DioException catch (e) {
if (e.type == DioExceptionType.connectionTimeout) {
return Left(TimeoutFailure(e.connectTimeout!));
}
if (e.type == DioExceptionType.connectionError) {
return Left(const NetworkFailure());
}
return Left(ServerFailure(message: e.message));
}
}
// Handling specific failures
void _handleFailure(Failure failure, BuildContext context) {
switch (failure) {
case QuotaExceededFailure(:final limit, :final requiredTier):
_showUpgradeDialog(
context,
message: 'You\'ve reached your limit of $limit todos',
requiredTier: requiredTier,
);
case MaintenanceFailure(:final estimatedEndTime):
_showMaintenanceBanner(
context,
estimatedEndTime: estimatedEndTime,
);
case TokenExpiredFailure():
_navigateToLogin(context);
case TimeoutFailure(:final timeout):
_showRetrySnackBar(
context,
message: 'Request timed out after ${timeout.inSeconds}s',
);
default:
_globalSnackBar.showError(failure, context);
}
}
Anti-Pattern:
// ❌ BAD: Generic failure
class AppError extends Failure {
final String message;
AppError(this.message);
}
// ❌ BAD: String-based errors
if (error.contains('network')) {
// Fragile string parsing
}
// ❌ BAD: Status code in UI
showError('Error 503'); // User doesn't know what this means
// ❌ BAD: Lost error context
catch (e) {
return Left(Failure()); // Which failure? Why?
}
Key Considerations:
- One failure type per error scenario
- Include relevant context in failures
- Enable different handling strategies
- Support rich error UI (retry, upgrade, etc.)
- Make recovery paths clear
Rule: flutter analyze must pass with ZERO issues
Explanation: Code quality is non-negotiable. flutter analyze
ensures consistent code style, catches potential bugs, and maintains best practices. Zero warnings policy prevents technical debt accumulation.
Good Pattern:
// ✅ GOOD: Clean analysis output
$ flutter analyze
Analyzing flutterexperience2...
No issues found! (ran in 2.1s)
// ✅ GOOD: Fixing issues immediately
// Before
class TodoScreen extends StatelessWidget {
final String Title; // analyzer: Name non-constant identifiers using lowerCamelCase
// After
class TodoScreen extends StatelessWidget {
final String title; // Fixed!
// ✅ GOOD: Using analysis_options.yaml
include: package:flutter_lints/flutter.yaml
analyzer:
errors:
# Treat these as errors, not warnings
prefer_const_constructors: error
always_use_package_imports: error
avoid_print: error
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
- "build/**"
linter:
rules:
# Additional strict rules
prefer_single_quotes: true
always_declare_return_types: true
prefer_final_locals: true
require_trailing_commas: true
Anti-Pattern:
// ❌ BAD: Ignoring analyzer warnings
$ flutter analyze
Analyzing flutterexperience2...
info • Prefer const with constant constructors • lib/main.dart:45:12
warning • Name non-constant identifiers using lowerCamelCase • lib/todo.dart:8:9
2 issues found. (ran in 2.1s)
// ❌ BAD: Suppressing warnings
// ignore: prefer_const_constructors
return Container(); // Don't suppress, fix it!
// ❌ BAD: Committing with analysis issues
git commit -m "feat: add todo feature" # With 5 analyzer warnings!
Key Considerations:
- Run before every commit
- Fix immediately, don't accumulate
- Use strict linting rules
- Never suppress warnings without team agreement
- Part of CI/CD pipeline
Rule: Run build_runner after model changes
Explanation: Code generation is essential for Freezed models, JSON serialization, and dependency injection. Forgetting to run build_runner leads to compilation errors and outdated generated code.
Good Pattern:
// ✅ GOOD: Run after any changes to:
// - @freezed classes
// - @JsonSerializable classes
// - @injectable services
// - @module definitions
dart run build_runner build --delete-conflicting-outputs
// ✅ GOOD: Watch mode during development
dart run build_runner watch --delete-conflicting-outputs
// ✅ GOOD: Add reminder comments
@freezed
class TodoEntity with _$TodoEntity {
/// IMPORTANT: Run build_runner after modifying this class
/// dart run build_runner build --delete-conflicting-outputs
const factory TodoEntity({
required String id,
required String title,
String? description,
}) = _TodoEntity;
}
// ✅ GOOD: Script for common tasks
// scripts/generate.sh
#!/bin/bash
echo "🔨 Running code generation..."
dart run build_runner build --delete-conflicting-outputs
echo "✅ Code generation complete"
Anti-Pattern:
// ❌ BAD: Forgetting to run build_runner
@freezed
class NewModel with _$NewModel {
const factory NewModel({String? data}) = _NewModel;
}
// Commit without generating .freezed.dart file!
// ❌ BAD: Editing generated files
// todo_entity.freezed.dart
// GENERATED CODE - DO NOT MODIFY BY HAND
map<TResult extends Object?>({...}) {
// DON'T EDIT THIS!
}
// ❌ BAD: Committing conflicting outputs
dart run build_runner build // Without --delete-conflicting-outputs
Key Considerations:
- Always use --delete-conflicting-outputs
- Run before committing model changes
- Never edit generated files
- Include in onboarding documentation
- Consider git hooks for automation
Rule: Only use flutter build to verify compilation
Explanation: As an AI assistant, use flutter build
to check for compilation errors without actually running the app. This ensures code correctness while respecting the boundary between development assistance and app execution.
Good Pattern:
// ✅ GOOD: Build to verify compilation
flutter build apk --debug # Android compilation check
flutter build ios --debug # iOS compilation check (on macOS)
flutter build web # Web compilation check
// ✅ GOOD: Using build to catch errors
$ flutter build apk --debug
Building with sound null safety
lib/main.dart:45:7: Error: The method 'runApp' isn't defined
^^^^^^^
// ✅ GOOD: Build-only workflow
1. Write code
2. Run flutter analyze
3. Run flutter build apk --debug
4. Fix any compilation errors
5. Commit changes
// ✅ GOOD: Explain to user
"I've verified the code compiles successfully with `flutter build`.
You can now run the app on your device/emulator to test the feature."
Anti-Pattern:
// ❌ BAD: Running the app as AI
flutter run # AI shouldn't execute apps
// ❌ BAD: Using hot reload
flutter run
R - Hot reload # AI can't interact with running apps
// ❌ BAD: Testing on devices/emulators
flutter run -d iPhone14 # AI doesn't have device access
Key Considerations:
- Build verifies syntax and type safety
- Catches compilation errors early
- Respects AI/human boundaries
- User handles actual app execution
- Part of pre-commit verification
Rule: Use conventional commits: type(scope): message
Explanation: Conventional commits provide a standardized format that enables automated changelog generation, clear communication of changes, and better project history navigation.
Good Pattern:
# ✅ GOOD: Well-formatted commits
git commit -m "feat(todo): add swipe to delete functionality"
git commit -m "fix(auth): resolve token refresh race condition"
git commit -m "refactor(user): extract profile update logic to use case"
git commit -m "docs(readme): update setup instructions for M1 Macs"
git commit -m "test(subscription): add unit tests for tier calculation"
git commit -m "style(home): adjust padding for tablet layout"
git commit -m "chore(deps): upgrade flutter_bloc to 8.1.3"
# ✅ GOOD: With breaking changes
git commit -m "feat(api)!: change todo endpoint response format
BREAKING CHANGE: The todo API now returns a paginated response
instead of a simple array. Update your API client to handle the
new format."
# ✅ GOOD: Multi-line with details
git commit -m "fix(subscription): prevent duplicate purchase attempts
- Add loading state during purchase flow
- Disable button while transaction pending
- Show clear error if purchase fails
- Add retry mechanism for network errors
Fixes #123"
Anti-Pattern:
# ❌ BAD: No type/scope
git commit -m "fixed bug"
git commit -m "updates"
git commit -m "WIP"
# ❌ BAD: Wrong format
git commit -m "Fix: authentication" # Should be fix(auth):
git commit -m "[TODO] - Add delete" # Non-standard format
# ❌ BAD: Unclear scope
git commit -m "feat(): add new feature" # What scope?
git commit -m "fix(app): fix issue" # Too generic
# ❌ BAD: Multiple changes in one commit
git commit -m "feat(todo): add CRUD operations and fix auth bug"
Common Types:
feat
: New featurefix
: Bug fixdocs
: Documentation onlystyle
: Formatting, missing semicolons, etc.refactor
: Code change that neither fixes a bug nor adds a featuretest
: Adding missing testschore
: Updating build tasks, package manager configs, etc.
Key Considerations:
- One logical change per commit
- Present tense, imperative mood
- Line length limits (50/72 rule)
- Reference issues when applicable
- Enables semantic versioning
Rule: Track tasks in project management tools
Explanation: Professional development requires proper task tracking. Use dedicated project management tools instead of ad-hoc methods. This ensures visibility, accountability, and progress tracking.
Good Pattern:
# ✅ GOOD: Using Linear/Jira/GitHub Projects
# Linear issue example
Title: "Implement offline mode for todos"
Status: In Progress
Labels: [feature, offline, p1]
Milestone: v2.0
Description: |
Add offline capability to todo feature:
- Cache todos in SQLite
- Queue operations when offline
- Sync when connection restored
# ✅ GOOD: Task breakdown
Epic: Offline Support
├── Task 1: Setup SQLite database [Done]
├── Task 2: Implement todo caching [In Progress]
├── Task 3: Create sync queue [Todo]
└── Task 4: Add conflict resolution [Todo]
# ✅ GOOD: Git integration
git commit -m "feat(todo): implement offline caching [ADA-123]"
# Links commit to Linear/Jira issue ADA-123
# ✅ GOOD: Sprint planning
Sprint 15 Goals:
- [ ] Complete offline mode (ADA-123)
- [ ] Fix subscription bugs (ADA-124, ADA-125)
- [ ] Update documentation (ADA-126)
Anti-Pattern:
# ❌ BAD: Using TODO comments as task tracking
// TODO: Fix this later
// FIXME: This is broken
// HACK: Temporary solution
# ❌ BAD: Local text files
my_tasks.txt:
- remember to fix login
- add dark mode maybe
- that weird bug john mentioned
# ❌ BAD: No task tracking
"I'll just remember what needs to be done"
# ❌ BAD: Mixing code and project management
bool get shouldImplementFeature => false; // TODO when sprint 16
Recommended Tools:
- Linear: Modern, fast, developer-focused
- Jira: Enterprise standard, rich features
- GitHub Projects: Integrated with code
- Notion: Flexible, good for documentation
- Asana/Monday: General project management
Key Considerations:
- Central source of truth
- Team visibility
- Progress tracking
- Integration with git
- Historical record
Rule: Define all features in PremiumFeature enum
Explanation: Centralizing premium feature definitions in a single enum ensures consistency, prevents duplication, and makes it easy to manage feature gates across the app. Each feature includes its tier requirement and UI content.
Good Pattern:
// ✅ GOOD: Comprehensive feature enum
enum PremiumFeature {
// Premium tier features
unlimitedTodos(
requiredTier: SubscriptionTier.premium,
icon: '🚀',
headlineKey: 'unlockFullPowerHeadline',
descriptionKey: 'unlimitedTodosDescription',
),
advancedThemes(
requiredTier: SubscriptionTier.premium,
icon: '✨',
headlineKey: 'levelUpYourStyleHeadline',
descriptionKey: 'advancedThemesDescription',
),
exportData(
requiredTier: SubscriptionTier.premium,
icon: '📤',
headlineKey: 'exportYourDataHeadline',
descriptionKey: 'exportDataDescription',
),
// VIP tier features
prioritySupport(
requiredTier: SubscriptionTier.vip,
icon: '👑',
headlineKey: 'vipExperienceHeadline',
descriptionKey: 'prioritySupportDescription',
),
aiAssistant(
requiredTier: SubscriptionTier.vip,
icon: '🤖',
headlineKey: 'aiPoweredHeadline',
descriptionKey: 'aiAssistantDescription',
),
exclusiveContent(
requiredTier: SubscriptionTier.vip,
icon: '💎',
headlineKey: 'exclusiveAccessHeadline',
descriptionKey: 'exclusiveContentDescription',
);
final SubscriptionTier requiredTier;
final String icon;
final String headlineKey;
final String descriptionKey;
const PremiumFeature({
required this.requiredTier,
required this.icon,
required this.headlineKey,
required this.descriptionKey,
});
// Helper to check if user has access
bool hasAccess(SubscriptionTier userTier) {
return userTier.index >= requiredTier.index;
}
// Get localized headline
String getHeadline(BuildContext context) {
return context.l10n.getString(headlineKey);
}
// Get localized description
String getDescription(BuildContext context) {
return context.l10n.getString(descriptionKey);
}
}
Anti-Pattern:
// ❌ BAD: Scattered feature checks
if (userTier == 'premium' || userTier == 'vip') {
// Feature available
}
// ❌ BAD: Hardcoded tier requirements
class ExportButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Tier requirement not centralized
if (subscription.tier.index >= 1) {
return ElevatedButton(...);
}
}
}
// ❌ BAD: Missing UI content
enum Feature {
export, // No icon, title, or description!
themes,
support,
}
Key Considerations:
- One source of truth for features
- Include all UI content in enum
- Use tier hierarchy (free < premium < vip)
- Localize all text
- Easy to add new features
Rule: Use PremiumWrapper for all gated features
Explanation: PremiumWrapper provides a consistent pattern for feature gating. It automatically handles subscription checks, shows upgrade dialogs, and ensures buttons always look the same regardless of access level.
Good Pattern:
// ✅ GOOD: Simple wrapper usage
PremiumWrapper(
feature: PremiumFeature.exportData,
action: () => _exportTodos(),
builder: (context, onPressed) => ElevatedButton.icon(
onPressed: onPressed, // Wrapper provides the callback
icon: const Icon(Icons.download),
label: Text(context.l10n.exportData),
),
)
// ✅ GOOD: Custom button styles
PremiumWrapper(
feature: PremiumFeature.advancedThemes,
action: () => _openThemeSelector(),
builder: (context, onPressed) => OutlinedButton(
onPressed: onPressed,
style: OutlinedButton.styleFrom(
side: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.palette),
const SizedBox(width: 8),
Text('Advanced Themes'),
],
),
),
)
// ✅ GOOD: Menu items
PopupMenuButton<String>(
itemBuilder: (context) => [
PopupMenuItem(
value: 'export',
child: PremiumWrapper(
feature: PremiumFeature.exportData,
action: () => _handleExport(),
builder: (context, onPressed) => ListTile(
onTap: onPressed,
leading: const Icon(Icons.download),
title: Text('Export Data'),
contentPadding: EdgeInsets.zero,
),
),
),
],
)
// ✅ GOOD: Implementation details
class PremiumWrapper extends StatelessWidget {
final PremiumFeature feature;
final VoidCallback action;
final Widget Function(BuildContext, VoidCallback?) builder;
const PremiumWrapper({
required this.feature,
required this.action,
required this.builder,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<SubscriptionCubit, SubscriptionState>(
builder: (context, state) {
final userTier = state.tier;
final hasAccess = feature.hasAccess(userTier);
return builder(
context,
hasAccess ? action : () => _showUpgradeDialog(context),
);
},
);
}
void _showUpgradeDialog(BuildContext context) {
showDialog(
context: context,
builder: (_) => PremiumUpgradeDialog(
feature: feature,
currentTier: context.read<SubscriptionCubit>().state.tier,
),
);
}
}
Anti-Pattern:
// ❌ BAD: Manual subscription checks
BlocBuilder<SubscriptionCubit, SubscriptionState>(
builder: (context, state) {
if (state.tier == SubscriptionTier.premium) {
return ElevatedButton(...);
} else {
return ElevatedButton(
onPressed: null, // Just disabling is confusing
child: Text('Export (Premium)'),
);
}
},
)
// ❌ BAD: Inconsistent gating
// Some features check tier
if (tier >= SubscriptionTier.premium) { }
// Others check specific features
if (hasExportFeature) { }
// Others check RevenueCat entitlements
if (entitlements.contains('export')) { }
// ❌ BAD: Hidden premium features
if (!isPremium) {
return SizedBox.shrink(); // User doesn't know it exists!
}
Key Considerations:
- Consistent UX across all features
- Buttons always visible
- Clear upgrade path
- Centralized dialog handling
- Works with any widget
Rule: Tier hierarchy: free < premium < vip
Explanation: Subscription tiers follow a strict hierarchy where higher tiers include all features from lower tiers. This simplifies access checks and provides clear upgrade paths.
Good Pattern:
// ✅ GOOD: Hierarchical tier enum
enum SubscriptionTier {
free, // index: 0 - Basic features
premium, // index: 1 - Includes free + premium features
vip; // index: 2 - Includes all features
// Check if this tier has access to another tier's features
bool hasAccessTo(SubscriptionTier requiredTier) {
return index >= requiredTier.index;
}
// Get tier from RevenueCat entitlement
static SubscriptionTier fromEntitlementId(String? id) {
return switch (id) {
'premium' => SubscriptionTier.premium,
'vip' => SubscriptionTier.vip,
_ => SubscriptionTier.free,
};
}
}
// ✅ GOOD: Tier capabilities
@freezed
class TierCapabilities with _$TierCapabilities {
const factory TierCapabilities.free() = FreeTier;
const factory TierCapabilities.premium() = PremiumTier;
const factory TierCapabilities.vip() = VipTier;
const TierCapabilities._();
// Shared across all tiers
int get maxFreeTodos => 5;
// Premium and VIP
int get maxTodos => map(
free: (_) => maxFreeTodos,
premium: (_) => 50,
vip: (_) => -1, // Unlimited
);
bool get canExportData => map(
free: (_) => false,
premium: (_) => true,
vip: (_) => true,
);
// VIP only
bool get hasAiAssistant => map(
free: (_) => false,
premium: (_) => false,
vip: (_) => true,
);
}
// ✅ GOOD: Clear upgrade messages
String getUpgradeMessage(
SubscriptionTier current,
SubscriptionTier required,
) {
if (current == SubscriptionTier.free && required == SubscriptionTier.premium) {
return 'Upgrade to Premium to unlock this feature';
} else if (required == SubscriptionTier.vip) {
return 'This exclusive feature requires VIP membership';
}
return 'Upgrade required';
}
Anti-Pattern:
// ❌ BAD: Non-hierarchical tiers
enum Tier {
basic,
pro,
business,
enterprise, // Too many tiers!
}
// ❌ BAD: Confusing tier names
enum Subscription {
free,
plus, // What's the difference
premium, // between these?
pro,
}
// ❌ BAD: Complex access logic
bool hasAccess(Feature feature, Tier tier) {
if (feature == Feature.export && tier == Tier.pro) return true;
if (feature == Feature.export && tier == Tier.business) return false; // ???
if (feature == Feature.themes && tier == Tier.plus) return true;
// Impossible to understand!
}
Key Considerations:
- Simple hierarchy: free < premium < vip
- Higher tiers include all lower features
- Use index for comparison
- Clear naming convention
- Maximum 3 tiers for simplicity
Rule: Handle subscription loading and error states
Explanation: Subscription services can fail or be slow to initialize. The app must gracefully handle these scenarios, providing feedback to users while maintaining core functionality.
Good Pattern:
// ✅ GOOD: Comprehensive subscription states
@freezed
sealed class SubscriptionState with _$SubscriptionState {
const factory SubscriptionState.initial() = Initial;
const factory SubscriptionState.loading() = Loading;
const factory SubscriptionState.loaded({
required SubscriptionTier tier,
required DateTime? expiresAt,
required bool isActive,
}) = Loaded;
const factory SubscriptionState.loadedWithWarning({
required SubscriptionTier tier,
required String warning,
}) = LoadedWithWarning;
const factory SubscriptionState.error(Failure failure) = Error;
const SubscriptionState._();
// Safe tier access with fallback
SubscriptionTier get tier => switch (this) {
Loaded(:final tier) => tier,
LoadedWithWarning(:final tier) => tier,
_ => SubscriptionTier.free, // Safe default
};
}
// ✅ GOOD: Resilient initialization
@injectable
class SubscriptionCubit extends Cubit<SubscriptionState> {
SubscriptionCubit() : super(const SubscriptionState.initial());
Future<void> initialize() async {
emit(const SubscriptionState.loading());
try {
// Don't block app startup
await Future.any([
_initializeRevenueCat(),
Future.delayed(const Duration(seconds: 5)),
]);
} catch (e) {
log('[SubscriptionCubit] Non-critical init error: $e');
emit(SubscriptionState.loadedWithWarning(
tier: SubscriptionTier.free,
warning: 'Premium features temporarily unavailable',
));
return;
}
}
Future<void> _initializeRevenueCat() async {
await Purchases.setLogLevel(LogLevel.error);
await Purchases.configure(
PurchasesConfiguration(apiKey)
..appUserID = userId,
);
final customerInfo = await Purchases.getCustomerInfo();
_handleCustomerInfo(customerInfo);
}
}
// ✅ GOOD: Feature degradation UI
class TodoListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<SubscriptionCubit, SubscriptionState>(
builder: (context, state) {
return switch (state) {
Loading() => _buildLoadingUI(),
Error(:final failure) => _buildErrorUI(failure),
LoadedWithWarning(:final warning) => Column(
children: [
_buildWarningBanner(warning),
_buildNormalUI(state.tier),
],
),
_ => _buildNormalUI(state.tier),
};
},
);
}
}
Anti-Pattern:
// ❌ BAD: Blocking app on subscription init
void main() async {
await RevenueCat.initialize(); // App hangs if this fails!
runApp(MyApp());
}
// ❌ BAD: No error handling
Future<void> loadSubscription() async {
final info = await Purchases.getCustomerInfo();
tier = info.entitlements.active.isNotEmpty
? SubscriptionTier.premium
: SubscriptionTier.free;
// What if getCustomerInfo throws?
}
// ❌ BAD: Silent failures
try {
await _loadSubscription();
} catch (e) {
// User has no idea something went wrong
}
Key Considerations:
- Non-blocking initialization
- Graceful degradation
- User-visible warnings
- Timeout handling
- Offline support
Rule: Show contextual upgrade dialogs with PremiumWrapper
Explanation: When users tap gated features, show contextual upgrade dialogs that explain the specific benefits of upgrading for that feature. This provides clear value proposition at the moment of interest.
Good Pattern:
// ✅ GOOD: Contextual upgrade dialog
class PremiumUpgradeDialog extends StatelessWidget {
final PremiumFeature feature;
final SubscriptionTier currentTier;
const PremiumUpgradeDialog({
required this.feature,
required this.currentTier,
});
@override
Widget build(BuildContext context) {
return Dialog(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Feature-specific icon
Text(
feature.icon,
style: const TextStyle(fontSize: 48),
),
const SizedBox(height: 16),
// Feature-specific headline
Text(
feature.getHeadline(context),
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
// Feature-specific description
Text(
feature.getDescription(context),
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Tier-specific benefits
_buildTierBenefits(context),
const SizedBox(height: 24),
// Action buttons
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.l10n.maybeLater),
),
ElevatedButton(
onPressed: () => _openPurchaseFlow(context),
child: Text(
feature.requiredTier == SubscriptionTier.vip
? context.l10n.upgradeToVip
: context.l10n.upgradeToPremium,
),
),
],
),
],
),
),
);
}
Widget _buildTierBenefits(BuildContext context) {
final benefits = feature.requiredTier == SubscriptionTier.vip
? [
'✓ ${context.l10n.unlimitedTodos}',
'✓ ${context.l10n.allThemes}',
'✓ ${context.l10n.exportFeatures}',
'✓ ${context.l10n.aiAssistant}',
'✓ ${context.l10n.prioritySupport}',
]
: [
'✓ ${context.l10n.upTo50Todos}',
'✓ ${context.l10n.premiumThemes}',
'✓ ${context.l10n.exportFeatures}',
];
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: benefits.map((benefit) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(benefit),
)).toList(),
),
);
}
}
// ✅ GOOD: Purchase flow handling
Future<void> _openPurchaseFlow(BuildContext context) async {
final scaffoldMessenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
try {
// Show loading
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(
child: CircularProgressIndicator(),
),
);
// Get offerings
final offerings = await Purchases.getOfferings();
final offering = feature.requiredTier == SubscriptionTier.vip
? offerings.getOffering('vip')
: offerings.getOffering('premium');
if (offering == null) {
throw PurchaseException('Offering not found');
}
// Make purchase
final result = await Purchases.purchasePackage(
offering.availablePackages.first,
);
// Success
navigator.pop(); // Close loading
navigator.pop(); // Close upgrade dialog
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(context.l10n.purchaseSuccessful),
backgroundColor: Colors.green,
),
);
// Update subscription state
context.read<SubscriptionCubit>().refreshSubscription();
} catch (e) {
navigator.pop(); // Close loading
if (e is PurchasesErrorCode) {
if (e == PurchasesErrorCode.purchaseCancelledError) {
return; // User cancelled, no error needed
}
}
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(context.l10n.purchaseFailed),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
Anti-Pattern:
// ❌ BAD: Generic upgrade message
showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text('Upgrade Required'),
content: Text('This is a premium feature'), // Not helpful!
actions: [
TextButton(
onPressed: () => launchUrl(upgradeUrl), // Leaves app!
child: Text('Upgrade'),
),
],
),
);
// ❌ BAD: No context about feature
Text('Subscribe to Premium for $4.99/month'); // Why should I?
// ❌ BAD: Aggressive upgrade prompts
Timer.periodic(Duration(hours: 1), (_) {
showUpgradeDialog(); // Annoying!
});
Key Considerations:
- Feature-specific messaging
- Clear value proposition
- Show benefits, not just price
- Handle purchase flow in-app
- Graceful error handling
Rule: Features follow domain/data/presentation layers
Explanation: Clean Architecture mandates clear separation of concerns through layered organization. Each feature contains three distinct layers that depend only on inner layers, ensuring testability and maintainability.
Good Pattern:
// ✅ GOOD: Complete feature structure
lib/
└── features/
└── todo/
├── domain/
│ ├── entities/
│ │ ├── todo_entity.dart
│ │ └── todo_list_entity.dart
│ ├── repositories/
│ │ └── todo_repository.dart // Abstract interface
│ └── use_cases/
│ ├── get_todos.dart
│ ├── save_todo.dart
│ ├── delete_todo.dart
│ └── toggle_todo.dart
├── data/
│ ├── repositories/
│ │ └── todo_repository_impl.dart
│ ├── data_sources/
│ │ ├── todo_remote_data_source.dart
│ │ └── todo_local_data_source.dart
│ ├── dtos/
│ │ ├── todo_dto.dart
│ │ └── firestore_todo_dto.dart
│ └── mappers/
│ └── todo_mapper.dart
└── presentation/
├── cubits/
│ ├── todo_read_cubit.dart
│ ├── todo_read_state.dart
│ ├── todo_write_cubit.dart
│ └── todo_write_state.dart
├── screens/
│ ├── todo_list_screen.dart
│ └── todo_detail_screen.dart
└── widgets/
├── todo_list_item.dart
├── todo_form.dart
└── todo_filter_bar.dart
// ✅ GOOD: Clear dependencies
// Domain layer - no dependencies
abstract interface class TodoRepository {
Future<Either<Failure, List<TodoEntity>>> getTodos();
}
// Data layer - depends on domain
@LazySingleton(as: TodoRepository)
class TodoRepositoryImpl implements TodoRepository {
final TodoRemoteDataSource _remoteDataSource;
final TodoLocalDataSource _localDataSource;
final TodoMapper _mapper;
}
// Presentation layer - depends on domain
@injectable
class TodoReadCubit extends Cubit<TodoReadState> {
final GetTodos _getTodos; // Use case from domain
}
Anti-Pattern:
// ❌ BAD: Mixed layers
lib/
└── features/
└── todo/
├── todo_screen.dart // UI mixed with logic
├── todo_service.dart // What layer is this?
├── todo_model.dart // Entity? DTO? Both?
└── todo_helper.dart // Vague utility file
// ❌ BAD: Wrong dependencies
// Presentation depending on data layer
class TodoScreen extends StatelessWidget {
final TodoRepositoryImpl repository; // Should use abstract!
}
// ❌ BAD: Domain depending on external packages
class TodoEntity {
@JsonKey(name: 'title') // Domain shouldn't know about JSON!
final String title;
}
Key Considerations:
- Domain is pure Dart, no external dependencies
- Data implements domain interfaces
- Presentation only uses domain layer
- Clear folder structure per feature
- Consistent naming within layers
Rule: One file per class, named after the class
Explanation: Following the single responsibility principle at the file level makes code easier to find, understand, and maintain. File names should exactly match the class they contain in snake_case.
Good Pattern:
// ✅ GOOD: One class per file
// File: todo_entity.dart
@freezed
abstract class TodoEntity with _$TodoEntity {
const factory TodoEntity({
required String id,
required String title,
String? description,
}) = _TodoEntity;
}
// File: todo_repository.dart
abstract interface class TodoRepository {
Future<Either<Failure, List<TodoEntity>>> getTodos();
Future<Either<Failure, TodoEntity>> saveTodo(TodoEntity todo);
}
// File: todo_repository_impl.dart
@LazySingleton(as: TodoRepository)
class TodoRepositoryImpl implements TodoRepository {
// Implementation
}
// File: get_todos.dart
@injectable
class GetTodos {
final TodoRepository _repository;
GetTodos(this._repository);
Future<Either<Failure, List<TodoEntity>>> call() async {
return await _repository.getTodos();
}
}
// ✅ GOOD: Related constants in dedicated file
// File: todo_constants.dart
class TodoConstants {
static const int maxTitleLength = 100;
static const int maxDescriptionLength = 500;
static const Duration syncInterval = Duration(minutes: 5);
}
Anti-Pattern:
// ❌ BAD: Multiple classes in one file
// File: todo_models.dart
class TodoEntity { }
class TodoDto { }
class TodoRepository { }
class TodoService { }
// ❌ BAD: Mismatched file names
// File: models.dart
class TodoEntity { } // Should be todo_entity.dart
// File: todo.dart
class TodoRepository { } // Should be todo_repository.dart
// ❌ BAD: Generic file names
// File: utils.dart
class TodoValidator { }
class TodoFormatter { }
class TodoHelper { }
Key Considerations:
- File name = class name in snake_case
- Easy to find classes
- Clear git history
- Better IDE navigation
- Exceptions: related enums/extensions can share
Rule: Core utilities go in lib/core/
Explanation: Shared utilities, constants, and cross-cutting concerns belong in the core directory. This prevents duplication and establishes a clear location for framework-level code.
Good Pattern:
// ✅ GOOD: Organized core structure
lib/
└── core/
├── di/ // Dependency injection
│ ├── injectable_module.dart // External dependencies
│ └── service_locator.dart // GetIt configuration
├── error/ // Error handling
│ ├── failures.dart // Failure types
│ └── exceptions.dart // Custom exceptions
├── network/ // Networking
│ ├── api_client.dart // Dio configuration
│ └── api_endpoints.dart // Endpoint constants
├── theme/ // Theming
│ ├── app_theme.dart // Theme configuration
│ ├── app_colors.dart // Color palette
│ └── app_text_styles.dart // Typography
├── utils/ // Utilities
│ ├── extensions/
│ │ ├── string_extensions.dart
│ │ └── date_extensions.dart
│ ├── validators/
│ │ ├── email_validator.dart
│ │ └── phone_validator.dart
│ └── constants/
│ ├── app_constants.dart
│ └── storage_keys.dart
├── widgets/ // Shared widgets
│ ├── loading_indicator.dart
│ ├── error_widget.dart
│ └── empty_state_widget.dart
└── language/ // Localization
├── app_localizations.dart
└── l10n/
├── app_en.arb
└── app_es.arb
// ✅ GOOD: Using core utilities
import 'package:myapp/core/error/failures.dart';
import 'package:myapp/core/utils/extensions/string_extensions.dart';
import 'package:myapp/core/widgets/loading_indicator.dart';
class TodoScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (state is Loading) {
return const LoadingIndicator(); // Shared widget
}
final trimmedTitle = title.trimAndCapitalize(); // Extension
}
}
Anti-Pattern:
// ❌ BAD: Utilities scattered in features
lib/
└── features/
├── todo/
│ └── utils/
│ └── string_utils.dart // Duplicated in each feature
└── user/
└── utils/
└── string_utils.dart // Same utilities copied!
// ❌ BAD: Core mixed with features
lib/
└── core/
├── todo_validator.dart // Feature-specific!
└── user_helper.dart // Should be in feature
// ❌ BAD: No organization in core
lib/
└── core/
├── stuff.dart
├── helpers.dart
├── utils.dart
└── misc.dart
Key Considerations:
- Only truly shared code in core
- Feature-specific code stays in features
- Clear subdirectory organization
- Avoid "util" grab bags
- Document when to use core vs feature
Rule: Test structure mirrors lib structure
Explanation: Maintaining parallel structures between source and test directories makes it easy to find tests, ensures complete coverage, and establishes clear testing conventions.
Good Pattern:
// ✅ GOOD: Parallel test structure
lib/
├── core/
│ └── utils/
│ └── validators/
│ └── email_validator.dart
└── features/
└── todo/
├── domain/
│ ├── entities/
│ │ └── todo_entity.dart
│ └── use_cases/
│ └── save_todo.dart
└── presentation/
└── cubits/
└── todo_write_cubit.dart
test/
├── core/
│ └── utils/
│ └── validators/
│ └── email_validator_test.dart
└── features/
└── todo/
├── domain/
│ ├── entities/
│ │ └── todo_entity_test.dart
│ └── use_cases/
│ └── save_todo_test.dart
└── presentation/
└── cubits/
└── todo_write_cubit_test.dart
// ✅ GOOD: Test helpers organization
test/
├── helpers/
│ ├── test_helper.dart // Global test utilities
│ └── mock_helper.dart // Shared mocks
└── features/
└── todo/
└── helpers/
├── todo_test_data.dart // Feature-specific test data
└── todo_mocks.dart // Feature-specific mocks
// ✅ GOOD: Naming convention
// Source: todo_repository_impl.dart
// Test: todo_repository_impl_test.dart
// Source: get_todos.dart
// Test: get_todos_test.dart
Anti-Pattern:
// ❌ BAD: Flat test structure
test/
├── todo_test.dart
├── user_test.dart
├── repository_test.dart
└── all_tests.dart // Everything in one file!
// ❌ BAD: Inconsistent organization
test/
├── unit/
│ └── todo_test.dart
├── widget/ // We don't write widget tests
│ └── todo_screen_test.dart
└── integration/ // We don't write integration tests
└── app_test.dart
// ❌ BAD: Missing tests obvious
lib/features/todo/domain/use_cases/
├── get_todos.dart ✓ has test
├── save_todo.dart ✓ has test
├── delete_todo.dart ✗ missing test!
└── update_todo.dart ✗ missing test!
Key Considerations:
- Same path in test as in lib
- _test.dart suffix for all tests
- Easy to spot missing tests
- Feature-specific test helpers
- Run tests by directory
Rule: Use relative imports for internal files
Explanation: Relative imports make features more portable, clarify dependencies, and prevent circular dependencies. Package imports should only be used for external dependencies.
Good Pattern:
// ✅ GOOD: Relative imports within feature
// File: lib/features/todo/presentation/screens/todo_list_screen.dart
import '../cubits/todo_read_cubit.dart';
import '../cubits/todo_read_state.dart';
import '../widgets/todo_list_item.dart';
import '../../domain/entities/todo_entity.dart';
// ✅ GOOD: Package imports for external deps
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:myapp/core/widgets/loading_indicator.dart';
// ✅ GOOD: Relative imports in same directory
// File: lib/features/todo/data/repositories/todo_repository_impl.dart
import '../data_sources/todo_remote_data_source.dart';
import '../data_sources/todo_local_data_source.dart';
import '../dtos/todo_dto.dart';
import '../mappers/todo_mapper.dart';
import '../../domain/repositories/todo_repository.dart';
// ✅ GOOD: Clear feature boundaries
// In todo feature, importing from user feature
import 'package:myapp/features/user/domain/entities/user_entity.dart';
// ✅ GOOD: Test imports
// File: test/features/todo/domain/use_cases/save_todo_test.dart
import '../../../../../lib/features/todo/domain/use_cases/save_todo.dart';
import '../../helpers/todo_test_data.dart';
Anti-Pattern:
// ❌ BAD: Package imports within feature
// File: lib/features/todo/presentation/screens/todo_list_screen.dart
import 'package:myapp/features/todo/presentation/cubits/todo_read_cubit.dart';
import 'package:myapp/features/todo/presentation/widgets/todo_list_item.dart';
// ❌ BAD: Absolute paths
import '/lib/features/todo/domain/entities/todo_entity.dart';
// ❌ BAD: Going up too many levels
import '../../../../../../../../core/error/failures.dart';
// ❌ BAD: Circular dependencies hidden by package imports
// File: feature_a/file.dart
import 'package:myapp/features/feature_b/some_file.dart';
// File: feature_b/some_file.dart
import 'package:myapp/features/feature_a/file.dart'; // Circular!
Key Considerations:
- Relative within features
- Package imports for cross-feature
- Makes dependencies visible
- Easier refactoring
- Prevents circular dependencies
This comprehensive guide provides detailed explanations and examples for all the rules defined in CLAUDE.md
. By following these patterns and avoiding the anti-patterns, you'll create a maintainable, scalable, and high-quality Flutter application.
- Architecture Matters: Clean Architecture with proper layering ensures long-term maintainability
- State Management: CQRS pattern with ReadCubits and WriteCubits provides clear data flow
- Code Quality: Zero tolerance for analyzer warnings maintains high standards
- Testing: Prioritize business logic testing with clean, deterministic tests
- Error Handling: Always inform users of failures with helpful messages
- Premium Features: Consistent gating pattern provides clear upgrade paths
- File Organization: Clear structure makes navigation and maintenance easier
- Follow the rules consistently across the codebase
- When in doubt, refer to the examples in this guide
- Keep the code clean, tested, and well-organized
- Always prioritize user experience and code maintainability
Happy coding! 🚀