name: 17th-dart-patterns
description: |
Flutter/Dart project development patterns and conventions.
Covers Clean Architecture, Cubit/BLoC, Result type, Testing, DI (GetIt), gRPC, Repository patterns.
Reference this skill before writing code to maintain consistent patterns.
Flutter/Dart Development Patterns
This document defines patterns and conventions for writing consistent Flutter/Dart code.
Uses Panther library for logging and error handling (company-wide shared package).
1. Clean Architecture
Layer Structure
lib/feature/<feature>/
├── domain/ # Core business logic (framework-agnostic)
│ ├── entity/ # Domain models, value objects
│ ├── model/ # Domain models (alternative naming)
│ └── repository/ # Repository contracts (abstract classes)
├── application/ # Use cases, business rules
│ ├── <action>_<entity>.dart # UseCase implementations
│ ├── model/ # Application-level models
│ └── port/ # Port interfaces for external services
├── data/ # Data access implementations
│ ├── repository/ # Repository implementations
│ ├── source/ # Data sources (remote, local)
│ └── mapper/ # DTO ↔ Domain conversion
└── presentation/ # UI layer
├── <screen>/
│ ├── bloc/ # Cubit/Bloc + State
│ └── view.dart # Screen widget
├── model/ # View models, UI-specific data
└── widget/ # Reusable widgets
Dependency Direction
presentation → application → domain ← data
↑
data (implements domain interfaces)
domain : No Flutter, gRPC, Firebase, or external framework dependencies
application : Only imports domain, no infrastructure packages
data : Implements domain interfaces, uses gRPC/external packages
presentation : Calls application, uses Flutter packages
Feature Dependency Registration
Each feature has a dependency.dart file in lib/feature/<feature>/:
// lib/feature/<feature>/dependency.dart
void register<Feature>() {
GetIt.I
// Application (Use Cases)
..registerFactory(() => Get<Entity>(GetIt.I()))
..registerFactory(() => Create<Entity>(GetIt.I()))
// Presentation (Cubits)
..registerFactory(
() => <Feature>Cubit(
GetIt.I<SomeUseCase>(),
),
)
// Data
..registerLazySingleton<<Entity>Repository>(
() => <Entity>RepositoryImpl(
GetIt.I(),
GetIt.I<GlobalLoggerProvider>(),
),
);
}
2. Result Type Pattern
Sealed Result Class
// lib/service/result/result.dart
sealed class Result<T> {
const Result();
Result<R> map<R>(R Function(T value) mapper) {
return switch (this) {
Success(:final value) => Success(mapper(value)),
Failure(:final error) => Failure(error),
};
}
R fold<R>({
required R Function(DomainError error) onFailure,
required R Function(T value) onSuccess,
}) {
return switch (this) {
Success(:final value) => onSuccess(value),
Failure(:final error) => onFailure(error),
};
}
T getOrThrow() {
return switch (this) {
Success(:final value) => value,
Failure(:final error) => throw error,
};
}
}
final class Success<T> extends Result<T> {
const Success(this.value);
final T value;
}
final class Failure<T> extends Result<T> {
const Failure(this.error);
final DomainError error;
}
Result Pattern Matching
// Switch expression pattern (preferred)
final result = await useCase(params, logger);
switch (result) {
case Success(:final value):
// Handle success
return value;
case Failure(:final error):
// Handle error
return null;
}
// Fold method pattern
result.fold(
onSuccess: (value) => Success(value),
onFailure: (error) => Failure(toDataException(error)),
);
3. UseCase Pattern
Application Type Definitions
// lib/service/type/application.dart
typedef ResultFuture<T> = Future<Result<T>>;
abstract class FutureApplicationWithParams<T, P> {
const FutureApplicationWithParams();
ResultFuture<T> call(P params, EventLogger logger);
}
abstract class FutureApplication<T> {
const FutureApplication();
ResultFuture<T> call(EventLogger logger);
}
abstract class StreamApplication<T> {
const StreamApplication();
Stream<T> call(EventLogger logger);
}
UseCase Implementation
// lib/feature/<feature>/application/<action>_<entity>.dart
class Get<Entity> extends FutureApplicationWithParams<<Entity>Result, <Entity>Params> {
const Get<Entity>(this._repository);
final <Entity>Repository _repository;
@override
ResultFuture<<Entity>Result> call(
<Entity>Params params,
EventLogger logger,
) async {
final context = loggerContextFromEventLogger(logger).child('Get<Entity>');
try {
final result = await _repository.get<Entity>(
context: context,
params: params,
);
return result.fold(
onSuccess: (value) {
if (!_isValidResult(value)) {
logger.error(
'Get<Entity> returned invalid payload',
StateError('Invalid Get<Entity> response'),
methodName: 'call',
className: 'Get<Entity>',
);
return const Failure(
DataException('Invalid response', AppStatus.invalidServerResponse),
);
}
return Success(value);
},
onFailure: (error) {
final dataError = toDataException(error);
logger.warning(
'Get<Entity> failed: ${dataError.message}',
methodName: 'call',
className: 'Get<Entity>',
);
return Failure(dataError);
},
);
} on Exception catch (error, stackTrace) {
logger.error(
'Unexpected error while calling Get<Entity>',
error,
methodName: 'call',
className: 'Get<Entity>',
stackTrace: stackTrace,
);
return Failure(
DataException(
'Unexpected error: $error',
AppStatus.unknownError,
error: error,
stackTrace: stackTrace,
),
);
}
}
}
4. Cubit/BLoC Pattern
State Definition (using part of)
// lib/feature/<feature>/presentation/<screen>/bloc/state.dart
part of 'cubit.dart';
class <Feature><Screen>State extends Equatable {
const <Feature><Screen>State({
required this.status,
required this.isLoading,
this.data,
this.errorKind,
});
factory <Feature><Screen>State.initial() => const <Feature><Screen>State(
status: LoadStatus.initial,
isLoading: false,
);
final LoadStatus status;
final bool isLoading;
final <Entity>? data;
final <Feature>ErrorKind? errorKind;
bool get hasData => data != null;
// Sentinel pattern for nullable fields in copyWith
static const _errorSentinel = Object();
<Feature><Screen>State copyWith({
LoadStatus? status,
bool? isLoading,
<Entity>? data,
Object? errorKind = _errorSentinel,
bool clearData = false,
}) {
return <Feature><Screen>State(
status: status ?? this.status,
isLoading: isLoading ?? this.isLoading,
data: clearData ? null : (data ?? this.data),
errorKind: identical(errorKind, _errorSentinel)
? this.errorKind
: errorKind as <Feature>ErrorKind?,
);
}
@override
List<Object?> get props => [status, isLoading, data, errorKind];
}
Cubit Implementation
// lib/feature/<feature>/presentation/<screen>/bloc/cubit.dart
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:panther/panther_logger.dart';
import 'package:panther/panther_util.dart';
part 'state.dart';
class <Feature><Screen>Cubit extends Cubit<<Feature><Screen>State>
with PantherBlocMixin<<Feature><Screen>State> {
<Feature><Screen>Cubit(
this._getEntity,
) : super(<Feature><Screen>State.initial());
final Get<Entity> _getEntity;
Future<void> load(<Entity>Params params) async {
emit(state.copyWith(isLoading: true, errorKind: null));
await withEventLogger(id, '<Feature>Load', (logger) async {
final result = await _getEntity(params, logger);
switch (result) {
case Success(:final value):
emit(
state.copyWith(
data: value,
isLoading: false,
status: LoadStatus.success,
),
);
case Failure(:final error):
emit(
state.copyWith(
clearData: true,
isLoading: false,
status: LoadStatus.failure,
errorKind: mapError(error),
),
);
}
});
}
}
Logger Mixin Pattern (Panther)
// Use PantherBlocMixin for logging in Cubits
class <Feature>Cubit extends Cubit<<Feature>State>
with PantherBlocMixin<<Feature>State> {
// ...
// Use withEventLogger for async operations
await withEventLogger(id, 'OperationName', (logger) async {
// logger is automatically scoped with cubit id
});
}
5. Repository Pattern
Domain Repository Contract
// lib/feature/<feature>/domain/repository/<entity>_repository.dart
abstract class <Entity>Repository {
Future<Result<<Entity>>> get<Entity>({
required LoggerContext context,
required <Entity>Params params,
});
Future<Result<<Entity>>> create<Entity>({
required LoggerContext context,
required Create<Entity>Params params,
});
Future<Result<void>> delete<Entity>({
required LoggerContext context,
required Delete<Entity>Params params,
});
}
Repository Implementation
// lib/feature/<feature>/data/repository/<entity>_repository_impl.dart
class <Entity>RepositoryImpl implements <Entity>Repository {
<Entity>RepositoryImpl(
this._remoteDataSource,
this._loggerProvider,
);
final <Entity>RemoteDataSource _remoteDataSource;
final GlobalLoggerProvider _loggerProvider;
@override
Future<Result<<Entity>>> get<Entity>({
required LoggerContext context,
required <Entity>Params params,
}) => withEventLoggerFromContext(
loggerProvider: _loggerProvider,
context: context.child('<Entity>Repository.get<Entity>'),
action: (logger) => _remoteDataSource.get<Entity>(
logger: logger,
params: params,
),
);
}
6. Testing Patterns
UseCase Unit Tests
void main() {
late Mock<Entity>Repository mockRepository;
late MockEventLogger mockLogger;
late Get<Entity> useCase;
const params = <Entity>Params(id: 'uuid');
const validResult = <Entity>(id: 'uuid', name: 'Test');
setUp(() {
mockRepository = Mock<Entity>Repository();
mockLogger = MockEventLogger();
setupMockEventLogger(mockLogger);
useCase = Get<Entity>(mockRepository);
provideDummy<Result<<Entity>>>(const Success(validResult));
});
test('returns success when repository returns valid result', () async {
when(
mockRepository.get<Entity>(
context: anyNamed('context'),
params: anyNamed('params'),
),
).thenAnswer((_) async => const Success(validResult));
final result = await useCase(params, mockLogger);
expect(result, isA<Success<<Entity>>>());
expect((result as Success<<Entity>>).value, validResult);
});
test('converts DomainError to DataException failure', () async {
const domainError = DomainError('not found', AppStatus.dbNotFound);
when(
mockRepository.get<Entity>(
context: anyNamed('context'),
params: anyNamed('params'),
),
).thenAnswer((_) async => const Failure(domainError));
final result = await useCase(params, mockLogger);
expect(result, isA<Failure<<Entity>>>());
final error = (result as Failure<<Entity>>).error as DataException;
expect(error.statusCode, AppStatus.dbNotFound);
});
}
Cubit Tests with Stub Pattern
class _StubGet<Entity> extends Get<Entity> {
_StubGet<Entity>(this._result) : super(Mock<Entity>Repository());
Result<<Entity>> _result;
int callCount = 0;
@override
Future<Result<<Entity>>> call(<Entity>Params params, EventLogger logger) async {
callCount += 1;
return _result;
}
set result(Result<<Entity>> value) => _result = value;
}
void main() {
group('<Feature>Cubit', () {
late _StubGet<Entity> getEntity;
late <Feature>Cubit cubit;
late MockGlobalLoggerProvider loggerProvider;
late MockEventLogger eventLogger;
setUp(() async {
await GetIt.I.reset();
loggerProvider = MockGlobalLoggerProvider();
eventLogger = MockEventLogger();
when(loggerProvider.event(any, any, eventId: anyNamed('eventId')))
.thenReturn(eventLogger);
setupMockEventLogger(eventLogger);
GetIt.I.registerSingleton<GlobalLoggerProvider>(loggerProvider);
getEntity = _StubGet<Entity>(const Success(testEntity));
cubit = <Feature>Cubit(getEntity);
});
tearDown(() async {
if (!cubit.isClosed) {
await cubit.close();
}
await GetIt.I.reset();
});
test('emits data on successful load', () async {
await cubit.load(params);
expect(cubit.state.data, testEntity);
expect(cubit.state.errorKind, isNull);
expect(getEntity.callCount, 1);
});
});
}
Mock Definitions (centralized)
// test/test_util/mock_definitions.dart
@GenerateMocks([
<Entity>Repository,
EventLogger,
GlobalLoggerProvider,
// ... other interfaces
])
void main() {}
// Generate with: dart run build_runner build
7. Dependency Injection (GetIt)
Global Initialization
// lib/infrastructure/dependency_injector/initialize.dart
Future<void> initDependencies() async {
// 1. Initialize Firebase
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// 2. Initialize external services
final firebaseAuth = FirebaseAuth.instance;
// 3. Initialize logging (Panther)
final logManager = FileLogManager(fileManagerParams);
await logManager.init();
// 4. Initialize SharedPreferences
final sharedPreferences = await SharedPreferences.getInstance();
// 5. Register global singletons
GetIt.I
..registerLazySingleton<FileLogManager>(() => logManager)
..registerLazySingleton<GlobalLoggerProvider>(
() => GlobalLoggerProvider.init(GetIt.I(), logLevel: logLevel),
)
..registerLazySingleton(() => firebaseAuth)
..registerLazySingleton(() => sharedPreferences);
// 6. Register gRPC infrastructure
GetIt.I
..registerLazySingleton<RequestIdProvider>(RequestIdProvider.new)
..registerLazySingletonAsync<ClientChannel>(
GrpcClientChannelFactory.create,
);
await GetIt.I.isReady<ClientChannel>();
// 7. Register features
registerAuthentication();
registerFeatureA();
registerFeatureB();
// ... other features
}
Registration Patterns
// Singleton - shared instance
GetIt.I.registerLazySingleton<<Entity>Repository>(
() => <Entity>RepositoryImpl(GetIt.I(), GetIt.I<GlobalLoggerProvider>()),
);
// Factory - new instance each time
GetIt.I.registerFactory(() => Get<Entity>(GetIt.I()));
// Factory with explicit type
GetIt.I.registerFactory<Validate<Entity>Input>(Validate<Entity>Input.new);
// Async singleton
GetIt.I.registerLazySingletonAsync<ClientChannel>(
GrpcClientChannelFactory.create,
);
await GetIt.I.isReady<ClientChannel>();
8. Naming Conventions
File Names
Type
Pattern
Example
UseCase
<action>_<entity>.dart
get_user.dart, create_order.dart
Repository Interface
<entity>_repository.dart
user_repository.dart
Repository Impl
<entity>_repository_impl.dart
user_repository_impl.dart
Cubit
cubit.dart (in bloc folder)
bloc/cubit.dart
State
state.dart (part of cubit)
bloc/state.dart
View
view.dart
view.dart
Test
<original>_test.dart
get_user_test.dart
Dependency
dependency.dart
dependency.dart
Mapper
<entity>_mapper.dart
user_status_mapper.dart
Class Names
Type
Pattern
Example
UseCase
<Action><Entity>
GetUser, CreateOrder
Repository (Interface)
<Entity>Repository
UserRepository
Repository (Impl)
<Entity>RepositoryImpl
UserRepositoryImpl
Cubit
<Feature><Screen>Cubit
UserDetailCubit
State
<Feature><Screen>State
UserDetailState
DataSource
<Entity>DataSource
UserRemoteDataSource
Mapper
map<From>To<To> (function)
mapUserStatusToProto
Folder Names
Type
Pattern
Example
Feature
snake_case, singular
user, order, authentication
Screen subfolder
snake_case
detail, list, create
Bloc subfolder
bloc/
detail/bloc/
9. Domain Models
Entity with Factory Constructor
// lib/feature/<feature>/domain/entity/<entity>_params.dart
class <Entity>Params {
const <Entity>Params({
required this.id,
this.includeDetails = false,
});
final String id;
final bool includeDetails;
}
Immutable Models with Equatable
import 'package:equatable/equatable.dart';
class <Entity> extends Equatable {
const <Entity>({
required this.id,
required this.name,
required this.status,
});
final String id;
final String name;
final <Entity>Status status;
@override
List<Object?> get props => [id, name, status];
}
10. gRPC Integration
gRPC Client Wrapper
// lib/infrastructure/grpc/client/<entity>_grpc_client.dart
class <Entity>GrpcClient {
<Entity>GrpcClient(
this._channel,
this._requestIdInterceptor,
this._authInterceptor,
this._testHeaderInterceptor,
);
final ClientChannel _channel;
final RequestIdInterceptor _requestIdInterceptor;
final AuthInterceptor _authInterceptor;
final TestHeaderInterceptor _testHeaderInterceptor;
<Entity>ServiceClient get client => <Entity>ServiceClient(
_channel,
interceptors: [
_requestIdInterceptor,
_authInterceptor,
_testHeaderInterceptor,
],
);
}
Service Facade Pattern
// lib/infrastructure/grpc/<entity>/<entity>_service_facade.dart
class <Entity>ServiceFacade {
<Entity>ServiceFacade(this._client, this._requestIdProvider);
final <Entity>GrpcClient _client;
final RequestIdProvider _requestIdProvider;
Future<Get<Entity>Response> get<Entity>(Get<Entity>Request request) async {
return _client.client.get<Entity>(request);
}
}
Data Source Implementation
// lib/feature/<feature>/data/source/<entity>_remote_data_source.dart
abstract class <Entity>RemoteDataSource {
Future<Result<<Entity>>> get<Entity>({
required EventLogger logger,
required <Entity>Params params,
});
}
class <Entity>RemoteDataSourceImpl implements <Entity>RemoteDataSource {
<Entity>RemoteDataSourceImpl(this._facade);
final <Entity>ServiceFacade _facade;
@override
Future<Result<<Entity>>> get<Entity>({
required EventLogger logger,
required <Entity>Params params,
}) async {
try {
final request = Get<Entity>Request()
..id = params.id;
final response = await _facade.get<Entity>(request);
return Success(<Entity>(
id: response.id,
name: response.name,
status: map<Entity>Status(response.status),
));
} on GrpcError catch (e) {
return Failure(mapGrpcError(e));
}
}
}
11. Service Layer (Hexagonal Architecture)
Service Structure
lib/service/
├── authentication/ # Cross-cutting auth service
│ ├── port/ # Inbound ports (interfaces)
│ │ ├── auth_status_port.dart
│ │ └── user_id_provider.dart
│ ├── transport/ # Outbound adapters (implementations)
│ │ ├── auth_stream_transport.dart
│ │ └── firebase_auth_stream_transport.dart
│ ├── application/ # Application services
│ │ └── facade/ # Port implementations
│ ├── manager/ # State managers
│ ├── model/ # Service models
│ └── dependency.dart # DI registration
├── result/ # Result type utilities
├── logger/ # Logging utilities (Panther integration)
├── request_id/ # Request ID generation
├── retry/ # Retry policies
└── type/ # Shared type definitions
Port Interface Pattern
// lib/service/<service>/port/<service>_port.dart
/// Application-facing port for <service>.
abstract class <Service>Port {
const <Service>Port();
/// Starts the service.
ResultFuture<void> attach(EventLogger logger);
/// Returns event stream.
ResultStream<<Service>Event> stream(LoggerContext context);
/// Stops the service and cleans up resources.
ResultFuture<void> release(EventLogger logger);
/// Returns the latest cached snapshot, if any.
<Service>Snapshot? getSnapshot();
}
Transport Interface Pattern
// lib/service/<service>/transport/<service>_transport.dart
/// Transport abstraction for <service>.
abstract class <Service>Transport {
const <Service>Transport();
/// Starts streaming transport-level events.
Stream<Transport<Service>Event> stream({required StreamLogger logger});
/// Cancels the active stream, if any.
Future<void> cancel({required StreamLogger logger});
}
sealed class Transport<Service>Event extends Equatable {
const Transport<Service>Event();
}
class Transport<Service>Connected extends Transport<Service>Event {
const Transport<Service>Connected(this.data);
final Transport<Service>Data data;
@override
List<Object?> get props => [data];
}
class Transport<Service>Disconnected extends Transport<Service>Event {
const Transport<Service>Disconnected();
@override
List<Object?> get props => [];
}
Facade Implementation Pattern
// lib/service/<service>/application/facade/<service>_facade.dart
class <Service>Facade implements <Service>Port {
<Service>Facade(this._manager, this._loggerProvider);
final <Service>Manager _manager;
final GlobalLoggerProvider _loggerProvider;
@override
ResultFuture<void> attach(EventLogger logger) {
logger.debug(
'Attaching <service> stream',
methodName: 'attach',
className: '<Service>Facade',
);
final streamLogger = _loggerProvider.stream('<Service>Facade::Stream');
return _manager.attach(logger: streamLogger);
}
@override
ResultStream<<Service>Event> stream(LoggerContext context) {
return _manager.stream(context);
}
}
Port vs Transport
Layer
Purpose
Example
Port
Application-facing contract
AuthStatusPort
Transport
Infrastructure adapter contract
AuthStreamTransport
Facade
Port implementation using Manager
AuthStatusFacade
Manager
Orchestrates transport + state
AuthStreamManager
12. Common Presentation Layer
Structure
lib/common/presentation/
├── asset/ # Shared asset references
│ └── app_illustrations.dart
├── bloc/ # Common BLoC utilities
│ └── safe_emit_mixin.dart
├── theme/ # App theming
│ ├── app_colors.dart # Semantic colors (ThemeExtension)
│ ├── app_gradients.dart # Gradient definitions
│ ├── app_radius.dart # Border radius tokens
│ ├── app_shadows.dart # Shadow definitions
│ ├── app_spacing.dart # Spacing tokens
│ └── app_theme.dart # Theme configuration
├── util/ # Presentation utilities
└── widget/ # Reusable widgets
ThemeExtension Pattern
// lib/common/presentation/theme/app_colors.dart
@immutable
class AppColors extends ThemeExtension<AppColors> {
const AppColors({
required this.success,
required this.onSuccess,
required this.successContainer,
required this.onSuccessContainer,
required this.info,
required this.onInfo,
required this.infoContainer,
required this.onInfoContainer,
required this.warning,
required this.onWarning,
required this.warningContainer,
required this.onWarningContainer,
});
final Color success;
final Color onSuccess;
final Color successContainer;
final Color onSuccessContainer;
// ... other colors
@override
AppColors copyWith({...}) { ... }
@override
AppColors lerp(ThemeExtension<AppColors>? other, double t) { ... }
/// Light theme semantic colors
static const AppColors light = AppColors(
success: Color(0xFF22C55E),
onSuccess: Color(0xFFFFFFFF),
successContainer: Color(0xFFDCFCE7),
onSuccessContainer: Color(0xFF166534),
// ...
);
/// Dark theme semantic colors
static const AppColors dark = AppColors(
success: Color(0xFF4ADE80),
onSuccess: Color(0xFF166534),
// ...
);
}
Using ThemeExtension in Widgets
// Access semantic colors
final appColors = Theme.of(context).extension<AppColors>()!;
Container(
color: appColors.successContainer,
child: Text(
'Success!',
style: TextStyle(color: appColors.onSuccessContainer),
),
)
Design Token Classes
// lib/common/presentation/theme/app_spacing.dart
abstract class AppSpacing {
static const double xs = 4.0;
static const double sm = 8.0;
static const double md = 16.0;
static const double lg = 24.0;
static const double xl = 32.0;
}
// lib/common/presentation/theme/app_radius.dart
abstract class AppRadius {
static const double sm = 8.0;
static const double md = 12.0;
static const double lg = 16.0;
static const double full = 9999.0;
}
Quick Reference Checklist
Adding New Feature
lib/feature/<name>/domain/entity/ - Define domain models
lib/feature/<name>/domain/repository/ - Repository contracts
lib/feature/<name>/application/ - Define UseCases
lib/feature/<name>/data/source/ - Data sources
lib/feature/<name>/data/repository/ - Repository implementations
lib/feature/<name>/presentation/ - Cubits and views
lib/feature/<name>/dependency.dart - DI registration
Add register<Feature>() to initDependencies()
Add tests mirroring lib structure
Adding New UseCase
Define Params class in domain/entity/
Extend appropriate FutureApplication* type
Inject Repository via constructor
Use Result pattern for returns
Add proper logging with context (Panther)
Handle all error cases
Register in dependency.dart
Write unit tests
Adding New Cubit
Create bloc/ subfolder
Create state.dart with part of 'cubit.dart'
Extend Cubit<State> with PantherBlocMixin
Define initial() factory for state
Use Equatable for state
Implement copyWith with sentinel pattern
Use withEventLogger for async operations
Register as factory in dependency.dart
Adding New Service (Hexagonal)
Create lib/service/<name>/ folder
Define port interface in port/<name>_port.dart
Define transport interface in transport/<name>_transport.dart
Implement transport (e.g., Firebase, gRPC) in transport/
Create manager in manager/ for state orchestration
Implement facade in application/facade/
Define models in model/
Register in dependency.dart
Adding Common Widget
Create in lib/common/presentation/widget/
Use design tokens from theme/ (AppSpacing, AppRadius, etc.)
Access semantic colors via Theme.of(context).extension<AppColors>()
Keep widget stateless when possible
Document public API with dartdoc comments