agentsclimarketplace

Scaffold feature module

Skill zakariaf/Flutter-Skills/skills/scaffold-feature-module

Stands up one navigable feature the same way every time — a fixed lib/features/<feature>/presentation/ folder (dumb View + one 1:1 Notifier/AsyncNotifier ViewModel + widgets/ + scoped <feature>_providers.dart), downward-only dependencies with no cross-feature imports, reactive reads as scoped StreamProviders over a repository .watch(), every mutation through one repository single-write-path method, typed Result/Failure surfaced as AsyncValue, a typed go_router route carrying identity in path params, ARB parity across locales, family+autoDispose keying, and EdgeInsetsDirectional-only geometry. Use when adding a screen, tab, or feature folder; wiring a feature Notifier/ViewModel or a scoped stream provider; registering a route; adding ARB keys for a new screen; splitting an over-grown feature; or adding a persisted record its View reads.From its SKILL.md

Install
npx -y skills add zakariaf/Flutter-Skills --skill scaffold-feature-module

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

2 things to look at

  • 19 days oldThe repository was created 19 days ago. New is not bad, but a brand new repository carrying a familiar-sounding name is the shape a typosquat arrives in, and there has been no time for anyone else to find a problem with it.
  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

18.7 KB, ~4.2k tokens by cl100k_base, as published. Nobody here has run it

Scaffold a feature module

Stand up one feature the same way every time: a folder lib/features/<feature>/ whose presentation/ holds a dumb View, its 1:1 Notifier/AsyncNotifier ViewModel, leaf widgets/, and scoped providers (feature-local models live in an optional domain/). A feature is a folder, not a package. It reads shared repositories from lib/data/ and shared foundation from lib/core/, routes every write through them, and depends only downward — never sideways into another feature. (The single-package tree is owned by project-structure-and-packages.)

Agnostic core (holds under any state library): the View is dumb; one ViewModel per feature owns private mutable state and exposes intent methods that assign a new immutable value; reads derive from the repository (the single source of truth), writes go through one repository method (the single write path, persist-before-publish); the feature depends on abstractions and navigates by route ID. The Riverpod-first "how" below wires that with Notifiers, scoped StreamProviders, and providers-as-DI.

Read the reference for the task at hand:

  • references/anatomy-and-wiring.md — the fixed folder shape, the Notifier + scoped stream, watch/read/listen, family+autoDispose vs keepAlive, the single write path, and the ViewModel test.
  • references/routing-and-l10n.md — typed go_router registration (path params, not state.extra) and the ARB parity workflow.
  • references/add-persisted-record.md — the optional model → table → DAO → repository chain when the feature reads a brand-new record (defers to persistence-drift).

Run scripts/scaffold_feature.sh <feature> to generate the skeleton, then scripts/verify_feature.sh before a PR.

Non-negotiable rules

  1. A feature is a FOLDER, never a new package. lib/features/<feature>/, lower_snake_case, named for the screen (tasks, task_detail, settings, onboarding). A new folder is a normal addition; a new package is a deliberate boundary decision (see project-structure-and-packages). Do not invent numbered-folder or fixed-package-count conventions. The app package does not use lib/src/ — that is a multi-package convention (see project-structure-and-packages).
  2. Fixed anatomy, one primary public type per file. Under lib/features/<feature>/presentation/: <feature>_screen.dart (the dumb View), <feature>_notifier.dart (the 1:1 Notifier/AsyncNotifier ViewModel — file name = its primary declaration, per naming-conventions), widgets/ (leaf views), and <feature>_providers.dart (providers scoped to this feature, never global). A feature-local model lives in an optional domain/<feature>.dart. Predictable shape = a reviewer finds any piece in one second.
  3. The View is dumb; the ViewModel is 1:1. The View reads exactly one controller and renders — only show/hide ifs, layout, animation, and navigation. No repository call, no try/catch business logic, no unit math or formatting in build(). Logic lives in the ViewModel.
  4. A feature NEVER imports another feature. Share via a foundation layer (core/data), or navigate by route ID. Dependencies point down only — a lint/grep gate fails a cross-feature import. Sideways coupling turns two features into one un-deletable knot.
  5. Read the store only through repository providers; wrap .watch() in a scoped StreamProvider. Never touch Drift, secure storage, or a platform channel from a widget or ViewModel. Scope the stream by its key (an owner/parent id, a time window) so one write never re-emits app-wide.
  6. ref.watch in build, ref.read in callbacks, ref.listen for one-shot effects (SnackBar, navigate). Watching in a callback re-subscribes; reading in build misses rebuilds. (See state-management-riverpod for the full split.)
  7. Per-key state is family + autoDispose; singletons are keepAlive. Any provider scoped to a specific entity/session is family-keyed on a stable equatable value and autoDisposed so it dies with its screen. Never an un-keyed "current thing" provider; never autoDispose the app-scope db/service singletons.
  8. Every mutation is one repository method — the single write path, persist-before-publish. A command on the ViewModel calls a named repository method that commits, then the stream re-emits and the UI rebuilds. No optimistic republish, no setState, no manual cache poke, no "save later".
  9. Derive, don't store. Counts, totals, filtered lists, streaks are computed from the source of truth (a derived/stream provider), never a second stored counter that drifts out of sync.
  10. Repositories map rows → value objects; the ViewModel passes value objects. A Drift row, companion, or Map never reaches a ViewModel or widget. Measured quantities are value objects (see value-objects-money-and-units) — never a bare double for money.
  11. Errors are typed values surfaced as AsyncValue. Repositories return a sealed Result/ Failure; the ViewModel maps it to AsyncData/AsyncError; the View switches exhaustively and localizes the Failure at the presentation edge — never a raw exception string. (See error-handling-typed-results.)
  12. Register in the ONE go_router with a typed route; identity rides in path params. Add a TypedGoRoute/GoRouteData; carry deep-linkable identity in path params (:taskId), never state.extra (null on cold start/OS restore). Full-screen add/edit flows set parentNavigatorKey: rootNavigatorKey. The router itself — shell/branch structure, redirect/auth guards, deep links, transitions — is owned by navigation-and-routing; this feature only registers its route into it.
  13. Navigate from the View or a router redirect — never from the ViewModel. The controller publishes state; the View or a redirect decides what is on screen. A controller that pushes a route couples logic to navigation and breaks headless tests.
  14. Inject "now"; never call DateTime.now() in a feature. The ViewModel reads a Clock provider so a whole flow is assertable with a pinned fake clock. (See async-safety.)
  15. Directional geometry and localized strings only. EdgeInsetsDirectional/AlignmentDirectional — never .left/.right, Alignment.centerLeft, or TextAlign.left. Every user-facing string comes from gen-l10n across all locale ARBs with key + placeholder parity. (See i18n-rtl-l10n.)
  16. Test the ViewModel by faking the repository, not the Notifier. ProviderContainer.test() + overrideWith a fake repository and clockProvider.overrideWithValue(Clock.fixed(t)); no widget pump; verify wiring and UI-state mapping only. (See testing-strategy.)

The canonical Notifier + scoped stream

Default to hand-written providers (state-management-riverpod owns the discipline; @riverpod codegen is an optional alternative, not the default). Colocate the scoped stream and the command Notifier in <feature>_providers.dart / <feature>_notifier.dart. Full file: 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.
  }
}

The dumb View

The View watches the scoped stream, renders inside .when, and calls ref.read(...notifier) from callbacks. It ref.listens the controller for one-shot effects. Full file: 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),
                ),
              ),
      ),
    );
  }
}

The two manual steps the generator can't do

  1. Register the route in the single go_router (TypedGoRoute, path-param identity, parentNavigatorKey: rootNavigatorKey for full-screen flows), then regenerate typed routes with build_runner. See references/routing-and-l10n.md.
  2. Add the strings to the template ARB first, then mirror into every locale ARB with identical placeholder names and ICU structure. Run the parity check, then codegen, then analyze.

Optional: the feature reads a brand-new persisted record

If the screen needs a record that does not exist yet, add the chain before wiring the feature: an immutable value type (pure, no store import) → a Drift table with schema invariants → a DAO that maps rows to the value type → a repository method that opens one transaction. Then the feature reads its scoped .watch() stream as usual. The full ritual lives in persistence-drift and run-migration; references/add-persisted-record.md is the short bridge.

Anti-patterns

  • A new package when you only need a screen. A screen is a folder; a package is a boundary decision.
  • Two primary types in one file, naming the ViewModel <feature>_view_model.dart/*Controller on the Riverpod path, or naming the View <feature>_view.dart when the convention is _screen.dart. One primary public type per file, file name = its declaration (naming-conventions); the Riverpod ViewModel is <feature>_notifier.dart<Feature>Notifier.
  • Repository/engine calls or try/catch business logic in build(). The View reads one controller and renders; logic lives in the ViewModel.
  • Touching Drift/secure-storage/a platform channel from a widget or ViewModel. Reads go through a repository provider; the row shape is a data-layer secret.
  • Optimistic republish before commit, setState, a manual cache poke, or a debounced "save later". Every write is one repository method that persists, then the stream re-emits.
  • A stored counter trusted as truth. Derive counts/totals/streaks from the source of truth on read.
  • An un-keyed "current entity" provider, or autoDispose on app-scope singletons. Per-key state is family + autoDispose; singletons are keepAlive.
  • DateTime.now() in a feature. Read an injected Clock provider so flows are assertable.
  • state.extra for deep-linkable identity, or Navigator.push of a primary flow. Path params + a typed GoRoute; the View/redirect decides navigation, not the controller.
  • Importing another feature's folder. Depend down, never sideways.
  • Hard-coded left/right geometry or an inline UI literal. EdgeInsetsDirectional + strings from gen-l10n.
  • Mocking the Notifier in tests instead of faking the repository. Fake the seam; test the wiring.

Definition of done

  • Feature is a folder lib/features/<feature>/ with the fixed anatomy under presentation/: <feature>_screen.dart (dumb View), <feature>_notifier.dart (1:1 Notifier/AsyncNotifier), widgets/, scoped <feature>_providers.dart.
  • The View reads exactly one controller and renders; no repository call or business try/catch in build(); .when surfaces a calm error.
  • Dependencies point down only; no import of another feature's folder; no Drift/DAO/secure-storage/platform-channel import in the feature.
  • Reads are scoped StreamProviders over a repository .watch(); per-key providers are family-keyed on a stable value and autoDisposed; singletons are keepAlive.
  • Every mutation routes through one named repository method (single write path), committing before republishing; derived state is computed on read, never stored.
  • The ViewModel passes value objects; no Drift row/companion/Map reaches a widget; money is not a bare double.
  • Errors are a sealed Result/Failure mapped to AsyncValue; the View switches exhaustively and localizes the Failure at the edge.
  • "Now" comes from an injected Clock; no DateTime.now() in the feature.
  • A typed GoRoute is registered in the single router; identity in path params; full-screen flows set parentNavigatorKey; no navigation from the ViewModel.
  • Strings come from gen-l10n with template-first + parity; geometry is Directional-only.
  • ViewModel tested with ProviderContainer.test() + a fake repository and a fixed Clock (clockProvider.overrideWithValue(Clock.fixed(t)), not a mocked Notifier).
  • dart format + dart analyze --fatal-infos clean; scripts/verify_feature.sh passes.

Related skills

  • flutter-architecture — the layer model and downward-only DAG this scaffold obeys.
  • state-management-riverpod — the Notifier/AsyncNotifier discipline, watch/read/listen, family+autoDispose, manual-providers-first.
  • navigation-and-routing — owns the single go_router this feature registers its typed route into.
  • forms-and-input — the Form/validation/focus discipline for the add/edit flow this feature launches.
  • widget-composition — dumb Views, small const widget classes, structural layout.
  • error-handling-typed-results — the sealed Result/Failure spine surfaced as AsyncValue.
  • persistence-drift and run-migration — the repository/DAO/table/migration ritual behind a new record.
  • i18n-rtl-l10n — the ARB parity + Directional-geometry contract.
  • value-objects-money-and-units — the canonical value objects the ViewModel passes.
  • testing-strategy — faking the repository (and clockProvider) to test the ViewModel headlessly.
  • project-structure-and-packages — owns the single-package tree; when a foundation genuinely earns a package instead of a folder.

References

Provider / ChangeNotifier appendix

Using the official Flutter-guide stack (package:provider + ChangeNotifier)? The rules are identical; only the wiring changes:

  • The ViewModel is a ChangeNotifier; intent methods mutate private fields and call notifyListeners() once at the end (still one immutable state snapshot's worth of change per call).
  • Provide it with ChangeNotifierProvider at the feature route; the View reads it with context.watch<TasksViewModel>() in build and context.read<TasksViewModel>() in callbacks — the same watch-in-build / read-in-callbacks split as Riverpod.
  • Reactive reads: expose a repository Stream and fold it with a StreamProvider (the provider package's StreamProvider, or StreamBuilder) — do not cache a mutable copy in the ViewModel; derive on emit.
  • Per-screen scoping replaces family+autoDispose: create the ChangeNotifierProvider at the screen so it disposes with the route; keep app-scope services in a root provider.
  • Dispose controllers/subscriptions in ChangeNotifier.dispose().

Everything else — dumb View, single write path, typed Result/Failure, path-param routing, injected clock, ARB parity, Directional geometry, faking the repository in tests — is unchanged.

What ships with it: 7 files

33.1 KB alongside SKILL.md, 2 of them executable

scripts/

Keep looking

Skills are one crate of 326,861. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.