scaffold-feature-module

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

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.
每次都以完全相同的方式搭建一个功能:目录
lib/features/<feature>/
的
presentation/
包含无逻辑 View、与其一一对应的 Notifier/AsyncNotifier ViewModel、叶子
widgets/
,以及限定作用域的 providers(功能局部 model 位于可选的
domain/
中)。功能是一个文件夹,而不是 package。它从
lib/data/
读取共享 repository,从
lib/core/
读取共享 foundation,所有写入都通过它们执行,并且只向下依赖——绝不横向依赖另一个 feature。 (单 package 目录树由
project-structure-and-packages
负责。)
与状态库无关的核心原则(适用于任何状态库):View 是无逻辑的;每个功能仅有一个 ViewModel,负责持有私有可变状态,并暴露 intent 方法来赋予新的不可变值;读取内容派生自 repository(唯一事实来源),写入通过一个 repository 方法执行(单一写入路径,先持久化再发布);功能依赖抽象,并通过 route ID 导航。下面以 Riverpod 优先的“实现方式”通过 Notifier、限定作用域 StreamProviders 和 providers-as-DI 完成连接。
针对当前任务阅读参考文档:
  • references/anatomy-and-wiring.md
    ——固定目录结构、Notifier 与限定作用域 stream、watch/read/listen、family+autoDispose 与 keepAlive 的选择、单一写入路径,以及 ViewModel 测试。
  • references/routing-and-l10n.md
    ——类型化 go_router 注册(使用 path params,而非
    state.extra
    )和 ARB parity 工作流。
  • references/add-persisted-record.md
    ——当功能读取全新记录时,可选的 model → table → DAO → repository 链路(由
    persistence-drift
    负责具体实现)。
运行
scripts/scaffold_feature.sh <feature>
生成骨架,然后在提交 PR 前运行
scripts/verify_feature.sh
。

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
    if
    s, 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
    autoDispose
    d 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
    switch
    es 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
    .)
  1. 功能是一个 FOLDER,绝不是一个新 package。 路径为
    lib/features/<feature>/
    ,使用
    lower_snake_case
    ,并以 screen 命名(
    tasks
    、
    task_detail
    、
    settings
    、
    onboarding
    )。新增文件夹属于常规改动;新增 package 则是一项需要明确决策的边界划分(参见
    project-structure-and-packages
    )。不要自行引入带编号的文件夹约定或固定 package 数量约定。应用的 package 不使用
    lib/src/
    ——那是一种 multi-package 约定(参见
    project-structure-and-packages
    )。
  2. 固定结构,每个文件仅有一个主要 public type。 在
    lib/features/<feature>/presentation/
    下:
    <feature>_screen.dart
    (无逻辑 View)、
    <feature>_notifier.dart
    (与 View 一一对应的 Notifier/AsyncNotifier ViewModel——文件名等于其主要声明,遵循
    naming-conventions
    )、
    widgets/
    (叶子 View),以及
    <feature>_providers.dart
    (限定于该功能的 providers,绝不使用全局 provider)。可选的
    domain/<feature>.dart
    中放置功能局部 model。固定结构让 reviewer 能在一秒内找到任意部分。
  3. View 是无逻辑的;ViewModel 与之 1:1 对应。 View 只读取一个 controller 并进行渲染——
    build()
    中只允许进行显示/隐藏
    if
    、布局、动画和导航。不得调用 repository,不得在
    build()
    中编写
    try/catch
    业务逻辑,也不得进行单位换算或格式化。逻辑位于 ViewModel 中。
  4. 功能绝不导入另一个功能。 共享内容应通过 foundation 层(core/data)提供,或通过 route ID 导航。依赖只能向下——lint/grep gate 会拦截跨功能导入。横向耦合会将两个功能变成一个无法独立删除的紧密结块。
  5. 仅通过 repository providers 读取 store;将
    .watch()
    包裹在限定作用域的 StreamProvider 中。
    绝不要从 widget 或 ViewModel 直接访问 Drift、secure storage 或 platform channel。按其 key(owner/parent id、时间窗口等)对 stream 进行限定作用域,确保一次写入不会触发全应用范围的重新发射。
  6. 在
    build
    中使用
    ref.watch
    ,在 callback 中使用
    ref.read
    ,使用
    ref.listen
    处理一次性副作用
    (SnackBar、导航)。在 callback 中 watch 会导致重复订阅;在
    build
    中 read 会错过重建。(完整划分参见
    state-management-riverpod
    。)
  7. 按 key 隔离的状态使用
    family
    +
    autoDispose
    ;单例使用
    keepAlive
    。
    任何限定于特定实体或 session 的 provider,都应基于稳定且可相等比较的值进行
    family
    键控,并启用
    autoDispose
    ,使其随 screen 一起销毁。绝不使用无 key 的“当前对象”provider;也绝不对 app-scope 的 db/service 单例使用
    autoDispose
    。
  8. 每次变更都通过一个 repository 方法——单一写入路径,先持久化再发布。 ViewModel 上的 command 调用一个具名的 repository 方法完成提交,随后 stream 重新发射数据并触发 UI 重建。不得乐观发布、不得使用
    setState
    、不得手动修改缓存、不得“稍后保存”。
  9. 派生数据,不要存储。 数量、总计、筛选后的列表、连续记录等均从事实来源计算得出(使用 derived/stream provider),绝不能存储第二个容易失去同步的计数器。
  10. Repository 将 row 映射为 value object;ViewModel 传递 value object。 Drift row、companion 或
    Map
    绝不能进入 ViewModel 或 widget。被测量的数量应使用 value object(参见
    value-objects-money-and-units
    )——金额绝不能使用裸
    double
    。
  11. 错误是以 AsyncValue 呈现的类型化值。 Repository 返回 sealed
    Result
    /
    Failure
    ;ViewModel 将其映射为
    AsyncData
    /
    AsyncError
    ;View 对其执行穷尽的
    switch
    ,并且在 presentation 边缘对 Failure 进行本地化——绝不能直接显示原始异常字符串。(参见
    error-handling-typed-results
    。)
  12. 在唯一的 go_router 中注册类型化 route;标识信息通过 path params 传递。 添加
    TypedGoRoute
    /
    GoRouteData
    ;通过 path params(
    :taskId
    )传递支持 deep link 的标识信息,绝不能使用
    state.extra
    (在冷启动或 OS 恢复时可能为 null)。全屏新增/编辑流程应设置
    parentNavigatorKey: rootNavigatorKey
    。router 本身——shell/branch 结构、redirect/auth guard、deep link、transition——由
    navigation-and-routing
    负责;当前功能仅负责向其中注册自己的 route。
  13. 仅从 View 或 router redirect 执行导航——绝不能从 ViewModel 导航。 Controller 发布状态;由 View 或
    redirect
    决定当前显示内容。由 controller push route 会将逻辑与导航耦合,并破坏 headless 测试。
  14. 注入“当前时间”;功能中绝不调用
    DateTime.now()
    。
    ViewModel 读取
    Clock
    provider,从而可使用固定 fake clock 对整个流程进行断言。(参见
    async-safety
    。)
  15. 仅使用方向性几何属性和本地化字符串。 使用
    EdgeInsetsDirectional
    /
    AlignmentDirectional
    ——绝不能使用
    .left/.right
    、
    Alignment.centerLeft
    或
    TextAlign.left
    。所有面向用户的字符串均来自 gen-l10n,并确保所有 locale ARB 的 key 与 placeholder 保持一致。(参见
    i18n-rtl-l10n
    。)
  16. 测试 ViewModel 时 fake repository,而不是 mock Notifier。 使用
    ProviderContainer.test()
    ,通过
    overrideWith
    注入 fake repository,并使用
    clockProvider.overrideWithValue(Clock.fixed(t))
    ;无需 pump widget;仅验证接线和 UI 状态映射。(参见
    testing-strategy
    。)

The canonical Notifier + scoped stream

规范的 Notifier + 限定作用域 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
.
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));
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(
state-management-riverpod
负责相关规范;
@riverpod
codegen 是可选替代方案,并非默认项)。将限定作用域的 stream 和 command Notifier 分别放在
<feature>_providers.dart
/
<feature>_notifier.dart
中。完整文件:
examples/tasks_notifier.dart
。
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));
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
.when
, and calls
ref.read(...notifier)
from callbacks. It
ref.listen
s the controller for one-shot effects. Full file:
examples/tasks_screen.dart
.
dart
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,在
.when
中渲染,并在 callback 中调用
ref.read(...notifier)
。它通过
ref.listen
监听 controller,以处理一次性副作用。完整文件:
examples/tasks_screen.dart
。
dart
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

生成器无法完成的两个手动步骤

  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.
  1. 在单一 go_router 中注册 route(
    TypedGoRoute
    、使用 path param 传递标识信息,并为全屏流程设置
    parentNavigatorKey: rootNavigatorKey
    ),然后使用 build_runner 重新生成类型化 route。参见
    references/routing-and-l10n.md
    。
  2. 将字符串添加到模板 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
.watch()
stream as usual. The full ritual lives in
persistence-drift
and
run-migration
;
references/add-persisted-record.md
is the short bridge.
如果 screen 需要一条尚不存在的记录,请先添加以下链路: 不可变 value type(纯类型,不导入 store)→ 带 schema 不变量的 Drift table → 将 row 映射为 value type 的 DAO → 在单个 transaction 中执行操作的 repository method。随后,功能照常读取其限定作用域的
.watch()
stream。完整流程见
persistence-drift
和
run-migration
;
references/add-persisted-record.md
提供了简要衔接说明。

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.
  • 只需要一个 screen 却新建 package。 screen 是文件夹;package 是边界决策。
  • 一个文件中有两个主要类型,在 Riverpod 路径上将 ViewModel 命名为
    <feature>_view_model.dart
    /
    *Controller
    ,或按照
    _screen.dart
    约定却将 View 命名为
    <feature>_view.dart
    。
    每个文件仅有一个主要 public type,文件名等于其声明(
    naming-conventions
    );Riverpod 路径上的 ViewModel 命名为
    <feature>_notifier.dart
    →
    <Feature>Notifier
    。
  • 在
    build()
    中调用 repository/engine 或编写
    try/catch
    业务逻辑。
    View 只读取一个 controller 并进行渲染;逻辑位于 ViewModel 中。
  • 从 widget 或 ViewModel 直接访问 Drift/secure storage/platform channel。 读取操作必须经过 repository provider;row 结构属于 data layer 的内部细节。
  • 提交前乐观发布、使用
    setState
    、手动修改缓存,或采用 debounced 的“稍后保存”。
    每次写入都通过一个 repository 方法完成持久化,随后由 stream 重新发射。
  • 将存储的计数器视为事实来源。 读取时从事实来源派生数量、总计和连续记录。
  • 使用无 key 的“当前实体”provider,或对 app-scope 单例使用
    autoDispose
    。
    按 key 隔离的状态使用
    family
    +
    autoDispose
    ;单例使用
    keepAlive
    。
  • 在功能中调用
    DateTime.now()
    。
    读取注入的
    Clock
    provider,确保流程可断言。
  • 使用
    state.extra
    传递支持 deep link 的标识信息,或使用
    Navigator.push
    发起主要流程。
    使用 path params + 类型化
    GoRoute
    ;由 View/redirect 决定导航,而不是 controller。
  • 导入另一个 feature 的文件夹。 依赖只能向下,绝不能横向依赖。
  • 硬编码 left/right 几何属性或使用内联 UI 字符串。 使用
    EdgeInsetsDirectional
    + 来自 gen-l10n 的字符串。
  • 测试时 mock Notifier,而不是 fake repository。 Fake 边界层,测试接线。

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
    autoDispose
    d; 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.
  • Feature 是一个文件夹
    lib/features/<feature>/
    ,并在
    presentation/
    下具有固定结构:
    <feature>_screen.dart
    (无逻辑 View)、
    <feature>_notifier.dart
    (一一对应的 Notifier/AsyncNotifier)、
    widgets/
    、限定作用域的
    <feature>_providers.dart
    。
  • View 只读取一个 controller 并进行渲染;
    build()
    中没有 repository 调用或业务
    try/catch
    ;使用
    .when
    以稳定的方式呈现错误。
  • 依赖只向下;不导入另一个 feature 的文件夹;feature 中不包含 Drift/DAO/secure storage/platform channel 导入。
  • 读取操作使用基于 repository
    .watch()
    的限定作用域 StreamProviders;按 key 隔离的 provider 基于稳定值进行
    family
    键控并启用
    autoDispose
    ;单例使用
    keepAlive
    。
  • 每次变更都通过一个具名的 repository 方法执行(单一写入路径),提交后才重新发布;派生状态在读取时计算,绝不存储。
  • ViewModel 传递 value object;Drift row/companion/
    Map
    不会进入 widget;金额不使用裸
    double
    。
  • 错误使用 sealed
    Result
    /
    Failure
    并映射为
    AsyncValue
    ;View 执行穷尽的 switch,并在边缘对 Failure 进行本地化。
  • “当前时间”来自注入的
    Clock
    ;功能中不调用
    DateTime.now()
    。
  • 类型化
    GoRoute
    已注册到单一 router;标识信息通过 path params 传递;全屏流程设置
    parentNavigatorKey
    ;ViewModel 中不执行导航。
  • 字符串来自 gen-l10n,遵循 template-first + parity;几何属性仅使用
    Directional
    。
  • 使用
    ProviderContainer.test()
    、fake repository 和固定
    Clock
    (
    clockProvider.overrideWithValue(Clock.fixed(t))
    ,而非 mocked Notifier)测试 ViewModel。
  • dart format
    +
    dart analyze --fatal-infos
    检查通过;
    scripts/verify_feature.sh
    执行成功。

Related skills

相关 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.
  • flutter-architecture
    ——本脚手架所遵循的层级模型和仅向下的 DAG。
  • state-management-riverpod
    ——Notifier/AsyncNotifier 规范、watch/read/listen、family+autoDispose,以及 manual-providers-first。
  • navigation-and-routing
    ——管理当前 feature 向其注册类型化 route 的单一 go_router。
  • forms-and-input
    ——管理当前 feature 所启动的新增/编辑流程中的 Form、校验和 focus 规范。
  • widget-composition
    ——无逻辑 View、小型 const widget class 和结构化布局。
  • error-handling-typed-results
    ——以 AsyncValue 呈现的 sealed
    Result
    /
    Failure
    核心机制。
  • persistence-drift
    和
    run-migration
    ——新记录背后的 repository/DAO/table/migration 流程。
  • i18n-rtl-l10n
    ——ARB parity + Directional 几何属性约定。
  • value-objects-money-and-units
    ——ViewModel 传递的标准 value object。
  • testing-strategy
    ——通过 fake repository 和
    clockProvider
    对 ViewModel 进行 headless 测试。
  • project-structure-and-packages
    ——管理单 package 目录树;以及何时 foundation 确实应成为 package 而非文件夹。

References

参考资料

Provider / ChangeNotifier appendix

Provider / ChangeNotifier 附录

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.
使用官方 Flutter 指南技术栈(
package:provider
+
ChangeNotifier
)时,规则完全相同,仅连接方式有所变化:
  • ViewModel 是
    ChangeNotifier
    ;intent 方法修改私有字段,并在最后调用一次
    notifyListeners()
    (每次调用仍只产生相当于一个不可变状态快照的变化)。
  • 在 feature route 中提供
    ChangeNotifierProvider
    ;View 在
    build
    中通过
    context.watch<TasksViewModel>()
    读取,在 callback 中通过
    context.read<TasksViewModel>()
    读取——与 Riverpod 中 build 内 watch、callback 内 read 的划分相同。
  • 响应式读取:暴露 repository
    Stream
    ,并使用
    StreamProvider
    (
    provider
    package 的
    StreamProvider
    ,或
    StreamBuilder
    )对其执行 fold——不要在 ViewModel 中缓存可变副本;应在发射时派生数据。
  • 使用 per-screen scoping 代替
    family
    +
    autoDispose
    :在 screen 上创建
    ChangeNotifierProvider
    ,使其随 route 一起 dispose;将 app-scope service 保存在 root provider 中。
  • 在
    ChangeNotifier.dispose()
    中释放 controller 和 subscription。
其余内容保持不变——无逻辑 View、单一写入路径、类型化
Result
/
Failure
、path-param routing、注入 clock、ARB parity、Directional 几何属性,以及在测试中 fake repository。