mcpField
S
scaffold-feature-module

scaffold-feature-module

github.com/zakariaf開発63145 回インストール5 週間前に更新
リポジトリ

scaffold-feature-module について

Flutter アプリに一貫した構成の機能モジュールを作るためのスキルです。Riverpod、リポジトリ、型付きルートを使い、画面や ViewModel、共有データ、他機能との境界を定め、生成・検証スクリプトも備えます。

主な機能

  1. 01標準化された機能フォルダー構成
  2. 02Riverpod Provider と Notifier ViewModel
  3. 03リポジトリ経由の読み書き
  4. 04型付き go_router ルート登録
  5. 05ひな形生成と検証スクリプト

活用シーン

  1. 01統一構成で画面を追加
  2. 02機能状態をリポジトリのストリームに接続
  3. 03プルリクエスト前の機能境界チェック

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/zakariaf/flutter-skills --skill scaffold-feature-module

インストール後、Claude Code で/scaffold-feature-moduleを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

examples/
tasks_notifier.dart
tasks_screen.dart
references/
add-persisted-record.md
anatomy-and-wiring.md
routing-and-l10n.md
scripts/
scaffold_feature.sh
verify_feature.sh

詳細

機能モジュールのスキャフォールド

機能を毎回同じ方法で構築します。lib/features/<feature>/ フォルダーを作成し、 presentation/ にシンプルな View、その View と 1:1 に対応する Notifier/AsyncNotifier ViewModel、末端の widgets/、スコープ付きプロバイダーを配置します(機能固有のモデルは、必要に応じて domain/ に配置します)。機能はフォルダーであり、パッケージではありません。lib/data/ の共有リポジトリと lib/core/ の共有基盤を読み取り、すべての書き込みをそれら経由で行い、依存関係は下位方向のみとします。別の機能への横方向の依存は禁止です。 (単一パッケージのツリーについては project-structure-and-packages が管理します。)

状態管理ライブラリに依存しないコア原則(どのライブラリでも適用されます):View はシンプルに保ちます。各機能につき 1 つの ViewModel が非公開の可変状態を所有し、新しい不変値を割り当てるインテントメソッドを公開します。読み取りはリポジトリ(唯一の信頼できるデータソース)から導出し、書き込みは 1 つのリポジトリメソッド(唯一の書き込み経路。公開前に永続化)を経由します。機能は抽象化に依存し、ルート ID で遷移します。以下の Riverpod を優先した「方法」では、Notifier、スコープ付き StreamProvider、DI としてのプロバイダーを使ってこれを組み立てます。

作業内容に応じて、以下のリファレンスを参照してください。

  • references/anatomy-and-wiring.md — 固定のフォルダー構成、Notifier とスコープ付きストリーム、watch/read/listen、family+autoDispose と keepAlive、単一の書き込み経路、ViewModel のテスト。
  • references/routing-and-l10n.md — 型安全な go_router の登録(state.extra ではなくパスパラメーター)と ARB の整合性を保つワークフロー。
  • references/add-persisted-record.md — 機能が新しいレコードを読み取る場合の、任意のモデル → テーブル → DAO → リポジトリの連鎖(persistence-drift に準拠)。

<feature> を指定して scripts/scaffold_feature.sh <feature> を実行し、骨組みを生成してから、PR の前に scripts/verify_feature.sh を実行します。

譲れないルール

  1. 機能はフォルダーであり、新しいパッケージではありません。 lib/features/<feature>/ に配置し、lower_snake_case で画面名を付けます(tasks、task_detail、settings、onboarding)。新しいフォルダーの追加は通常の作業ですが、新しいパッケージの追加は境界を意図的に決める必要があります(project-structure-and-packages を参照)。番号付きフォルダーや固定パッケージ数の規則を独自に作らないでください。アプリパッケージでは lib/src/ を使用しません。これは複数パッケージで使われる規則です(project-structure-and-packages を参照)。
  2. 構成は固定し、ファイルごとに主要な公開型を1つにします。 lib/features/<feature>/presentation/ の下に、<feature>_screen.dart(ロジックを持たない View)、<feature>_notifier.dart(1対1で対応する Notifier/AsyncNotifier の ViewModel。naming-conventions に従い、ファイル名は主要な宣言名と一致させます)、widgets/(末端の View)、<feature>_providers.dart(この機能にスコープを限定したプロバイダー。グローバルにはしません)を置きます。機能固有のモデルは、必要に応じて domain/<feature>.dart に配置します。構成を予測しやすくすれば、レビュー担当者は必要なものをすぐに見つけられます。
  3. Viewはロジックを持たず、ViewModelとは1対1で対応します。 Viewが読み取るコントローラーは1つだけで、描画を担います。許可されるのは表示・非表示を切り替える if、レイアウト、アニメーション、ナビゲーションのみです。リポジトリー呼び出し、try/catch を使ったビジネスロジック、単位計算、build() 内でのフォーマット処理は禁止です。ロジックはViewModelに置きます。
  4. ある機能から別の機能をインポートしてはいけません。 foundation レイヤー(core/data)を介して共有するか、ルート ID で遷移します。依存関係は下位にのみ向けます。機能間のインポートはlint/grepのチェックで失敗させます。横方向の結合は、2つの機能を分割できない一塊にしてしまいます。
  5. ストアの読み取りはリポジトリープロバイダー経由に限定し、.watch() はスコープ付きの StreamProvider でラップします。 ウィジェットやViewModelからDrift、セキュアストレージ、プラットフォームチャネルに直接アクセスしてはいけません。ストリームはキー(所有者/親IDや時間枠など)でスコープを限定し、1つの書き込みがアプリ全体で再発行を引き起こさないようにします。
  6. build では ref.watch、コールバックでは ref.read、単発の副作用(SnackBar、画面遷移)には ref.listen を使います。 コールバック内で監視すると再購読され、build 内で読み取ると再ビルドが発生しません。(使い分けの詳細は state-management-riverpod を参照してください。)
  7. キーごとの状態には family と autoDispose、シングルトンには keepAlive を使います。 特定のエンティティ/セッションにスコープされたプロバイダーは、安定した等価比較可能な値をキーとする family にし、画面とともに破棄されるよう autoDispose を設定します。キーなしの「現在のもの」プロバイダーは禁止です。アプリスコープのdb/serviceシングルトンに autoDispose を設定してはいけません。
  8. すべての変更処理を1つのリポジトリーメソッドに集約し、唯一の書き込み経路とします。公開より先に永続化します。 ViewModelのコマンドは名前付きのリポジトリーメソッドを呼び出してコミットし、その後にストリームが再発行されてUIが再ビルドされます。楽観的な再発行、setState、手動でのキャッシュ更新、「後で保存」は禁止です。
  9. 保存せずに導出します。 件数、合計、フィルター済みリスト、連続記録は、信頼できる唯一の情報源から(派生/ストリームプロバイダーで)計算します。同期がずれる可能性のあるカウンターを別途保存してはいけません。
  10. リポジトリーで行を値オブジェクトに変換し、ViewModelでは値オブジェクトを受け渡します。 Driftの行、コンパニオン、Map をViewModelやウィジェットに渡してはいけません。計測量には値オブジェクトを使用します(value-objects-money-and-units を参照)。金額を単なる double で表してはいけません。
  11. エラーは AsyncValue として公開する型付きの値です。 リポジトリーはsealed Result/Failure を返し、ViewModelは AsyncData/AsyncError に変換します。Viewは網羅的に switch し、表示レイヤーで Failure をローカライズします。例外の生の文字列を使ってはいけません。(error-handling-typed-results を参照してください。)
  12. 型付きルートを使って1つのgo_routerに登録し、識別情報はパスパラメーターで渡します。 TypedGoRoute/GoRouteData を追加し、ディープリンク可能な識別情報はパスパラメーター(:taskId)で渡します。コールドスタート/OSによる復元時にnullになる state.extra は使いません。全画面の追加/編集フローには parentNavigatorKey: rootNavigatorKey を設定します。ルーター自体(シェル/ブランチ構造、リダイレクト/認証ガード、ディープリンク、画面遷移)は navigation-and-routing が管理します。この機能では、そのルーターにルートを登録するだけです。
  13. Viewまたはルーターのリダイレクトから遷移し、ViewModelからは遷移しません。 コントローラーは状態を公開し、画面に何を表示するかはViewまたは redirect が決めます。ルート遷移を行うコントローラーはロジックをナビゲーションに結合し、ヘッドレステストを妨げます。
  14. 現在時刻は注入し、機能内で DateTime.now() を呼び出してはいけません。 ViewModelは Clock プロバイダーから時刻を取得するため、固定したフェイククロックを使ってフロー全体を検証できます。(async-safety を参照してください。)
  15. 方向に依存しないジオメトリーとローカライズ済み文字列のみを使います。 EdgeInsetsDirectional/AlignmentDirectional を使い、.left/.right、Alignment.centerLeft、TextAlign.left は使いません。ユーザーに表示する文字列はすべてgen-l10nから取得し、すべてのロケールARB間でキーとプレースホルダーを一致させます。(i18n-rtl-l10n を参照してください。)
  16. ViewModelのテストではNotifierではなくリポジトリーをフェイクにします。 ProviderContainer.test() と overrideWith でフェイクリポジトリーを設定し、clockProvider.overrideWithValue(Clock.fixed(t)) を指定します。ウィジェットのpumpは行わず、配線とUI状態へのマッピングのみを検証します。(testing-strategy を参照してください。)

標準的な Notifier +スコープ付きストリーム

デフォルトでは手書きのプロバイダーを使用します(state-management-riverpod がこの規律を担います。@riverpod コード生成は任意の代替手段であり、デフォルトではありません)。スコープ付きストリームとコマンド用 Notifier は、<feature>_providers.dart/<feature>_notifier.dart にまとめて配置します。ファイル全体: examples/tasks_notifier.dart。

// features/tasks/presentation/tasks_providers.dart — reactive backbone, scoped so one write is not app-wide.
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:app/core/result.dart';          // Result, Ok, Err, Failure (shared foundation, lib/core/)
import 'package:app/data/task_repository.dart';  // taskRepositoryProvider (keepAlive; lives in lib/data/)
import '../domain/task.dart';                     // feature-local immutable model (value objects, not rows)

/// Wrap the repository's scoped `.watch()` in a family StreamProvider, keyed by projectId.
/// `.autoDispose` frees the subscription when the screen unmounts. The repository maps rows ->
/// domain models at the boundary; this never sees a Drift row.
final taskListProvider =
    StreamProvider.autoDispose.family<List<Task>, String>((ref, projectId) =>
        ref.watch(taskRepositoryProvider).watchTasks(projectId));
// features/tasks/presentation/tasks_notifier.dart — the 1:1 ViewModel: command state + writes.
final tasksNotifierProvider =
    AsyncNotifierProvider.autoDispose<TasksNotifier, void>(TasksNotifier.new);

class TasksNotifier extends AsyncNotifier<void> {
  @override
  FutureOr<void> build() {} // no synchronous heavy work here

  /// A command the View binds. The write goes through ONE repository method (single write path);
  /// the typed Result is mapped to AsyncValue for the View to switch on exhaustively.
  Future<void> add(Task draft) async {
    state = const AsyncLoading();
    final Result<void, Failure> result =
        await ref.read(taskRepositoryProvider).addTask(draft);
    state = switch (result) {
      Ok() => const AsyncData(null),
      Err(:final failure) => AsyncError(failure, StackTrace.current),
    };
    // No manual republish: the committed write makes the Drift stream re-emit and the list rebuilds.
  }
}

シンプルな View

View はスコープ付きストリームを監視し、.when の中で描画して、コールバックから ref.read(...notifier) を呼び出します。単発の副作用には、コントローラーに対して ref.listen を使います。ファイル全体:examples/tasks_screen.dart。

class TasksScreen extends ConsumerWidget {
  const TasksScreen({required this.projectId, super.key});
  final String projectId;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final l10n = AppLocalizations.of(context);

    ref.listen(tasksNotifierProvider, (prev, next) {             // one-shot effects only
      if (next case AsyncError(:final error)) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text(localizeFailure(l10n, error))), // typed Failure -> localized text
        );
      }
    });

    final async = ref.watch(taskListProvider(projectId));        // watch in build
    return Scaffold(
      appBar: AppBar(title: Text(l10n.tasksTitle)),
      body: async.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (e, _) => FailureView(message: localizeFailure(l10n, e)),
        data: (tasks) => tasks.isEmpty
            ? EmptyState(title: l10n.tasksEmptyTitle)
            : ListView.builder(
                padding: const EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 8),
                itemCount: tasks.length,
                itemBuilder: (context, i) => TaskTile(
                  task: tasks[i],
                  onTap: () => TaskDetailRoute(               // navigate by ID, from the View
                    projectId: projectId,
                    taskId: tasks[i].id,
                  ).go(context),
                ),
              ),
      ),
    );
  }
}

ジェネレーターでは実行できない、手動で行う 2 つの手順

  1. 単一の go_router にルートを登録し(TypedGoRoute、パスパラメーターによる ID、 全画面フローでは parentNavigatorKey: rootNavigatorKey)、その後 build_runner で型付きルートを再生成します。references/routing-and-l10n.md を参照してください。
  2. まずテンプレートの ARB に文字列を追加し、すべてのロケールの ARB に同じプレースホルダー名と ICU 構造で反映します。整合性チェックを実行し、次にコード生成を行い、その後解析します。

オプション:機能が新しい永続化レコードを読み取る場合

画面がまだ存在しないレコードを必要とする場合は、機能を接続する前に次の一連の処理を追加します: 不変の値型(純粋で、ストアをインポートしない)→ スキーマ不変条件を備えた Drift テーブル → 行を値型にマッピングする DAO → 1 つのトランザクションを開くリポジトリメソッド。その後、機能は通常どおり、スコープ付きの .watch() ストリームを読み取ります。詳しい手順は persistence-drift と run-migration に記載されています。簡潔な案内については references/add-persisted-record.md を参照してください。

アンチパターン

  • 画面しか必要ないのに新しいパッケージを作る。 画面はフォルダーであり、パッケージは境界に関する判断です。
  • 1 つのファイルに主要な型を 2 つ置くこと、Riverpod の場合に ViewModel を <feature>_view_model.dart/*Controller と命名すること、または規約で _screen.dart を使うところで View を <feature>_view.dart と命名すること。 ファイルごとに主要な公開型を 1 つだけ置き、ファイル名はその宣言名にします(naming-conventions)。Riverpod の ViewModel は <feature>_notifier.dart → <Feature>Notifier です。
  • build() 内でリポジトリ/エンジンを呼び出すことや、try/catch によるビジネスロジック。 View は 1 つのコントローラーを読み取って描画し、ロジックは ViewModel に置きます。
  • ウィジェットや ViewModel から Drift/secure-storage/プラットフォームチャネルにアクセスすること。 読み取りにはリポジトリプロバイダーを使用します。行の形状はデータレイヤーの内部情報です。
  • コミット前に楽観的に再公開すること、setState、手動でのキャッシュ更新、または「後で保存」するデバウンス処理。 すべての書き込みは、永続化してからストリームを再送出する 1 つのリポジトリメソッドで行います。
  • 保存済みのカウンターを正しい値として扱うこと。 読み取り時に、件数/合計/連続記録を信頼できる情報源から算出します。
  • キーのない「現在のエンティティ」プロバイダー、またはアプリスコープのシングルトンに autoDispose を指定すること。 キーごとの状態には family + autoDispose を使い、シングルトンには keepAlive を使います。
  • 機能内で DateTime.now() を使うこと。 フローをアサートできるよう、注入された Clock プロバイダーから読み取ります。
  • ディープリンク可能な ID に state.extra を使うこと、または主要なフローで Navigator.push を使うこと。 パスパラメーターと型付きの GoRoute を使います。ナビゲーションを決めるのは View/リダイレクトであり、コントローラーではありません。
  • 別の機能のフォルダーをインポートすること。 依存先は下位に限り、横方向には依存しません。
  • 左/右の位置をハードコードすること、または UI のリテラルを直接記述すること。 EdgeInsetsDirectional と gen-l10n の文字列を使います。
  • リポジトリを偽装せず、テストで Notifier をモックすること。 接合部を偽装し、配線をテストします。

完了の定義

  • 機能は lib/features/<feature>/ フォルダーであり、presentation/ 配下に固定の構成を備えています。<feature>_screen.dart(ロジックを持たない View)、<feature>_notifier.dart(1:1 の Notifier/AsyncNotifier)、widgets/、スコープ付きの <feature>_providers.dart を配置します。
  • View はコントローラーをちょうど 1 つ読み取り、描画します。build() 内でリポジトリーを呼び出したり、ビジネスロジックに関する try/catch を使ったりしません。.when で穏やかなエラーを表示します。
  • 依存関係は下位にのみ向けます。他の機能のフォルダーをインポートしません。機能内では Drift/DAO/セキュアストレージ/プラットフォームチャネルをインポートしません。
  • 読み取りには、リポジトリーの .watch() を使うスコープ付き StreamProvider を使用します。キーごとのプロバイダーは安定した値をキーとする family とし、autoDispose にします。シングルトンには keepAlive を使用します。
  • すべての変更処理は、名前付きのリポジトリーメソッド 1 つ(書き込み経路は 1 つ)を経由し、再公開する前にコミットします。派生状態は読み取り時に計算し、保存しません。
  • ViewModel は値オブジェクトを渡します。Drift の行/コンパニオン/Map をウィジェットに渡しません。金額に単なる double を使いません。
  • エラーは sealed Result/Failure とし、AsyncValue にマッピングします。View はすべてのケースを網羅して切り替え、境界で Failure をローカライズします。
  • 「現在時刻」は注入された Clock から取得します。機能内で DateTime.now() を使用しません。
  • 型付き GoRoute を単一のルーターに登録します。識別情報はパスパラメーターに含めます。全画面フローでは parentNavigatorKey を設定し、ViewModel からはナビゲーションしません。
  • 文字列には gen-l10n を使用し、テンプレートを先に作成してからパリティを確認します。ジオメトリには Directional のみを使用します。
  • ViewModel は ProviderContainer.test() とフェイクのリポジトリー、固定の Clock(モックの Notifier ではなく、clockProvider.overrideWithValue(Clock.fixed(t)))でテストします。
  • dart format と dart analyze --fatal-infos がクリーンであり、scripts/verify_feature.sh が成功します。

関連スキル

  • flutter-architecture — このスキャフォールドが従うレイヤーモデルと、下位方向のみの DAG。
  • state-management-riverpod — Notifier/AsyncNotifier の規律、watch/read/listen、family+autoDispose、手動プロバイダー優先の方針。
  • navigation-and-routing — この機能が型付きルートを登録する、単一の go_router を管理します。
  • forms-and-input — この機能が起動する追加/編集フローの Form/バリデーション/フォーカスの規律。
  • widget-composition — ロジックを持たない View、小さな const ウィジェットクラス、構造的なレイアウト。
  • error-handling-typed-results — AsyncValue として公開される sealed Result/Failure の基盤。
  • persistence-drift と run-migration — 新しいレコードを追加する際の、リポジトリー/DAO/テーブル/マイグレーションの手順。
  • i18n-rtl-l10n — ARB のパリティと Directional ジオメトリの契約。
  • value-objects-money-and-units — ViewModel が渡す標準の値オブジェクト。
  • testing-strategy — ViewModel をヘッドレスでテストするための、リポジトリー(および clockProvider)のフェイク化。
  • project-structure-and-packages — 単一パッケージのツリーを管理します。基盤がフォルダーではなくパッケージとして成立する場合に使用します。

参考資料

Provider/ChangeNotifier 付録

公式の Flutter ガイドのスタック(package:provider + ChangeNotifier)を使用していますか?ルールは同じで、配線だけが変わります。

  • ViewModel は ChangeNotifier です。インテントメソッドはプライベートフィールドを変更し、最後に一度だけ notifyListeners() を呼び出します(引き続き、呼び出しごとに不変な状態スナップショット 1 つ分の変更です)。
  • 機能のルートで ChangeNotifierProvider を使って提供します。View は build 内で context.watch<TasksViewModel>()、コールバック内で context.read<TasksViewModel>() を使って読み取ります。 Riverpod と同じく、build 内では watch、コールバック内では read を使い分けます。
  • リアクティブな読み取りには、リポジトリの Stream を公開し、StreamProvider(provider パッケージの StreamProvider または StreamBuilder)で畳み込みます。ViewModel 内に変更可能なコピーを キャッシュしてはいけません。emit のたびに導出します。
  • 画面ごとのスコープは family + autoDispose の代わりになります。画面で ChangeNotifierProvider を作成して ルートとともに破棄し、アプリスコープのサービスはルートプロバイダーに保持します。
  • コントローラーやサブスクリプションは ChangeNotifier.dispose() で破棄します。

それ以外はすべて変わりません。つまり、シンプルな View、単一の書き込み経路、型付きの Result/Failure、 パスパラメーターによるルーティング、注入されたクロック、ARB の同等性、Directional ジオメトリ、 テストでのリポジトリのフェイク化はそのままです。

コメント