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.
How do I install this agent skill?
npx skills add https://github.com/zakariaf/flutter-skills --skill scaffold-feature-moduleIs this agent skill safe to install?
- Gen Agent Trust Hubpass
The skill is a scaffolding and linting utility for Flutter applications. It provides shell scripts to generate architectural boilerplate and verify coding standards. Analysis found no security risks, malicious patterns, or unsafe data handling.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
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.
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/zakariaf/flutter-skills/scaffold-feature-module">View scaffold-feature-module on skillZs</a>