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
npx -y skills add zakariaf/Flutter-Skills --skill scaffold-feature-moduleAssembled 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, 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.
What ships with it: 7 files
33.1 KB alongside SKILL.md, 2 of them executable
examples/
- tasks_notifier.dart3.3 KB
- tasks_screen.dart3.7 KB
references/
- add-persisted-record.md4.8 KB
- anatomy-and-wiring.md6.9 KB
- routing-and-l10n.md5.6 KB
scripts/
- scaffold_feature.shruns5.2 KB
- verify_feature.shruns3.6 KB