Communitygithub.com

dart-flutter-patterns

プロダクション対応のDart/Flutterパターン集。Null安全、不変状態、非同期構成、ウィジェットアーキテクチャ、BLoC/Riverpod/Providerなどの状態管理、GoRouterナビゲーション、Dioネットワーキング、Freezedコード生成、クリーンアーキテクチャを網羅。Dart/Flutterのコードを記述・レビューする際に使用します。

dart-flutter-patterns とは?

このスキルは、プロダクション品質のDartおよびFlutterアプリケーションを構築するための実践的で即戦力となるコードパターンを提供します。Null安全、不変状態、非同期構成、ウィジェットアーキテクチャ、状態管理(BLoC/Cubit、Riverpod、Provider)、GoRouterナビゲーション、Dioネットワーキング、Freezedコード生成、エラーハンドリング、テストといった主要な関心事をカバーしています。Claude Code、Cursor、CodexなどのAIエージェントで使用することを想定しており、新しいFlutter機能の開始時やコードレビュー時、プロジェクトセットアップ時にパターンをコピー&ペーストするだけで利用できます。各パターンは、Dart 3のsealed classやswitch式、レコードなどの最新言語機能を活用し、一般的な落とし穴を避けるためのベストプラクティスを強調しています。

対応~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/affaan-m/everything-claude-code/tree/main/skills/dart-flutter-patterns

お気に入りのAIに質問する

このエージェントスキルを事前に読み込んだ状態で新しいチャットを開きます。

ドキュメント

Dart/Flutter Patterns

使用するタイミング

以下のケースでこのスキルを使用します:

  • 新しいFlutter機能の開発を開始し、状態管理、ナビゲーション、データアクセスに関する慣用的なパターンが必要な場合
  • Dartコードのレビューや記述時に、Null安全、sealed型、非同期構成に関するガイダンスが必要な場合
  • 新しいFlutterプロジェクトをセットアップし、BLoC、Riverpod、Providerのいずれを選択するか検討している場合
  • セキュアなHTTPクライアント、WebView統合、ローカルストレージを実装する場合
  • Flutterウィジェット、Cubit、Riverpodプロバイダーのテストを記述する場合
  • 認証ガード付きのGoRouterを設定する場合

仕組み

このスキルは、関心事ごとに整理された、コピー&ペーストで使用できるDart/Flutterコードパターンを提供します:

  1. Null安全 — !を避け、?./??/パターンマッチングを優先
  2. 不変状態 — sealed class、freezed、copyWith
  3. 非同期構成 — 並行Future.wait、await後の安全なBuildContext
  4. ウィジェットアーキテクチャ — メソッドではなくクラスに抽出、constの伝播、スコープ付きリビルド
  5. 状態管理 — BLoC/Cubitイベント、Riverpod Notifierと派生プロバイダー
  6. ナビゲーション — refreshListenableを使用したリアクティブな認証ガード付きGoRouter
  7. ネットワーキング — インターセプター付きDio、ワンタイムリトライガード付きトークンリフレッシュ
  8. エラーハンドリング — グローバルキャプチャ、ErrorWidget.builder、Crashlytics連携
  9. テスト — 単体テスト(BLoCテスト)、ウィジェットテスト(ProviderScopeオーバーライド)、モックよりFake

例

// Sealed state — ありえない状態を防止
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
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: [...],
);

// 安全なfirstWhereOrNullを使用したRiverpod派生プロバイダー
@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;
  });
}

DartおよびFlutterアプリケーション向けの、実用的でプロダクション対応のパターンです。可能な限りライブラリに依存せず、最も一般的なエコシステムパッケージを明示的にカバーします。


1. Null Safetyの基礎

パターンをBang演算子より優先

// BAD — 実行時にnullでクラッシュ
final name = user!.name;

// GOOD — フォールバックを提供
final name = user?.name ?? 'Unknown';

// GOOD — Dart 3パターンマッチング(複雑なケースで推奨)
final display = switch (user) {
  User(:final name, :final email) => '$name <$email>',
  null => 'Guest',
};

// GOOD — 早期リターンでガード
String getUserName(User? user) {
  if (user == null) return 'Unknown';
  return user.name; // チェック後、非nullに昇格
}

lateの過剰使用を避ける

// BAD — nullエラーを実行時に先送り
late String userId;

// GOOD — 明示的な初期化でnullableに
String? userId;

// OK — 最初のアクセス前に初期化が保証されている場合のみlateを使用
// (例:initState()内で、ウィジェット操作の前)
late final AnimationController _controller;

@override
void initState() {
  super.initState();
  _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
}

2. 不変状態

状態階層のためのSealed Class

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 — コンパイラがすべてのブランチを強制
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によるボイラープレートフリーの不変性

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

// 使用例
final user = User(id: '1', name: 'Alice', email: '[email protected]');
final updated = user.copyWith(name: 'Alice Smith'); // 不変な更新
final json = user.toJson();
final fromJson = User.fromJson(json);

3. 非同期構成

Future.waitによる構造化並行処理

Future<DashboardData> loadDashboard(UserRepository users, OrderRepository orders) async {
  // 並行して実行 — 逐次awaitしない
  final (userList, orderList) = await (
    users.getAll(),
    orders.getRecent(),
  ).wait; // Dart 3のレコード分割代入 + Future.wait拡張

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

ストリームパターン

// リポジトリはライブデータ用のリアクティブストリームを公開
Stream<List<Item>> watchCartItems() => _db
    .watchTable('cart_items')
    .map((rows) => rows.map(Item.fromRow).toList());

// ウィジェット層 — 宣言的、手動購読不要
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(),
  },
)

Await後のBuildContext

// CRITICAL — StatefulWidget内のawait後は常にmountedをチェック
Future<void> _handleSubmit() async {
  setState(() => _isLoading = true);
  try {
    await authService.login(_email, _password);
    if (!mounted) return; // ← 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. ウィジェットアーキテクチャ

メソッドではなくクラスに抽出

// BAD — ウィジェットを返すprivateメソッド、最適化を阻害
Widget _buildHeader() {
  return Container(
    padding: const EdgeInsets.all(16),
    child: Text(title, style: Theme.of(context).textTheme.headlineMedium),
  );
}

// GOOD — 別のウィジェットクラス、const、要素の再利用が可能
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の伝播

// BAD — リビルドごとに新しいインスタンス
child: Padding(
  padding: EdgeInsets.all(16.0),       // constではない
  child: Icon(Icons.home, size: 24.0), // constではない
)

// GOOD — constがリビルドの伝播を止める
child: const Padding(
  padding: EdgeInsets.all(16.0),
  child: Icon(Icons.home, size: 24.0),
)

スコープ付きリビルド

// BAD — カウンターが変わるたびにページ全体がリビルド
class CounterPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider); // すべてをリビルド
    return Scaffold(
      body: Column(children: [
        const ExpensiveHeader(), // 不必要にリビルドされる
        Text('$count'),
        const ExpensiveFooter(), // 不必要にリビルドされる
      ]),
    );
  }
}

// GOOD — リビルドする部分だけを分離
class CounterPage extends StatelessWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Column(children: [
        ExpensiveHeader(),        // 決してリビルドされない(const)
        _CounterDisplay(),        // これだけがリビルドされる
        ExpensiveFooter(),        // 決してリビルドされない(const)
      ]),
    );
  }
}

class _CounterDisplay extends ConsumerWidget {
  const _CounterDisplay();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    return Text('$count');
  }
}

5. 状態管理: BLoC/Cubit

// Cubit — 同期的または単純な非同期状態
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());
  }
}

// ウィジェット内
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. 状態管理: Riverpod

// 自動破棄の非同期プロバイダー
@riverpod
Future<List<Product>> products(Ref ref) async {
  final repo = ref.watch(productRepositoryProvider);
  return repo.getAll();
}

// 複雑なミューテーションを持つNotifier
@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 = [];
}

// 派生プロバイダー(セレクターパターン)
@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(collectionパッケージより)は、製品が見つからない場合のStateErrorを回避
    final product = products.firstWhereOrNull((p) => p.id == item.productId);
    return total + (product?.price ?? 0) * item.quantity;
  });
}

7. GoRouterによるナビゲーション

final router = GoRouter(
  initialLocation: '/',
  // refreshListenableは認証状態が変わるたびにリダイレクトを再評価
  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. DioによるHTTP通信

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'},
));

// 認証インターセプターを追加
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 {
    // 無限リトライループを防止: リクエストごとに1回だけリフレッシュを試行
    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);
  },
));

// 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. エラーハンドリングアーキテクチャ

// グローバルエラーキャプチャ — 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
class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      builder: (context, widget) {
        ErrorWidget.builder = (details) => Material(
          child: Center(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  const Icon(Icons.error_outline, size: 48, color: Colors.red),
                  const SizedBox(height: 16),
                  Text(
                    'Something went wrong',
                    style: Theme.of(context).textTheme.headlineSmall,
                  ),
                  const SizedBox(height: 8),
                  Text(details.exceptionAsString()),
                ],
              ),
            ),
          ),
        );
        return widget!;
      },
      home: const HomePage(),
    );
  }
}

10. テスト

BLoC/Cubitの単体テスト

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

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

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

    test('emits [loading, authenticated] on successful login', () async {
      when(() => mockService.login('[email protected]', 'password'))
          .thenAnswer((_) async => User(id: '1', name: 'Test', email: '[email protected]'));

      expectLater(
        cubit.stream,
        emitsInOrder([
          const AuthState.loading(),
          AuthState.authenticated(any(named: 'user')),
        ]),
      );

      cubit.login('[email protected]', 'password');
    });

    test('emits [loading, error] on failed login', () async {
      when(() => mockService.login('[email protected]', 'wrong'))
          .thenThrow(const AuthException('Invalid credentials'));

      expectLater(
        cubit.stream,
        emitsInOrder([
          const AuthState.loading(),
          const AuthState.error('Invalid credentials'),
        ]),
      );

      cubit.login('[email protected]', 'wrong');
    });
  });
}

Riverpodのウィジェットテスト

void main() {
  testWidgets('CartTotal displays correct total', (tester) async {
    await tester.pumpWidget(
      ProviderScope(
        overrides: [
          cartNotifierProvider.overrideWith(() => _TestCartNotifier()),
          productsProvider.overrideWith(() => Future.value([
            Product(id: '1', name: 'Test Product', price: 19.99),
          ])),
        ],
        child: const MaterialApp(home: CartTotalWidget()),
      ),
    );

    expect(find.text('\$19.99'), findsOneWidget);
  });
}

class _TestCartNotifier extends CartNotifier {
  @override
  List<CartItem> build() => [CartItem(productId: '1', quantity: 1)];
}

Fakeはモックより優先

// GOOD — 軽量なFake実装はモックよりメンテナンスしやすい
class FakeAuthService implements AuthService {
  bool loginShouldSucceed = true;
  bool logoutCalled = false;

  @override
  Future<User> login(String email, String password) async {
    if (!loginShouldSucceed) throw const AuthException('Fake error');
    return User(id: 'fake-id', name: 'Fake User', email: email);
  }

  @override
  Future<void> logout() async {
    logoutCalled = true;
  }
}

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

Anthropic Claude API patterns for Python and TypeScript. Covers Messages API, streaming, tool use, vision, extended thinking, batches, prompt caching, and Claude Agent SDK. Use when building applications with the Claude API or Anthropic SDKs.

affaan-m/everything-claude-code

Development conventions and patterns for everything-claude-code. JavaScript project with conventional commits.

affaan-m/everything-claude-code

End-to-end marketing campaign planning and execution. Covers audience research, positioning, campaign angle definition, landing page copy, email sequences, social posts, ad copy, short-form video scripts, and content calendars. Use as the orchestration layer for multi-channel product launches. Use when planning or executing a multi-channel product launch, or producing landing page, email, social, or ad copy.

affaan-m/everything-claude-code-conventions

Development conventions and patterns for everything-claude-code. JavaScript project with conventional commits.

affaan-m/frontend-design

Create distinctive, production-grade frontend interfaces with high design quality. Use when the user asks to build web components, pages, or applications and the visual direction matters as much as the code quality.

affaan-m/gget

gget CLI and Python workflow for quick genomic database queries, sequence lookup, BLAST-style searches, enrichment checks, and reproducible bioinformatics evidence logs.

affaan-m/literature-review

Systematic literature-review workflow for academic, biomedical, technical, and scientific topics, including search planning, source screening, synthesis, citation checks, and evidence logging.

affaan-m/motion-ui

Production-ready UI motion system for React/Next.js. Use when implementing animations, transitions, or motion patterns.

affaan-m/project-guidelines-example

Example project-specific skill template based on a real production application.

affaan-m/pubmed-database

Direct PubMed and NCBI E-utilities search workflows for biomedical literature, MeSH queries, PMID lookup, citation retrieval, and API-backed literature monitoring.

affaan-m/scholar-evaluation

Structured scholarly-work evaluation for papers, proposals, literature reviews, methods sections, evidence quality, citation support, and research-writing feedback.

affaan-m/uspto-database

USPTO patent and trademark data workflow for official record lookup, PatentSearch queries, TSDR checks, assignment data, and reproducible IP research logs.

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.

関連スキル