Patrones de Dart/Flutter
Cuándo usar
Usa este skill cuando:
- Inicies una nueva funcionalidad en Flutter y necesites patrones idiomáticos para gestión de estado, navegación o acceso a datos.
- Estés revisando o escribiendo código Dart y necesites orientación sobre seguridad nula, tipos sellados o composición asíncrona.
- Estés configurando un nuevo proyecto Flutter y decidiendo entre BLoC, Riverpod o Provider.
- Implementes clientes HTTP seguros, integración con WebView o almacenamiento local.
- Escribas tests para widgets Flutter, Cubits o providers de Riverpod.
- Estés conectando GoRouter con guardias de autenticación.
Cómo funciona
Este skill proporciona patrones de código Dart/Flutter listos para copiar y pegar, organizados por tema:
- Seguridad nula: evitar
!, preferir?./??/pattern matching. - Estado inmutable: clases selladas,
freezed,copyWith. - Composición asíncrona:
Future.waitconcurrente,BuildContextseguro después deawait. - Arquitectura de widgets: extraer a clases (no a métodos), propagación de
const, reconstrucciones acotadas. - Gestión de estado: eventos BLoC/Cubit, notificadores y providers derivados de Riverpod.
- Navegación: GoRouter con guardias de autenticación reactivas mediante
refreshListenable. - Redes: Dio con interceptores, renovación de tokens con protección de reintento único.
- Manejo de errores: captura global,
ErrorWidget.builder, integración con crashlytics. - Testing: unitario (BLoC test), de widgets (anulaciones de ProviderScope), fakes en lugar de mocks.
Ejemplos
// Estado sellado — previene estados imposibles
sealed class AsyncState<T> {}
final class Loading<T> extends AsyncState<T> {}
final class Success<T> extends AsyncState<T> { final T data; const Success(this.data); }
final class Failure<T> extends AsyncState<T> { final Object error; const Failure(this.error); }
// GoRouter con redirección reactiva de autenticación
final router = GoRouter(
refreshListenable: GoRouterRefreshStream(authCubit.stream),
redirect: (context, state) {
final authed = context.read<AuthCubit>().state is AuthAuthenticated;
if (!authed && !state.matchedLocation.startsWith('/login')) return '/login';
return null;
},
routes: [...],
);
// Provider derivado de Riverpod con firstWhereOrNull seguro
@riverpod
double cartTotal(Ref ref) {
final cart = ref.watch(cartNotifierProvider);
final products = ref.watch(productsProvider).valueOrNull ?? [];
return cart.fold(0.0, (total, item) {
final product = products.firstWhereOrNull((p) => p.id == item.productId);
return total + (product?.price ?? 0) * item.quantity;
});
}
Patrones prácticos y listos para producción para aplicaciones Dart y Flutter. Agnóstico en cuanto a librerías cuando es posible, con cobertura explícita de los paquetes más comunes del ecosistema.
1. Fundamentos de Seguridad Nula
Preferir Patrones sobre el Operador Bang
// MALO — falla en tiempo de ejecución si es nulo
final name = user!.name;
// BUENO — proporciona valor por defecto
final name = user?.name ?? 'Desconocido';
// BUENO — pattern matching de Dart 3 (preferido para casos complejos)
final display = switch (user) {
User(:final name, :final email) => '$name <$email>',
null => 'Invitado',
};
// BUENO — retorno temprano con guardia
String getUserName(User? user) {
if (user == null) return 'Desconocido';
return user.name; // promovido a no nulo después de la comprobación
}
Evitar el Uso Excesivo de late
// MALO — difiere el error nulo al tiempo de ejecución
late String userId;
// BUENO — anulable con inicialización explícita
String? userId;
// ACEPTABLE — usar late solo cuando la inicialización está garantizada antes del primer acceso
// (ej. en initState() antes de cualquier interacción con widgets)
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
}
2. Estado Inmutable
Clases Selladas para Jerarquías de Estado
sealed class UserState {}
final class UserInitial extends UserState {}
final class UserLoading extends UserState {}
final class UserLoaded extends UserState {
const UserLoaded(this.user);
final User user;
}
final class UserError extends UserState {
const UserError(this.message);
final String message;
}
// Switch exhaustivo — el compilador obliga a cubrir todas las ramas
Widget buildFrom(UserState state) => switch (state) {
UserInitial() => const SizedBox.shrink(),
UserLoading() => const CircularProgressIndicator(),
UserLoaded(:final user) => UserCard(user: user),
UserError(:final message) => ErrorText(message),
};
Freezed para Inmutabilidad sin Boilerplate
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
required String email,
@Default(false) bool isAdmin,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
// Uso
final user = User(id: '1', name: 'Alicia', email: '[email protected]');
final updated = user.copyWith(name: 'Alicia García'); // actualización inmutable
final json = user.toJson();
final fromJson = User.fromJson(json);
3. Composición Asíncrona
Concurrencia Estructurada con Future.wait
Future<DashboardData> loadDashboard(UserRepository users, OrderRepository orders) async {
// Ejecutar concurrentemente — no esperar secuencialmente
final (userList, orderList) = await (
users.getAll(),
orders.getRecent(),
).wait; // Desestructuración con registros de Dart 3 + extensión Future.wait
return DashboardData(users: userList, orders: orderList);
}
Patrones con Streams
// El repositorio expone streams reactivos para datos en vivo
Stream<List<Item>> watchCartItems() => _db
.watchTable('cart_items')
.map((rows) => rows.map(Item.fromRow).toList());
// En la capa de widgets — declarativo, sin suscripción manual
StreamBuilder<List<Item>>(
stream: cartRepository.watchCartItems(),
builder: (context, snapshot) => switch (snapshot) {
AsyncSnapshot(connectionState: ConnectionState.waiting) =>
const CircularProgressIndicator(),
AsyncSnapshot(:final error?) => ErrorWidget(error.toString()),
AsyncSnapshot(:final data?) => CartList(items: data),
_ => const SizedBox.shrink(),
},
)
BuildContext Después de Await
// CRÍTICO — siempre verificar mounted después de cualquier await en StatefulWidget
Future<void> _handleSubmit() async {
setState(() => _isLoading = true);
try {
await authService.login(_email, _password);
if (!mounted) return; // ← guardia antes de usar context
context.go('/home');
} on AuthException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
4. Arquitectura de Widgets
Extraer a Clases, No a Métodos
// MALO — método privado que devuelve un widget, impide la optimización
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(16),
child: Text(title, style: Theme.of(context).textTheme.headlineMedium),
);
}
// BUENO — clase de widget separada, permite const, reutilización de elementos
class _PageHeader extends StatelessWidget {
const _PageHeader(this.title);
final String title;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Text(title, style: Theme.of(context).textTheme.headlineMedium),
);
}
}
Propagación de const
// MALO — nuevas instancias en cada reconstrucción
child: Padding(
padding: EdgeInsets.all(16.0), // no es const
child: Icon(Icons.home, size: 24.0), // no es const
)
// BUENO — const detiene la propagación de reconstrucciones
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Icon(Icons.home, size: 24.0),
)
Reconstrucciones Acotadas
// MALO — toda la página se reconstruye en cada cambio del contador
class CounterPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider); // reconstruye todo
return Scaffold(
body: Column(children: [
const ExpensiveHeader(), // reconstruido innecesariamente
Text('$count'),
const ExpensiveFooter(), // reconstruido innecesariamente
]),
);
}
}
// BUENO — aislar la parte que se reconstruye
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Column(children: [
ExpensiveHeader(), // nunca se reconstruye (const)
_CounterDisplay(), // solo esto se reconstruye
ExpensiveFooter(), // nunca se reconstruye (const)
]),
);
}
}
class _CounterDisplay extends ConsumerWidget {
const _CounterDisplay();
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Text('$count');
}
}
5. Gestión de Estado: BLoC/Cubit
// Cubit — estado síncrono o asíncrono simple
class AuthCubit extends Cubit<AuthState> {
AuthCubit(this._authService) : super(const AuthState.initial());
final AuthService _authService;
Future<void> login(String email, String password) async {
emit(const AuthState.loading());
try {
final user = await _authService.login(email, password);
emit(AuthState.authenticated(user));
} on AuthException catch (e) {
emit(AuthState.error(e.message));
}
}
void logout() {
_authService.logout();
emit(const AuthState.initial());
}
}
// En el widget
BlocBuilder<AuthCubit, AuthState>(
builder: (context, state) => switch (state) {
AuthInitial() => const LoginForm(),
AuthLoading() => const CircularProgressIndicator(),
AuthAuthenticated(:final user) => HomePage(user: user),
AuthError(:final message) => ErrorView(message: message),
},
)
6. Gestión de Estado: Riverpod
// Provider asíncrono con auto-descarte
@riverpod
Future<List<Product>> products(Ref ref) async {
final repo = ref.watch(productRepositoryProvider);
return repo.getAll();
}
// Notifier con mutaciones complejas
@riverpod
class CartNotifier extends _$CartNotifier {
@override
List<CartItem> build() => [];
void add(Product product) {
final existing = state.where((i) => i.productId == product.id).firstOrNull;
if (existing != null) {
state = [
for (final item in state)
if (item.productId == product.id) item.copyWith(quantity: item.quantity + 1)
else item,
];
} else {
state = [...state, CartItem(productId: product.id, quantity: 1)];
}
}
void remove(String productId) =>
state = state.where((i) => i.productId != productId).toList();
void clear() => state = [];
}
// Provider derivado (patrón selector)
@riverpod
int cartCount(Ref ref) => ref.watch(cartNotifierProvider).length;
@riverpod
double cartTotal(Ref ref) {
final cart = ref.watch(cartNotifierProvider);
final products = ref.watch(productsProvider).valueOrNull ?? [];
return cart.fold(0.0, (total, item) {
// firstWhereOrNull (del paquete collection) evita StateError cuando falta un producto
final product = products.firstWhereOrNull((p) => p.id == item.productId);
return total + (product?.price ?? 0) * item.quantity;
});
}
7. Navegación con GoRouter
final router = GoRouter(
initialLocation: '/',
// refreshListenable reevalúa la redirección cada vez que cambia el estado de autenticación
refreshListenable: GoRouterRefreshStream(authCubit.stream),
redirect: (context, state) {
final isLoggedIn = context.read<AuthCubit>().state is AuthAuthenticated;
final isGoingToLogin = state.matchedLocation == '/login';
if (!isLoggedIn && !isGoingToLogin) return '/login';
if (isLoggedIn && isGoingToLogin) return '/';
return null;
},
routes: [
GoRoute(path: '/login', builder: (_, __) => const LoginPage()),
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
GoRoute(path: '/', builder: (_, __) => const HomePage()),
GoRoute(
path: '/products/:id',
builder: (context, state) =>
ProductDetailPage(id: state.pathParameters['id']!),
),
],
),
],
);
8. HTTP con Dio
final dio = Dio(BaseOptions(
baseUrl: const String.fromEnvironment('API_URL'),
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {'Content-Type': 'application/json'},
));
// Añadir interceptor de autenticación
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await secureStorage.read(key: 'auth_token');
if (token != null) options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
},
onError: (error, handler) async {
// Proteger contra bucles infinitos de reintento: solo intentar refrescar una vez por solicitud
final isRetry = error.requestOptions.extra['_isRetry'] == true;
if (!isRetry && error.response?.statusCode == 401) {
final refreshed = await attemptTokenRefresh();
if (refreshed) {
error.requestOptions.extra['_isRetry'] = true;
return handler.resolve(await dio.fetch(error.requestOptions));
}
}
handler.next(error);
},
));
// Repositorio usando Dio
class UserApiDataSource {
const UserApiDataSource(this._dio);
final Dio _dio;
Future<User> getById(String id) async {
final response = await _dio.get<Map<String, dynamic>>('/users/$id');
return User.fromJson(response.data!);
}
}
9. Arquitectura de Manejo de Errores
// Captura global de errores — configurar en main()
void main() {
FlutterError.onError = (details) {
FlutterError.presentError(details);
crashlytics.recordFlutterFatalError(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
crashlytics.recordError(error, stack, fatal: true);
return true;
};
runApp(const App());
}
// ErrorWidget personalizado para producción
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: router,
builder: (context, widget) {
ErrorWidget.builder = (FlutterErrorDetails details) => Material(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
const Text('Algo salió mal'),
const SizedBox(height: 8),
Text(details.exception.toString(),
style: const TextStyle(fontSize: 12)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.go('/'),
child: const Text('Reintentar'),
),
],
),
),
);
return widget!;
},
);
}
}
10. Principios de Testing
Tests Unitarios para Cubits
void main() {
group('AuthCubit', () {
late MockAuthService authService;
late AuthCubit cubit;
setUp(() {
authService = MockAuthService();
cubit = AuthCubit(authService);
});
test('emite [loading, authenticated] cuando el login es exitoso', () async {
when(authService.login(any, any)).thenAnswer((_) async => User(id: '1', name: 'Test'));
expect(
cubit.stream,
emitsInOrder([
const AuthState.loading(),
isA<AuthAuthenticated>(),
]),
);
await cubit.login('[email protected]', 'password');
});
test('emite [loading, error] cuando el login falla', () async {
when(authService.login(any, any)).thenThrow(AuthException('Credenciales inválidas'));
expect(
cubit.stream,
emitsInOrder([
const AuthState.loading(),
const AuthState.error('Credenciales inválidas'),
]),
);
await cubit.login('[email protected]', 'wrong_password');
});
});
}
Tests de Widgets con Anulaciones de ProviderScope
testWidgets('muestra lista de productos', (tester) async {
final products = [
Product(id: '1', name: 'Widget A', price: 9.99),
Product(id: '2', name: 'Widget B', price: 14.99),
];
await tester.pumpWidget(
ProviderScope(
overrides: [
productsProvider.overrideWith((ref) => Future.value(products)),
cartNotifierProvider.overrideWith((ref) => CartNotifier()),
],
child: const MaterialApp(home: ProductListPage()),
),
);
expect(find.text('Widget A'), findsOneWidget);
expect(find.text('Widget B'), findsOneWidget);
});
Preferir Fakes sobre Mocks
// Fake en lugar de Mock — mantiene el comportamiento real de las colecciones
class FakeCartRepository extends Fake implements CartRepository {
final _items = <CartItem>[];
void addItem(CartItem item) => _items.add(item);
void removeItem(String productId) => _items.removeWhere((i) => i.productId == productId);
@override
List<CartItem> getItems() => List.unmodifiable(_items);
@override
void clear() => _items.clear();
}
// Uso en test
final repo = FakeCartRepository();
repo.addItem(CartItem(productId: '1', quantity: 2));
final result = repo.getItems();
expect(result.length, 1);
expect(result.first.quantity, 2);
// El fake es verificable y no requiere configuraciones de when(), etc.
11. Almacenamiento Local Seguro
// Usando flutter_secure_storage para tokens y datos sensibles
class SecureStorage {
const SecureStorage(this._storage);
final FlutterSecureStorage _storage;
static const _tokenKey = 'auth_token';
static const _refreshTokenKey = 'refresh_token';
Future<void> saveTokens(AuthTokens tokens) async {
await Future.wait([
_storage.write(key: _tokenKey, value: tokens.accessToken),
_storage.write(key: _refreshTokenKey, value: tokens.refreshToken),
]);
}
Future<String?> getAccessToken() => _storage.read(key: _tokenKey);
Future<String?> getRefreshToken() => _storage.read(key: _refreshTokenKey);
Future<void> clearAll() async {
await Future.wait([
_storage.delete(key: _tokenKey),
_storage.delete(key: _refreshTokenKey),
]);
}
}
// Usando shared_preferences para preferencias no sensibles
class UserPreferences {
const UserPreferences(this._prefs);
final SharedPreferences _prefs;
bool get isDarkMode => _prefs.getBool('dark_mode') ?? false;
set isDarkMode(bool value) => _prefs.setBool('dark_mode', value);
String? get lastOpenedProductId => _prefs.getString('last_product_id');
set lastOpenedProductId(String? value) {
if (value != null) {
_prefs.setString('last_product_id', value);
} else {
_prefs.remove('last_product_id');
}
}
}
12. Arquitectura Limpia (Carpetas y Responsabilidades)
Estructura de proyecto recomendada para aplicaciones de tamaño medio-grande:
lib/
├── main.dart
├── app.dart
├── core/
│ ├── constants/ # Rutas de API, claves, valores fijos
│ ├── enums/ # Enumeraciones compartidas
│ ├── errors/ # Excepciones, fallos
│ ├── network/ # Cliente Dio configurado, interceptores
│ ├── router/ # Configuración de GoRouter
│ ├── theme/ # Tema de Material
│ └── utils/ # Extensiones, helpers, formatos
├── data/
│ ├── datasources/ # Fuentes de datos (remotas, locales)
│ ├── dtos/ # Objetos de transferencia de datos (fromJson/toJson)
│ └── repositories/ # Implementaciones de repositorios
├── domain/
│ ├── entities/ # Entidades puras de Dart (sin dependencias externas)
│ ├── repositories/ # Interfaces/contratos abstractos de repositorios
│ └── usecases/ # Casos de uso (orquestan la lógica de negocio)
└── presentation/
├── providers/ # StateNotifiers, ChangeNotifiers, Riverpod providers
├── pages/ # Páginas enteras (con Scaffold)
├── widgets/ # Widgets reutilizables
└── shared/ # Widgets y lógica de presentación compartida
Contratos de Repositorio (Capa de Dominio)
// Capa de dominio — sin importaciones de framework
abstract class UserRepository {
Future<User> getById(String id);
Future<List<User>> getAll();
Future<User> create(CreateUserParams params);
Future<User> update(UpdateUserParams params);
Future<void> delete(String id);
Stream<User?> watchById(String id);
}
Caso de Uso de Ejemplo
class GetUserByIdUseCase {
const GetUserByIdUseCase(this._repository);
final UserRepository _repository;
Future<Result<User>> execute(String id) async {
if (id.isEmpty) {
return const Result.failure(ValidationError('ID no puede estar vacío'));
}
try {
final user = await _repository.getById(id);
return Result.success(user);
} on RepositoryException catch (e) {
return Result.failure(e);
}
}
}
Patrones prácticos y listos para producción para aplicaciones Dart y Flutter. Agnóstico en cuanto a librerías cuando es posible, con cobertura explícita de los paquetes más comunes del ecosistema.