scaffold-feature-module
Skill32 starsChanged 4 months agoMITView on GitHub
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.
1 other project uses a version of this.
Adopted by
Most recently taken up by
- zakariaf/CatchLawword for word, first seen Aug 1, 2026
Other versions1 project uses a version of this.
Word for word · 1
- zakariaf/CatchLaw★ 0100% same
Edited · 0
None found.
Wrote their own version · 0
None found.
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository40 other files it gives its agents.
- AGENTS.mdAGENTS.md
- accessibility-as-codeSkill
- adaptive-layoutSkill
- ads-and-iap-monetizationSkill
- app-startup-and-bootstrapSkill
- async-safetySkill
- ci-pipeline-and-gatesSkill
- codegen-and-toolchainSkill
- custom-canvas-and-gesturesSkill
- dart3-idioms-and-coding-standardsSkill
- dartdoc-conventionsSkill
- data-export-and-restoreSkill
- dependency-hygieneSkill
- design-review-workflowSkill
- design-system-structureSkill
- error-handling-typed-resultsSkill
- flutter-architectureSkill
- flutter-conventions-indexSkill
- flutter-performanceSkill
- forms-and-inputSkill
- i18n-rtl-l10nSkill
- lint-and-style-configSkill
- local-notifications-schedulerSkill
- motion-and-hapticsSkill
- naming-conventionsSkill
- navigation-and-routingSkill
- persistence-driftSkill
- project-structure-and-packagesSkill
- release-and-store-shippingSkill
- run-codegenSkill
- run-goldens-rebaselineSkill
- run-migrationSkill
- seeded-determinism-and-golden-vectorsSkill
- service-boundary-and-nativeSkill
- state-management-riverpodSkill
- testing-strategySkill
- ui-states-and-feedbackSkill
- value-objects-money-and-unitsSkill
- widget-compositionSkill
- widget-golden-and-a11y-testingSkill
The file
We split this skill into the rules it gives an agent
7 rules. About 0 are common, 3 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈3
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 21
Read the reference for the task at hand:
- Very specific: line 26
Run scripts/scaffold_feature.sh <feature> to generate the skeleton, then scripts/verify_feature.sh before a PR.
- Very specific: line 33
Do not invent numbered-folder or fixed-package-count conventions.
- Distinctive: line 48
Never touch Drift, secure storage, or a platform channel from a widget or ViewModel.
- Very specific: line 89
Default to hand-written providers (state-management-riverpod owns the discipline.
- Distinctive: line 190
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 …
- Very specific: line 205
DateTime.now()in a feature: Read an injected Clock provider so flows are assertable.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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, notstate.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 topersistence-drift).
Run scripts/scaffold_feature.sh <feature> to generate the skeleton, then scripts/verify_feature.sh before a PR.
Non-negotiable rules
- 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 (seeproject-structure-and-packages). Do not invent numbered-folder or fixed-package-count conventions. The app package does not uselib/src/— that is a multi-package convention (seeproject-structure-and-packages). - 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, pernaming-conventions),widgets/(leaf views), and<feature>_providers.dart(providers scoped to this feature, never global). A feature-local model lives in an optionaldomain/<feature>.dart. Predictable shape = a reviewer finds any piece in one second. - 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, notry/catchbusiness logic, no unit math or formatting inbuild(). Logic lives in the ViewModel. - 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.
- 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. ref.watchinbuild,ref.readin callbacks,ref.listenfor one-shot effects (SnackBar, navigate). Watching in a callback re-subscribes; reading inbuildmisses rebuilds. (Seestate-management-riverpodfor the full split.)- Per-key state is
family+autoDispose; singletons arekeepAlive. Any provider scoped to a specific entity/session isfamily-keyed on a stable equatable value andautoDisposed so it dies with its screen. Never an un-keyed "current thing" provider; neverautoDisposethe app-scope db/service singletons. - 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". - 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.
- Repositories map rows → value objects; the ViewModel passes value objects. A Drift row,
companion, or
Mapnever reaches a ViewModel or widget. Measured quantities are value objects (seevalue-objects-money-and-units) — never a baredoublefor money. - Errors are typed values surfaced as
AsyncValue. Repositories return a sealedResult/Failure; the ViewModel maps it toAsyncData/AsyncError; the Viewswitches exhaustively and localizes the Failure at the presentation edge — never a raw exception string. (Seeerror-handling-typed-results.) - 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), neverstate.extra(null on cold start/OS restore). Full-screen add/edit flows setparentNavigatorKey: rootNavigatorKey. The router itself — shell/branch structure, redirect/auth guards, deep links, transitions — is owned bynavigation-and-routing; this feature only registers its route into it. - Navigate from the View or a router redirect — never from the ViewModel. The controller
publishes state; the View or a
redirectdecides what is on screen. A controller that pushes a route couples logic to navigation and breaks headless tests. - Inject "now"; never call
DateTime.now()in a feature. The ViewModel reads aClockprovider so a whole flow is assertable with a pinned fake clock. (Seeasync-safety.) - Directional geometry and localized strings only.
EdgeInsetsDirectional/AlignmentDirectional— never.left/.right,Alignment.centerLeft, orTextAlign.left. Every user-facing string comes from gen-l10n across all locale ARBs with key + placeholder parity. (Seei18n-rtl-l10n.) - Test the ViewModel by faking the repository, not the Notifier.
ProviderContainer.test()+overrideWitha fake repository andclockProvider.overrideWithValue(Clock.fixed(t)); no widget pump; verify wiring and UI-state mapping only. (Seetesting-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
- Register the route in the single go_router (
TypedGoRoute, path-param identity,parentNavigatorKey: rootNavigatorKeyfor full-screen flows), then regenerate typed routes with build_runner. Seereferences/routing-and-l10n.md. - 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/*Controlleron the Riverpod path, or naming the View<feature>_view.dartwhen 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/catchbusiness logic inbuild(). 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
autoDisposeon app-scope singletons. Per-key state isfamily+autoDispose; singletons arekeepAlive. DateTime.now()in a feature. Read an injectedClockprovider so flows are assertable.state.extrafor deep-linkable identity, orNavigator.pushof a primary flow. Path params + a typedGoRoute; 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
Notifierin 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 underpresentation/:<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/catchinbuild();.whensurfaces 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 arefamily-keyed on a stable value andautoDisposed; singletons arekeepAlive. - 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/
Mapreaches a widget; money is not a baredouble. - Errors are a sealed
Result/Failuremapped toAsyncValue; the View switches exhaustively and localizes the Failure at the edge. - "Now" comes from an injected
Clock; noDateTime.now()in the feature. - A typed
GoRouteis registered in the single router; identity in path params; full-screen flows setparentNavigatorKey; 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 fixedClock(clockProvider.overrideWithValue(Clock.fixed(t)), not a mocked Notifier). dart format+dart analyze --fatal-infosclean;scripts/verify_feature.shpasses.
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 sealedResult/Failurespine surfaced asAsyncValue.persistence-driftandrun-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 (andclockProvider) 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
- Flutter — Guide to app architecture: https://docs.flutter.dev/app-architecture
- Riverpod 3.x: https://riverpod.dev
- go_router — typed routes: https://pub.dev/documentation/go_router/latest/topics/Type-safe%20routes-topic.html
- Flutter — Internationalization (gen-l10n): https://docs.flutter.dev/ui/accessibility-and-internationalization/internationalization
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 callnotifyListeners()once at the end (still one immutable state snapshot's worth of change per call). - Provide it with
ChangeNotifierProviderat the feature route; the View reads it withcontext.watch<TasksViewModel>()inbuildandcontext.read<TasksViewModel>()in callbacks — the same watch-in-build / read-in-callbacks split as Riverpod. - Reactive reads: expose a repository
Streamand fold it with aStreamProvider(theproviderpackage'sStreamProvider, orStreamBuilder) — do not cache a mutable copy in the ViewModel; derive on emit. - Per-screen scoping replaces
family+autoDispose: create theChangeNotifierProviderat 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.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

