Communitygithub.com

Patrones de Dart/Flutter

Patrones profesionales de Dart y Flutter para producción, que cubren seguridad ante nulos, estado inmutable, composición asíncrona, arquitectura de widgets, frameworks populares de gestión de estado (BLoC, Riverpod, Provider), navegación GoRouter, red con Dio, generación de código con Freezed y arquitectura limpia. Útil al escribir o revisar código Dart y Flutter: estado, widgets, navegación, red o arquitectura.

¿Qué es Flutter?

Este skill proporciona una colección de patrones de código prediseñados para Dart y Flutter, listos para copiar y pegar, con el objetivo de ayudar a los desarrolladores a escribir código robusto, mantenible y de calidad profesional. Aborda áreas críticas como seguridad ante nulos (evitando el operador `!`), estado inmutable con clases selladas y Freezed, y composición asíncrona con `Future.wait` y el manejo seguro del `BuildContext` después de `await`. También incluye pautas para la arquitectura de widgets, promoviendo la extracción de clases y el uso de `const`, y cubre en detalle los frameworks de gestión de estado más populares: BLoC/Cubit, Riverpod y Provider, ofreciendo ejemplos concretos de notificadores, proveedores derivados y selección de estado. Además, el skill ofrece ejemplos prácticos de navegación con GoRouter (incluyendo guardias de autenticación reactivos), configuración de red con Dio (interceptores y refresco de tokens), manejo global de errores y estrategias de testing. Está diseñado para ser agnóstico de librerías cuando sea posible, pero cubre explícitamente los paquetes más comunes del ecosistema Flutter, lo que lo hace útil para proyectos que usan BLoC, Riverpod o Provider, y para cualquier desarrollador que busque escribir código Dart y Flutter idiomático y preparado para producción.

Compatible con~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/affaan-m/ECC/tree/main/skills/dart-flutter-patterns

Preguntar en tu IA favorita

Abre un nuevo chat con esta habilidad de agente ya precargada.

Documentación

¿Qué hace Flutter?

Cuándo usar

Usa este skill cuando:

  • Estés comenzando una nueva funcionalidad de 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 ante nulos, tipos sellados o composición asíncrona
  • Estés configurando un nuevo proyecto Flutter y eligiendo entre BLoC, Riverpod o Provider
  • Estés implementando clientes HTTP seguros, integración de WebView o almacenamiento local
  • Estés escribiendo pruebas para widgets Flutter, Cubits o proveedores 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 área:

  1. Seguridad ante nulos — evitar !, preferir ?./??/coincidencia de patrones
  2. Estado inmutable — clases selladas, freezed, copyWith
  3. Composición asíncrona — Future.wait concurrente, BuildContext seguro después de await
  4. Arquitectura de widgets — extraer a clases (no métodos), propagación de const, reconstrucciones limitadas
  5. Gestión de estado — eventos de BLoC/Cubit, notificadores de Riverpod y proveedores derivados
  6. Navegación — GoRouter con guardias de autenticación reactivas mediante refreshListenable
  7. Red — Dio con interceptores, refresco de token con guardia de reintento único
  8. Manejo de errores — captura global, ErrorWidget.builder, conexión con Crashlytics
  9. Pruebas — unitarias (test BLoC), de widget (anulaciones con ProviderScope), simulaciones sobre 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: [...],
);

// Proveedor 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 preparados para producción para aplicaciones Dart y Flutter. Agnósticos de librerías cuando sea posible, con cobertura explícita de los paquetes más comunes del ecosistema.


1. Fundamentos de seguridad ante nulos

Preferir patrones sobre el operador bang

// MALO — falla en tiempo de ejecución si es nulo
final name = user!.name;

// BUENO — proporcionar valor por defecto
final name = user?.name ?? 'Unknown';

// BUENO — coincidencia de patrones 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 — aplaza el error nulo al tiempo de ejecución
late String userId;

// BUENO — nullable con inicialización explícita
String? userId;

// OK — usar late solo cuando la inicialización esté garantizada antes del primer acceso
// (p.ej., en initState() antes de cualquier interacción con el widget)
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 código repetitivo

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: 'Alice', email: '[email protected]');
final updated = user.copyWith(name: 'Alice Smith'); // 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; // Destructuración de registros de Dart 3 + extensión Future.wait

  return DashboardData(users: userList, orders: orderList);
}

Patrones de 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 widget — 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 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 const
  child: Icon(Icons.home, size: 24.0), // no const
)

// BUENO — const detiene la propagación de la reconstrucción
child: const Padding(
  padding: EdgeInsets.all(16.0),
  child: Icon(Icons.home, size: 24.0),
)

Reconstrucciones limitadas

// 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

// Proveedor asíncrono con auto-descarte
@riverpod
Future<List<Product>> products(Ref ref) async {
  final repo = ref.watch(productRepositoryProvider);
  return repo.getAll();
}

// Notificador 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 = [];
}

// Proveedor 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 el 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 re-evalú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 {
    // Protección contra bucles infinitos de reintento: solo intentar refrescar una vez por petición
    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());
}

// Widget de Error personalizado para producción
class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: router,
      builder: (context, child) {
        ErrorWidget.builder = (details) => Material(
          child: Center(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  const Icon(Icons.error_outline, size: 64, color: Colors.red),
                  const SizedBox(height: 16),
                  Text('Algo salió mal', style: Theme.of(context).textTheme.headlineSmall),
                  const SizedBox(height: 8),
                  Text(details.exception.toString(), textAlign: TextAlign.center),
                ],
              ),
            ),
          ),
        );
        return child!;
      },
    );
  }
}

10. Pruebas

Prueba unitaria de BLoC

void main() {
  group('AuthCubit', () {
    late AuthCubit cubit;
    late MockAuthService mockAuthService;

    setUp(() {
      mockAuthService = MockAuthService();
      cubit = AuthCubit(mockAuthService);
    });

    tearDown(() => cubit.close());

    test('el estado inicial es AuthInitial', () {
      expect(cubit.state, const AuthState.initial());
    });

    blocTest<AuthCubit, AuthState>(
      'emite [Loading, Authenticated] cuando login tiene éxito',
      build: () => cubit,
      act: (cubit) => cubit.login('[email protected]', 'password'),
      setUp: () => when(mockAuthService.login(any, any))
          .thenAnswer((_) async => User(id: '1', name: 'Test')),
      expect: () => [
        const AuthState.loading(),
        AuthState.authenticated(User(id: '1', name: 'Test')),
      ],
    );
  });
}

Prueba de widget con anulación de ProviderScope

void main() {
  testWidgets('CounterPage muestra el contador inicial', (tester) async {
    await tester.pumpWidget(
      ProviderScope(
        overrides: [
          cartNotifierProvider.overrideWith(() => CartNotifier()),
          productsProvider.overrideWith((ref) => Future.value([])),
        ],
        child: const MaterialApp(home: CounterPage()),
      ),
    );

    expect(find.text('0'), findsOneWidget);
  });
}

Preferir simulaciones (fakes) sobre mocks para pruebas de integración

// FAKE — implementación ligera para pruebas en lugar de un mock complejo
class FakeAuthService implements AuthService {
  @override
  Future<User> login(String email, String password) async {
    if (email == '[email protected]') throw AuthException('Credenciales inválidas');
    return User(id: 'fake-1', name: 'Usuario de prueba', email: email);
  }

  @override
  void logout() { /* no-op */ }
}

Resumen rápido

ÁreaRegla clave
Seguridad ante nulosEvitar ! y late excesivo; usar ?./??/coincidencia de patrones
Estado inmutableClases selladas para jerarquías de estado; freezed para modelos de datos
Composición asíncronaFuture.wait para concurrencia; siempre verificar mounted después de await
Arquitectura de widgetsExtraer a clases widget, no a métodos; propagar const; aislar reconstrucciones
Gestión de estadoBLoC/Cubit para lógica de negocio compleja; Riverpod para reactividad simple a media
NavegaciónGoRouter con refreshListenable para guardias de autenticación
RedDio con interceptores para tokens; guardia de reintento único en errores 401
Manejo de erroresFlutterError.onError + PlatformDispatcher.onError para captura global
PruebasblocTest para Cubits; anulaciones de ProviderScope para Riverpod; fakes sobre mocks

Siempre priorizar la corrección en tiempo de compilación. Dejar que el sistema de tipos prevenga errores. Escribir patrones que hagan que los estados inválidos sean imposibles de representar.

Individual skills in this repo

This repo contains 20 individual skills — each has its own dedicated page.

accessibility

Design, implement, and audit inclusive digital products using WCAG 2.2 Level AA. Use when building or auditing UI that must meet WCAG 2.2 Level AA, or when reviewing a change for keyboard, contrast, or screen-reader support.

affaan-m/content-engine

Create platform-native content systems for X, LinkedIn, TikTok, YouTube, newsletters, and repurposed multi-platform campaigns. Use when the user wants social posts, threads, scripts, content calendars, or one source asset adapted cleanly across platforms.

affaan-m/ECC

Multi-platform content distribution across X, LinkedIn, Threads, and Bluesky. Adapts content per platform using content-engine patterns. Never posts identical content cross-platform. Use when the user wants to distribute content across social platforms.

affaan-m/fal-ai-media

Unified media generation via fal.ai MCP — image, video, and audio. Covers text-to-image (Nano Banana), text/image-to-video (Seedance, Kling, Veo 3), text-to-speech (CSM-1B), and video-to-audio (ThinkSound). Use when the user wants to generate images, videos, or audio with AI.

affaan-m/manim-video

日本語翻訳:このファイルは manim-video 用の日本語翻訳が必要です

affaan-m/remotion-video-creation

Remotion のベストプラクティス - React で動画を作成する。3D、アニメーション、音声、字幕、チャート、トランジションなどをカバーするドメイン固有の29のルール。

affaan-m/video-editing

AI-assisted video editing workflows for cutting, structuring, and augmenting real footage. Covers the full pipeline from raw capture through FFmpeg, Remotion, ElevenLabs, fal.ai, and final polish in Descript or CapCut. Use when the user wants to edit video, cut footage, create vlogs, or build video content.

agent-architecture-audit

Full-stack diagnostic for agent and LLM applications. Audits the 12-layer agent stack for wrapper regression, memory pollution, tool discipline failures, hidden repair loops, and rendering corruption. Produces severity-ranked findings with code-first fixes. Essential for developers building agent applications, autonomous loops, or any LLM-powered feature. Use when an agent or LLM feature misbehaves and the failing layer is unknown, or before shipping an agent stack.

agent-eval

Head-to-head comparison of coding agents (Claude Code, Aider, Codex, etc.) on custom tasks with pass rate, cost, time, and consistency metrics. Use when choosing between coding agents, or when a change to an agent setup needs measured pass rate, cost, and time rather than an impression.

agent-harness-construction

Design and optimize AI agent action spaces, tool definitions, and observation formatting for higher completion rates. Use when defining or revising an agent

agentic-engineering

Operate as an agentic engineer using eval-first execution, decomposition, and cost-aware model routing. Use when planning or executing engineering work that agents will carry out end to end.

agentic-os

Build persistent multi-agent operating systems on Claude Code. Covers kernel architecture, specialist agents, slash commands, file-based memory, scheduled automation, and state management without external databases. Use when building a persistent multi-agent system on Claude Code with its own memory, commands, and scheduling.

agent-introspection-debugging

Structured self-debugging workflow for AI agent failures using capture, diagnosis, contained recovery, and introspection reports. Use when an agent run fails and you need a reproducible diagnosis instead of a retry.

agent-payment-x402

Add x402 payment execution to AI agents with per-task budgets, spending controls, and non-custodial wallets. Supports Base through agentwallet-sdk and X Layer through OKX Payments / OKX Agent Payments Protocol. Use when an agent must pay for something itself and needs per-task budgets, spending controls, and a non-custodial wallet.

agent-self-evaluation

Use after completing any non-trivial task. The agent self-rates its output on 5 axes — accuracy, completeness, clarity, actionability, conciseness — with concrete evidence per criterion. Produces a structured 1-5 scorecard with specific improvement suggestions.

agent-sort

Build an evidence-backed ECC install plan for a specific repo by sorting skills, commands, rules, hooks, and extras into DAILY vs LIBRARY buckets using parallel repo-aware review passes. Use when ECC should be trimmed to what a project actually needs instead of loading the full bundle.

ai-first-engineering

Engineering operating model for teams where AI agents generate a large share of implementation output. Use when setting team process, review gates, or ownership rules for a codebase largely written by agents.

ai-regression-testing

Regression testing strategies for AI-assisted development. Sandbox-mode API testing without database dependencies, automated bug-check workflows, and patterns to catch AI blind spots where the same model writes and reviews code. Use when adding regression coverage to AI-assisted code, or when the same model both wrote and reviewed a change.

android-clean-architecture

Clean Architecture patterns for Android and Kotlin Multiplatform projects — module structure, dependency rules, UseCases, Repositories, and data layer patterns. Use when structuring modules, layers, or data flow in an Android or KMP project.

angular-developer

Generates Angular code and provides architectural guidance. Trigger when creating projects, components, or services, or for best practices on reactivity (signals, linkedSignal, resource), forms, dependency injection, routing, SSR, accessibility (ARIA), animations, styling (component styles, Tailwind CSS), testing, or CLI tooling.

Skills relacionados