dart-flutter-patterns

Production-ready Dart and Flutter patterns covering null safety, immutable state, async composition, widget architecture, popular state management frameworks…

INSTALLATION
npx skills add https://github.com/affaan-m/everything-claude-code --skill dart-flutter-patterns
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

Dart/Flutter Patterns

When to Use

Use this skill when:

  • Starting a new Flutter feature and need idiomatic patterns for state management, navigation, or data access
  • Reviewing or writing Dart code and need guidance on null safety, sealed types, or async composition
  • Setting up a new Flutter project and choosing between BLoC, Riverpod, or Provider
  • Implementing secure HTTP clients, WebView integration, or local storage
  • Writing tests for Flutter widgets, Cubits, or Riverpod providers
  • Wiring up GoRouter with authentication guards

How It Works

This skill provides copy-paste-ready Dart/Flutter code patterns organized by concern:

  • Null safety — avoid !, prefer ?./??/pattern matching
  • Immutable state — sealed classes, freezed, copyWith
  • Async composition — concurrent Future.wait, safe BuildContext after await
  • Widget architecture — extract to classes (not methods), const propagation, scoped rebuilds
  • State management — BLoC/Cubit events, Riverpod notifiers and derived providers
  • Navigation — GoRouter with reactive auth guards via refreshListenable
  • Networking — Dio with interceptors, token refresh with one-time retry guard
  • Error handling — global capture, ErrorWidget.builder, crashlytics wiring
  • Testing — unit (BLoC test), widget (ProviderScope overrides), fakes over mocks

Examples

// Sealed state — prevents impossible states

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 with reactive auth redirect

final router = GoRouter(

  refreshListenable: GoRouterRefreshStream(authCubit.stream),

  redirect: (context, state) {

    final authed = context.read<AuthCubit>().state is AuthAuthenticated;

    if (!authed &#x26;&#x26; !state.matchedLocation.startsWith('/login')) return '/login';

    return null;

  },

  routes: [...],

);

// Riverpod derived provider with safe firstWhereOrNull

@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;

  });

}

Practical, production-ready patterns for Dart and Flutter applications. Library-agnostic where possible, with explicit coverage of the most common ecosystem packages.

1. Null Safety Fundamentals

Prefer Patterns Over Bang Operator

// BAD — crashes at runtime if null

final name = user!.name;

// GOOD — provide fallback

final name = user?.name ?? 'Unknown';

// GOOD — Dart 3 pattern matching (preferred for complex cases)

final display = switch (user) {

  User(:final name, :final email) => '$name <$email>',

  null => 'Guest',

};

// GOOD — guard early return

String getUserName(User? user) {

  if (user == null) return 'Unknown';

  return user.name; // promoted to non-null after check

}

Avoid late Overuse

// BAD — defers null error to runtime

late String userId;

// GOOD — nullable with explicit initialization

String? userId;

// OK — use late only when initialization is guaranteed before first access

// (e.g., in initState() before any widget interaction)

late final AnimationController _controller;

@override

void initState() {

  super.initState();

  _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));

}

2. Immutable State

Sealed Classes for State Hierarchies

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;

}

// Exhaustive switch — compiler enforces all branches

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 for Boilerplate-Free Immutability

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);

}

// Usage

final user = User(id: '1', name: 'Alice', email: 'alice@example.com');

final updated = user.copyWith(name: 'Alice Smith'); // immutable update

final json = user.toJson();

final fromJson = User.fromJson(json);

3. Async Composition

Structured Concurrency with Future.wait

Future<DashboardData> loadDashboard(UserRepository users, OrderRepository orders) async {

  // Run concurrently — don't await sequentially

  final (userList, orderList) = await (

    users.getAll(),

    orders.getRecent(),

  ).wait; // Dart 3 record destructuring + Future.wait extension

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

}

Stream Patterns

// Repository exposes reactive streams for live data

Stream<List<Item>> watchCartItems() => _db

    .watchTable('cart_items')

    .map((rows) => rows.map(Item.fromRow).toList());

// In widget layer — declarative, no manual subscription

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 After Await

// CRITICAL — always check mounted after any await in StatefulWidget

Future<void> _handleSubmit() async {

  setState(() => _isLoading = true);

  try {

    await authService.login(_email, _password);

    if (!mounted) return; // ← guard before using 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. Widget Architecture

Extract to Classes, Not Methods

// BAD — private method returning widget, prevents optimization

Widget _buildHeader() {

  return Container(

    padding: const EdgeInsets.all(16),

    child: Text(title, style: Theme.of(context).textTheme.headlineMedium),

  );

}

// GOOD — separate widget class, enables const, element reuse

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),

    );

  }

}

const Propagation

// BAD — new instances every rebuild

child: Padding(

  padding: EdgeInsets.all(16.0),       // not const

  child: Icon(Icons.home, size: 24.0), // not const

)

// GOOD — const stops rebuild propagation

child: const Padding(

  padding: EdgeInsets.all(16.0),

  child: Icon(Icons.home, size: 24.0),

)

Scoped Rebuilds

// BAD — entire page rebuilds on every counter change

class CounterPage extends ConsumerWidget {

  @override

  Widget build(BuildContext context, WidgetRef ref) {

    final count = ref.watch(counterProvider); // rebuilds everything

    return Scaffold(

      body: Column(children: [

        const ExpensiveHeader(), // unnecessarily rebuilt

        Text('$count'),

        const ExpensiveFooter(), // unnecessarily rebuilt

      ]),

    );

  }

}

// GOOD — isolate the rebuilding part

class CounterPage extends StatelessWidget {

  const CounterPage({super.key});

  @override

  Widget build(BuildContext context) {

    return const Scaffold(

      body: Column(children: [

        ExpensiveHeader(),        // never rebuilt (const)

        _CounterDisplay(),        // only this rebuilds

        ExpensiveFooter(),        // never rebuilt (const)

      ]),

    );

  }

}

class _CounterDisplay extends ConsumerWidget {

  const _CounterDisplay();

  @override

  Widget build(BuildContext context, WidgetRef ref) {

    final count = ref.watch(counterProvider);

    return Text('$count');

  }

}

5. State Management: BLoC/Cubit

// Cubit — synchronous or simple async state

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());

  }

}

// In 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. State Management: Riverpod

// Auto-dispose async provider

@riverpod

Future<List<Product>> products(Ref ref) async {

  final repo = ref.watch(productRepositoryProvider);

  return repo.getAll();

}

// Notifier with complex mutations

@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 = [];

}

// Derived provider (selector pattern)

@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 (from collection package) avoids StateError when product is missing

    final product = products.firstWhereOrNull((p) => p.id == item.productId);

    return total + (product?.price ?? 0) * item.quantity;

  });

}

7. Navigation with GoRouter

final router = GoRouter(

  initialLocation: '/',

  // refreshListenable re-evaluates redirect whenever auth state changes

  refreshListenable: GoRouterRefreshStream(authCubit.stream),

  redirect: (context, state) {

    final isLoggedIn = context.read<AuthCubit>().state is AuthAuthenticated;

    final isGoingToLogin = state.matchedLocation == '/login';

    if (!isLoggedIn &#x26;&#x26; !isGoingToLogin) return '/login';

    if (isLoggedIn &#x26;&#x26; 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 with 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'},

));

// Add auth interceptor

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 {

    // Guard against infinite retry loops: only attempt refresh once per request

    final isRetry = error.requestOptions.extra['_isRetry'] == true;

    if (!isRetry &#x26;&#x26; 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);

  },

));

// Repository using 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. Error Handling Architecture

// Global error capture — set up in 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());

}

// Custom ErrorWidget for production

class App extends StatelessWidget {

  @override

  Widget build(BuildContext context) {

    ErrorWidget.builder = (details) => ProductionErrorWidget(details);

    return MaterialApp.router(routerConfig: router);

  }

}

10. Testing Quick Reference

// Unit test — use case

test('GetUserUseCase returns null for missing user', () async {

  final repo = FakeUserRepository();

  final useCase = GetUserUseCase(repo);

  expect(await useCase('missing-id'), isNull);

});

// BLoC test

blocTest<AuthCubit, AuthState>(

  'emits loading then error on failed login',

  build: () => AuthCubit(FakeAuthService(throwsOn: 'login')),

  act: (cubit) => cubit.login('user@test.com', 'wrong'),

  expect: () => [const AuthState.loading(), isA<AuthError>()],

);

// Widget test

testWidgets('CartBadge shows item count', (tester) async {

  await tester.pumpWidget(

    ProviderScope(

      overrides: [cartNotifierProvider.overrideWith(() => FakeCartNotifier(count: 3))],

      child: const MaterialApp(home: CartBadge()),

    ),

  );

  expect(find.text('3'), findsOneWidget);

});

References

  • Skill: flutter-dart-code-review — comprehensive review checklist
  • Rules: rules/dart/ — coding style, patterns, security, testing, hooks
BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card