scaffold-feature-module
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseScaffold a feature module
搭建功能模块
Stand up one feature the same way every time: a folder whose
holds a dumb View, its 1:1 Notifier/AsyncNotifier ViewModel, leaf , and
scoped providers (feature-local models live in an optional ). A feature is a folder, not a
package. It reads shared repositories from and shared foundation from , routes
every write through them, and depends only downward — never sideways into another feature.
(The single-package tree is owned by .)
lib/features/<feature>/presentation/widgets/domain/lib/data/lib/core/project-structure-and-packagesAgnostic 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:
- — the fixed folder shape, the Notifier + scoped stream, watch/read/listen, family+autoDispose vs keepAlive, the single write path, and the ViewModel test.
references/anatomy-and-wiring.md - — typed go_router registration (path params, not
references/routing-and-l10n.md) and the ARB parity workflow.state.extra - — the optional model → table → DAO → repository chain when the feature reads a brand-new record (defers to
references/add-persisted-record.md).persistence-drift
Run to generate the skeleton, then before a PR.
scripts/scaffold_feature.sh <feature>scripts/verify_feature.sh每次都以完全相同的方式搭建一个功能:目录 的 包含无逻辑 View、与其一一对应的 Notifier/AsyncNotifier ViewModel、叶子 ,以及限定作用域的 providers(功能局部 model 位于可选的 中)。功能是一个文件夹,而不是 package。它从 读取共享 repository,从 读取共享 foundation,所有写入都通过它们执行,并且只向下依赖——绝不横向依赖另一个 feature。
(单 package 目录树由 负责。)
lib/features/<feature>/presentation/widgets/domain/lib/data/lib/core/project-structure-and-packages与状态库无关的核心原则(适用于任何状态库):View 是无逻辑的;每个功能仅有一个 ViewModel,负责持有私有可变状态,并暴露 intent 方法来赋予新的不可变值;读取内容派生自 repository(唯一事实来源),写入通过一个 repository 方法执行(单一写入路径,先持久化再发布);功能依赖抽象,并通过 route ID 导航。下面以 Riverpod 优先的“实现方式”通过 Notifier、限定作用域 StreamProviders 和 providers-as-DI 完成连接。
针对当前任务阅读参考文档:
- ——固定目录结构、Notifier 与限定作用域 stream、watch/read/listen、family+autoDispose 与 keepAlive 的选择、单一写入路径,以及 ViewModel 测试。
references/anatomy-and-wiring.md - ——类型化 go_router 注册(使用 path params,而非
references/routing-and-l10n.md)和 ARB parity 工作流。state.extra - ——当功能读取全新记录时,可选的 model → table → DAO → repository 链路(由
references/add-persisted-record.md负责具体实现)。persistence-drift
运行 生成骨架,然后在提交 PR 前运行 。
scripts/scaffold_feature.sh <feature>scripts/verify_feature.shNon-negotiable rules
不可妥协的规则
- A feature is a FOLDER, never a new package. ,
lib/features/<feature>/, named for the screen (lower_snake_case,tasks,task_detail,settings). A new folder is a normal addition; a new package is a deliberate boundary decision (seeonboarding). Do not invent numbered-folder or fixed-package-count conventions. The app package does not useproject-structure-and-packages— that is a multi-package convention (seelib/src/).project-structure-and-packages - Fixed anatomy, one primary public type per file. Under :
lib/features/<feature>/presentation/(the dumb View),<feature>_screen.dart(the 1:1 Notifier/AsyncNotifier ViewModel — file name = its primary declaration, per<feature>_notifier.dart),naming-conventions(leaf views), andwidgets/(providers scoped to this feature, never global). A feature-local model lives in an optional<feature>_providers.dart. Predictable shape = a reviewer finds any piece in one second.domain/<feature>.dart - The View is dumb; the ViewModel is 1:1. The View reads exactly one controller and renders —
only show/hide s, layout, animation, and navigation. No repository call, no
ifbusiness logic, no unit math or formatting intry/catch. Logic lives in the ViewModel.build() - 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 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.
.watch() - in
ref.watch,buildin callbacks,ref.readfor one-shot effects (SnackBar, navigate). Watching in a callback re-subscribes; reading inref.listenmisses rebuilds. (Seebuildfor the full split.)state-management-riverpod - Per-key state is +
family; singletons areautoDispose. Any provider scoped to a specific entity/session iskeepAlive-keyed on a stable equatable value andfamilyd so it dies with its screen. Never an un-keyed "current thing" provider; neverautoDisposethe app-scope db/service singletons.autoDispose - 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 , no manual cache poke, no "save later".
setState - 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 never reaches a ViewModel or widget. Measured quantities are value objects (see
Map) — never a barevalue-objects-money-and-unitsfor money.double - Errors are typed values surfaced as . Repositories return a sealed
AsyncValue/Result; the ViewModel maps it toFailure/AsyncData; the ViewAsyncErrores exhaustively and localizes the Failure at the presentation edge — never a raw exception string. (Seeswitch.)error-handling-typed-results - Register in the ONE go_router with a typed route; identity rides in path params. Add a
/
TypedGoRoute; carry deep-linkable identity in path params (GoRouteData), never:taskId(null on cold start/OS restore). Full-screen add/edit flows setstate.extra. The router itself — shell/branch structure, redirect/auth guards, deep links, transitions — is owned byparentNavigatorKey: rootNavigatorKey; this feature only registers its route into it.navigation-and-routing - Navigate from the View or a router redirect — never from the ViewModel. The controller
publishes state; the View or a decides what is on screen. A controller that pushes a route couples logic to navigation and breaks headless tests.
redirect - Inject "now"; never call in a feature. The ViewModel reads a
DateTime.now()provider so a whole flow is assertable with a pinned fake clock. (SeeClock.)async-safety - Directional geometry and localized strings only. /
EdgeInsetsDirectional— neverAlignmentDirectional,.left/.right, orAlignment.centerLeft. Every user-facing string comes from gen-l10n across all locale ARBs with key + placeholder parity. (SeeTextAlign.left.)i18n-rtl-l10n - Test the ViewModel by faking the repository, not the Notifier. +
ProviderContainer.test()a fake repository andoverrideWith; no widget pump; verify wiring and UI-state mapping only. (SeeclockProvider.overrideWithValue(Clock.fixed(t)).)testing-strategy
- 功能是一个 FOLDER,绝不是一个新 package。 路径为 ,使用
lib/features/<feature>/,并以 screen 命名(lower_snake_case、tasks、task_detail、settings)。新增文件夹属于常规改动;新增 package 则是一项需要明确决策的边界划分(参见onboarding)。不要自行引入带编号的文件夹约定或固定 package 数量约定。应用的 package 不使用project-structure-and-packages——那是一种 multi-package 约定(参见lib/src/)。project-structure-and-packages - 固定结构,每个文件仅有一个主要 public type。 在 下:
lib/features/<feature>/presentation/(无逻辑 View)、<feature>_screen.dart(与 View 一一对应的 Notifier/AsyncNotifier ViewModel——文件名等于其主要声明,遵循<feature>_notifier.dart)、naming-conventions(叶子 View),以及widgets/(限定于该功能的 providers,绝不使用全局 provider)。可选的<feature>_providers.dart中放置功能局部 model。固定结构让 reviewer 能在一秒内找到任意部分。domain/<feature>.dart - View 是无逻辑的;ViewModel 与之 1:1 对应。 View 只读取一个 controller 并进行渲染——中只允许进行显示/隐藏
build()、布局、动画和导航。不得调用 repository,不得在if中编写build()业务逻辑,也不得进行单位换算或格式化。逻辑位于 ViewModel 中。try/catch - 功能绝不导入另一个功能。 共享内容应通过 foundation 层(core/data)提供,或通过 route ID 导航。依赖只能向下——lint/grep gate 会拦截跨功能导入。横向耦合会将两个功能变成一个无法独立删除的紧密结块。
- 仅通过 repository providers 读取 store;将 包裹在限定作用域的 StreamProvider 中。 绝不要从 widget 或 ViewModel 直接访问 Drift、secure storage 或 platform channel。按其 key(owner/parent id、时间窗口等)对 stream 进行限定作用域,确保一次写入不会触发全应用范围的重新发射。
.watch() - 在 中使用
build,在 callback 中使用ref.watch,使用ref.read处理一次性副作用(SnackBar、导航)。在 callback 中 watch 会导致重复订阅;在ref.listen中 read 会错过重建。(完整划分参见build。)state-management-riverpod - 按 key 隔离的状态使用 +
family;单例使用autoDispose。 任何限定于特定实体或 session 的 provider,都应基于稳定且可相等比较的值进行keepAlive键控,并启用family,使其随 screen 一起销毁。绝不使用无 key 的“当前对象”provider;也绝不对 app-scope 的 db/service 单例使用autoDispose。autoDispose - 每次变更都通过一个 repository 方法——单一写入路径,先持久化再发布。 ViewModel 上的 command 调用一个具名的 repository 方法完成提交,随后 stream 重新发射数据并触发 UI 重建。不得乐观发布、不得使用 、不得手动修改缓存、不得“稍后保存”。
setState - 派生数据,不要存储。 数量、总计、筛选后的列表、连续记录等均从事实来源计算得出(使用 derived/stream provider),绝不能存储第二个容易失去同步的计数器。
- Repository 将 row 映射为 value object;ViewModel 传递 value object。 Drift row、companion 或 绝不能进入 ViewModel 或 widget。被测量的数量应使用 value object(参见
Map)——金额绝不能使用裸value-objects-money-and-units。double - 错误是以 AsyncValue 呈现的类型化值。 Repository 返回 sealed /
Result;ViewModel 将其映射为Failure/AsyncData;View 对其执行穷尽的AsyncError,并且在 presentation 边缘对 Failure 进行本地化——绝不能直接显示原始异常字符串。(参见switch。)error-handling-typed-results - 在唯一的 go_router 中注册类型化 route;标识信息通过 path params 传递。 添加 /
TypedGoRoute;通过 path params(GoRouteData)传递支持 deep link 的标识信息,绝不能使用:taskId(在冷启动或 OS 恢复时可能为 null)。全屏新增/编辑流程应设置state.extra。router 本身——shell/branch 结构、redirect/auth guard、deep link、transition——由parentNavigatorKey: rootNavigatorKey负责;当前功能仅负责向其中注册自己的 route。navigation-and-routing - 仅从 View 或 router redirect 执行导航——绝不能从 ViewModel 导航。 Controller 发布状态;由 View 或 决定当前显示内容。由 controller push route 会将逻辑与导航耦合,并破坏 headless 测试。
redirect - 注入“当前时间”;功能中绝不调用 。 ViewModel 读取
DateTime.now()provider,从而可使用固定 fake clock 对整个流程进行断言。(参见Clock。)async-safety - 仅使用方向性几何属性和本地化字符串。 使用 /
EdgeInsetsDirectional——绝不能使用AlignmentDirectional、.left/.right或Alignment.centerLeft。所有面向用户的字符串均来自 gen-l10n,并确保所有 locale ARB 的 key 与 placeholder 保持一致。(参见TextAlign.left。)i18n-rtl-l10n - 测试 ViewModel 时 fake repository,而不是 mock Notifier。 使用 ,通过
ProviderContainer.test()注入 fake repository,并使用overrideWith;无需 pump widget;仅验证接线和 UI 状态映射。(参见clockProvider.overrideWithValue(Clock.fixed(t))。)testing-strategy
The canonical Notifier + scoped stream
规范的 Notifier + 限定作用域 stream
Default to hand-written providers ( owns the discipline;
codegen is an optional alternative, not the default). Colocate the scoped stream and the command
Notifier in / . Full file:
.
state-management-riverpod@riverpod<feature>_providers.dart<feature>_notifier.dartexamples/tasks_notifier.dartdart
// 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));dart
// 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> {
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.
}
}默认采用手写 providers( 负责相关规范; codegen 是可选替代方案,并非默认项)。将限定作用域的 stream 和 command Notifier 分别放在 / 中。完整文件:
。
state-management-riverpod@riverpod<feature>_providers.dart<feature>_notifier.dartexamples/tasks_notifier.dartdart
// 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));dart
// 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> {
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
无逻辑 View
The View watches the scoped stream, renders inside , and calls from
callbacks. It s the controller for one-shot effects. Full file: .
.whenref.read(...notifier)ref.listenexamples/tasks_screen.dartdart
class TasksScreen extends ConsumerWidget {
const TasksScreen({required this.projectId, super.key});
final String projectId;
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),
),
),
),
);
}
}View 监听限定作用域的 stream,在 中渲染,并在 callback 中调用 。它通过 监听 controller,以处理一次性副作用。完整文件:。
.whenref.read(...notifier)ref.listenexamples/tasks_screen.dartdart
class TasksScreen extends ConsumerWidget {
const TasksScreen({required this.projectId, super.key});
final String projectId;
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 (, path-param identity,
TypedGoRoutefor full-screen flows), then regenerate typed routes with build_runner. SeeparentNavigatorKey: rootNavigatorKey.references/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.
- 在单一 go_router 中注册 route(、使用 path param 传递标识信息,并为全屏流程设置
TypedGoRoute),然后使用 build_runner 重新生成类型化 route。参见parentNavigatorKey: rootNavigatorKey。references/routing-and-l10n.md - 将字符串添加到模板 ARB,然后镜像到每个 locale ARB,并确保 placeholder 名称和 ICU 结构完全一致。依次运行 parity check、codegen 和 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 stream as usual. The full ritual lives in and ;
is the short bridge.
.watch()persistence-driftrun-migrationreferences/add-persisted-record.md如果 screen 需要一条尚不存在的记录,请先添加以下链路:
不可变 value type(纯类型,不导入 store)→ 带 schema 不变量的 Drift table → 将 row 映射为 value type 的 DAO → 在单个 transaction 中执行操作的 repository method。随后,功能照常读取其限定作用域的 stream。完整流程见 和 ; 提供了简要衔接说明。
.watch()persistence-driftrun-migrationreferences/add-persisted-record.mdAnti-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.darton the Riverpod path, or naming the View*Controllerwhen the convention is<feature>_view.dart. One primary public type per file, file name = its declaration (_screen.dart); the Riverpod ViewModel isnaming-conventions→<feature>_notifier.dart.<Feature>Notifier - Repository/engine calls or business logic in
try/catch. The View reads one controller and renders; logic lives in the ViewModel.build() - 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, , a manual cache poke, or a debounced "save later". Every write is one repository method that persists, then the stream re-emits.
setState - A stored counter trusted as truth. Derive counts/totals/streaks from the source of truth on read.
- An un-keyed "current entity" provider, or on app-scope singletons. Per-key state is
autoDispose+family; singletons areautoDispose.keepAlive - in a feature. Read an injected
DateTime.now()provider so flows are assertable.Clock - for deep-linkable identity, or
state.extraof a primary flow. Path params + a typedNavigator.push; the View/redirect decides navigation, not the controller.GoRoute - Importing another feature's folder. Depend down, never sideways.
- Hard-coded left/right geometry or an inline UI literal. + strings from gen-l10n.
EdgeInsetsDirectional - Mocking the in tests instead of faking the repository. Fake the seam; test the wiring.
Notifier
- 只需要一个 screen 却新建 package。 screen 是文件夹;package 是边界决策。
- 一个文件中有两个主要类型,在 Riverpod 路径上将 ViewModel 命名为 /
<feature>_view_model.dart,或按照*Controller约定却将 View 命名为_screen.dart。 每个文件仅有一个主要 public type,文件名等于其声明(<feature>_view.dart);Riverpod 路径上的 ViewModel 命名为naming-conventions→<feature>_notifier.dart。<Feature>Notifier - 在 中调用 repository/engine 或编写
build()业务逻辑。 View 只读取一个 controller 并进行渲染;逻辑位于 ViewModel 中。try/catch - 从 widget 或 ViewModel 直接访问 Drift/secure storage/platform channel。 读取操作必须经过 repository provider;row 结构属于 data layer 的内部细节。
- 提交前乐观发布、使用 、手动修改缓存,或采用 debounced 的“稍后保存”。 每次写入都通过一个 repository 方法完成持久化,随后由 stream 重新发射。
setState - 将存储的计数器视为事实来源。 读取时从事实来源派生数量、总计和连续记录。
- 使用无 key 的“当前实体”provider,或对 app-scope 单例使用 。 按 key 隔离的状态使用
autoDispose+family;单例使用autoDispose。keepAlive - 在功能中调用 。 读取注入的
DateTime.now()provider,确保流程可断言。Clock - 使用 传递支持 deep link 的标识信息,或使用
state.extra发起主要流程。 使用 path params + 类型化Navigator.push;由 View/redirect 决定导航,而不是 controller。GoRoute - 导入另一个 feature 的文件夹。 依赖只能向下,绝不能横向依赖。
- 硬编码 left/right 几何属性或使用内联 UI 字符串。 使用 + 来自 gen-l10n 的字符串。
EdgeInsetsDirectional - 测试时 mock Notifier,而不是 fake repository。 Fake 边界层,测试接线。
Definition of done
完成标准
- Feature is a folder with the fixed anatomy under
lib/features/<feature>/:presentation/(dumb View),<feature>_screen.dart(1:1 Notifier/AsyncNotifier),<feature>_notifier.dart, scopedwidgets/.<feature>_providers.dart - The View reads exactly one controller and renders; no repository call or business in
try/catch;build()surfaces a calm error..when - 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 ; per-key providers are
.watch()-keyed on a stable value andfamilyd; singletons areautoDispose.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/reaches a widget; money is not a bare
Map.double - Errors are a sealed /
Resultmapped toFailure; the View switches exhaustively and localizes the Failure at the edge.AsyncValue - "Now" comes from an injected ; no
Clockin the feature.DateTime.now() - A typed is registered in the single router; identity in path params; full-screen flows set
GoRoute; no navigation from the ViewModel.parentNavigatorKey - Strings come from gen-l10n with template-first + parity; geometry is -only.
Directional - ViewModel tested with + a fake repository and a fixed
ProviderContainer.test()(Clock, not a mocked Notifier).clockProvider.overrideWithValue(Clock.fixed(t)) - +
dart formatclean;dart analyze --fatal-infospasses.scripts/verify_feature.sh
- Feature 是一个文件夹 ,并在
lib/features/<feature>/下具有固定结构:presentation/(无逻辑 View)、<feature>_screen.dart(一一对应的 Notifier/AsyncNotifier)、<feature>_notifier.dart、限定作用域的widgets/。<feature>_providers.dart - View 只读取一个 controller 并进行渲染;中没有 repository 调用或业务
build();使用try/catch以稳定的方式呈现错误。.when - 依赖只向下;不导入另一个 feature 的文件夹;feature 中不包含 Drift/DAO/secure storage/platform channel 导入。
- 读取操作使用基于 repository 的限定作用域 StreamProviders;按 key 隔离的 provider 基于稳定值进行
.watch()键控并启用family;单例使用autoDispose。keepAlive - 每次变更都通过一个具名的 repository 方法执行(单一写入路径),提交后才重新发布;派生状态在读取时计算,绝不存储。
- ViewModel 传递 value object;Drift row/companion/不会进入 widget;金额不使用裸
Map。double - 错误使用 sealed /
Result并映射为Failure;View 执行穷尽的 switch,并在边缘对 Failure 进行本地化。AsyncValue - “当前时间”来自注入的 ;功能中不调用
Clock。DateTime.now() - 类型化 已注册到单一 router;标识信息通过 path params 传递;全屏流程设置
GoRoute;ViewModel 中不执行导航。parentNavigatorKey - 字符串来自 gen-l10n,遵循 template-first + parity;几何属性仅使用 。
Directional - 使用 、fake repository 和固定
ProviderContainer.test()(Clock,而非 mocked Notifier)测试 ViewModel。clockProvider.overrideWithValue(Clock.fixed(t)) - +
dart format检查通过;dart analyze --fatal-infos执行成功。scripts/verify_feature.sh
Related skills
相关 skills
- — the layer model and downward-only DAG this scaffold obeys.
flutter-architecture - — the Notifier/AsyncNotifier discipline, watch/read/listen, family+autoDispose, manual-providers-first.
state-management-riverpod - — owns the single go_router this feature registers its typed route into.
navigation-and-routing - — the Form/validation/focus discipline for the add/edit flow this feature launches.
forms-and-input - — dumb Views, small const widget classes, structural layout.
widget-composition - — the sealed
error-handling-typed-results/Resultspine surfaced asFailure.AsyncValue - and
persistence-drift— the repository/DAO/table/migration ritual behind a new record.run-migration - — the ARB parity + Directional-geometry contract.
i18n-rtl-l10n - — the canonical value objects the ViewModel passes.
value-objects-money-and-units - — faking the repository (and
testing-strategy) to test the ViewModel headlessly.clockProvider - — owns the single-package tree; when a foundation genuinely earns a package instead of a folder.
project-structure-and-packages
- ——本脚手架所遵循的层级模型和仅向下的 DAG。
flutter-architecture - ——Notifier/AsyncNotifier 规范、watch/read/listen、family+autoDispose,以及 manual-providers-first。
state-management-riverpod - ——管理当前 feature 向其注册类型化 route 的单一 go_router。
navigation-and-routing - ——管理当前 feature 所启动的新增/编辑流程中的 Form、校验和 focus 规范。
forms-and-input - ——无逻辑 View、小型 const widget class 和结构化布局。
widget-composition - ——以 AsyncValue 呈现的 sealed
error-handling-typed-results/Result核心机制。Failure - 和
persistence-drift——新记录背后的 repository/DAO/table/migration 流程。run-migration - ——ARB parity + Directional 几何属性约定。
i18n-rtl-l10n - ——ViewModel 传递的标准 value object。
value-objects-money-and-units - ——通过 fake repository 和
testing-strategy对 ViewModel 进行 headless 测试。clockProvider - ——管理单 package 目录树;以及何时 foundation 确实应成为 package 而非文件夹。
project-structure-and-packages
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
- Flutter——应用架构指南:https://docs.flutter.dev/app-architecture
- Riverpod 3.x:https://riverpod.dev
- go_router——类型化 route:https://pub.dev/documentation/go_router/latest/topics/Type-safe%20routes-topic.html
- Flutter——国际化(gen-l10n):https://docs.flutter.dev/ui/accessibility-and-internationalization/internationalization
Provider / ChangeNotifier appendix
Provider / ChangeNotifier 附录
Using the official Flutter-guide stack ( + )? The rules are
identical; only the wiring changes:
package:providerChangeNotifier- The ViewModel is a ; intent methods mutate private fields and call
ChangeNotifieronce at the end (still one immutable state snapshot's worth of change per call).notifyListeners() - Provide it with at the feature route; the View reads it with
ChangeNotifierProviderincontext.watch<TasksViewModel>()andbuildin callbacks — the same watch-in-build / read-in-callbacks split as Riverpod.context.read<TasksViewModel>() - Reactive reads: expose a repository and fold it with a
Stream(theStreamProviderpackage'sprovider, orStreamProvider) — do not cache a mutable copy in the ViewModel; derive on emit.StreamBuilder - Per-screen scoping replaces +
family: create theautoDisposeat the screen so it disposes with the route; keep app-scope services in a root provider.ChangeNotifierProvider - Dispose controllers/subscriptions in .
ChangeNotifier.dispose()
Everything else — dumb View, single write path, typed /, path-param routing,
injected clock, ARB parity, Directional geometry, faking the repository in tests — is unchanged.
ResultFailure使用官方 Flutter 指南技术栈( + )时,规则完全相同,仅连接方式有所变化:
package:providerChangeNotifier- ViewModel 是 ;intent 方法修改私有字段,并在最后调用一次
ChangeNotifier(每次调用仍只产生相当于一个不可变状态快照的变化)。notifyListeners() - 在 feature route 中提供 ;View 在
ChangeNotifierProvider中通过build读取,在 callback 中通过context.watch<TasksViewModel>()读取——与 Riverpod 中 build 内 watch、callback 内 read 的划分相同。context.read<TasksViewModel>() - 响应式读取:暴露 repository ,并使用
Stream(StreamProviderpackage 的provider,或StreamProvider)对其执行 fold——不要在 ViewModel 中缓存可变副本;应在发射时派生数据。StreamBuilder - 使用 per-screen scoping 代替 +
family:在 screen 上创建autoDispose,使其随 route 一起 dispose;将 app-scope service 保存在 root provider 中。ChangeNotifierProvider - 在 中释放 controller 和 subscription。
ChangeNotifier.dispose()
其余内容保持不变——无逻辑 View、单一写入路径、类型化 /、path-param routing、注入 clock、ARB parity、Directional 几何属性,以及在测试中 fake repository。
ResultFailure