为什么把登录页和设置页做成同一个页面

PipeMonitor 的”用户”Tab 是个有点反直觉的设计:登录表单和登录后的设置面板共用同一个 UserPage,靠 Consumer<UserViewModel> 在两态之间切换 UI,而不是登录成功后 push 到一个独立的设置页。

这种设计来自监控类应用的使用场景:运维人员打开 App,第一眼看到的应该是”这是一个监控系统”,而不是被登录页拦在门外。三个数据 Tab 在未登录态显示空壳,用户 Tab 在未登录态显示登录表单。登录成功后,AuthRepository 触发 notifyListeners(),GoRouter 的 refreshListenable 让各 Tab 重新评估 builder,用户 Tab 内部则根据 isLoggedIn 直接重建为设置面板——整个过程没有页面跳转,没有路由栈变化,只是同一棵 widget 子树的替换

控制页 ControlPage 也是这套交互闭环的一部分:它承载远程重启、参数下发等会改变设备运行状态的操作,需要处理”用户点击 → 二次确认 → 命令下发 → 等待 ACK → 反馈结果”的完整链路。这篇把 user_page.dartcontrol_page.dart 两个文件里的关键交互模式一次性讲透。

两态切换:Consumer 驱动的 UI 重建

UserPage 本身是个 StatelessWidget,所有状态都来自 UserViewModel

class UserPage extends StatelessWidget {
  const UserPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Consumer<UserViewModel>(
      builder: (context, vm, _) {
        // 已登录:展示账号信息和功能入口;未登录:展示登录表单。
        return SafeArea(
          child: vm.isLoggedIn
              ? _LoggedInView(vm: vm)
              : _LoginFormView(vm: vm),
        );
      },
    );
  }
}

关键点是 Consumer<UserViewModel> 包在 UserPage 这一层,而不是包在子视图内部。这样 UserViewModel 调用 notifyListeners() 后,整个 builder 重新执行,vm.isLoggedIntrue 时直接返回 _LoggedInView,原来的 _LoginFormView 连同其 TextEditingController 一起被销毁。

_LoginFormViewStatefulWidget,但它的 State 不持有任何业务状态,只持有 Form key 和两个文本控制器:

class _LoginFormViewState extends State<_LoginFormView> {
  // Form key + 两个文本控制器。State 自己持有,dispose 时释放。
  final _form = GlobalKey<FormState>();
  final _userCtrl = TextEditingController();
  final _pwdCtrl = TextEditingController();

  @override
  void dispose() {
    _userCtrl.dispose();
    _pwdCtrl.dispose();
    super.dispose();
  }
  // ...
}

登录态切换时,旧的 _LoginFormViewState 被 dispose,控制器随之释放,不会出现”登录后表单数据还留在内存里”的泄漏。_submit 方法的注释也点明了这一点:

/// 提交:先做表单校验,再走 ViewModel;登录态切换后页面会自动重建到已登录视图。
Future<void> _submit(UserViewModel vm) async {
  if (!_form.currentState!.validate()) return;
  await vm.submit(
    username: _userCtrl.text.trim(),
    password: _pwdCtrl.text,
  );
  // 不需要手动跳转:UserPage 顶层会随登录态切换刷新。
}

_submit 里没有任何 push / replace 调用。登录成功后页面自动重建这件事,由 UserPage 顶层的 Consumer + AuthRepositorynotifyListeners 兜底,_LoginFormView 自己完全不用关心。

Form 校验:非空检查与键盘交互

登录表单的校验逻辑很克制——只做非空检查,不引入额外的长度/复杂度规则:

// 用户名输入框。
TextFormField(
  controller: _userCtrl,
  textInputAction: TextInputAction.next,
  decoration: const InputDecoration(
    labelText: '用户名',
    border: OutlineInputBorder(),
  ),
  validator: (v) =>
      (v == null || v.trim().isEmpty) ? '请输入用户名' : null,
),
const SizedBox(height: 12),
// 密码输入框(脱敏)。
TextFormField(
  controller: _pwdCtrl,
  obscureText: true,
  textInputAction: TextInputAction.done,
  onFieldSubmitted: (_) => _submit(vm),
  decoration: const InputDecoration(
    labelText: '密码',
    border: OutlineInputBorder(),
  ),
  validator: (v) =>
      (v == null || v.isEmpty) ? '请输入密码' : null,
),

两个细节值得注意:

  • 用户名 trim()、密码不 trim()。密码里的空格可能是用户真实输入的一部分,强行 trim 会导致登录失败;用户名前后误输入的空格则是常见失误,需要清洗。
  • textInputAction 配合 onFieldSubmitted。用户名框的回车键显示为”下一个”(next),焦点跳到密码框;密码框的回车键显示为”完成”(done),按下后直接触发 _submit。这样软键盘用户不需要手动点登录按钮就能完成整个登录流程。

校验只在 _submit 入口处触发一次:if (!_form.currentState!.validate()) return;validate() 返回 false 时 Flutter 会自动把每个 TextFormFieldvalidator 返回的错误文案显示在输入框下方,不需要手动管理错误状态。

错误信息的展示还有一个”仅 ViewModel 报错时才出现”的条件块:

// 错误信息:仅当 ViewModel 报错时才出现。
if (vm.error != null) ...[
  const SizedBox(height: 12),
  Text(
    vm.error!,
    style: TextStyle(color: Theme.of(context).colorScheme.error),
  ),
],

表单校验错误(空字段)显示在输入框下方,由 Form 自己管理;服务端返回的鉴权错误(密码错误、账号不存在等)由 ViewModel.error 承载,显示在表单底部。两种错误来源分离,互不干扰。

busy 态防抖:按钮置灰与小转圈

登录请求是异步的,用户在等待响应期间可能多次点击按钮,导致重复提交。_LoginFormViewvm.busy 标志同时做两件事:

// 登录按钮:busy 时置灰并展示小转圈。
SizedBox(
  width: double.infinity,
  child: FilledButton(
    onPressed: vm.busy ? null : () => _submit(vm),
    child: vm.busy
        ? const SizedBox(
            width: 18,
            height: 18,
            child: CircularProgressIndicator(strokeWidth: 2),
          )
        : const Text('登录'),
  ),
),
  • onPressed: vm.busy ? null : ...null 会让 FilledButton 进入禁用态,Material 3 自动把按钮置灰、取消水波纹响应。
  • child: vm.busy ? CircularProgressIndicator : Text:按钮文案替换为小转圈,给用户”正在处理”的视觉反馈。

这种”置灰 + 转圈”的模式在 ControlPage 的重启按钮和 _UploadPeriodTile 的周期选择项里都有出现,是这套 UI 处理异步操作的统一约定。

_UploadPeriodTile:BottomSheet 选择 + 二次确认

登录后的设置面板里,_UploadPeriodTile 是个交互密度很高的组件。它要完成”展示当前周期 → 选择新周期 → 二次确认 → 下发命令 → 反馈结果”的完整链路,而且每一步都要处理 busy 态。

BottomSheet 选择周期

点击 Tile 弹出 ModalBottomSheet,列出 4 个合法周期值:

static const _periods = [2, 10, 30, 60];

Future<void> _changeUploadPeriod(BuildContext context) async {
  final selected = await showModalBottomSheet<int>(
    context: context,
    builder: (sheetContext) {
      final current = vm.uploadPeriodSeconds;
      return SafeArea(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const ListTile(
              title: Text('选择设备上报周期'),
            ),
            for (final seconds in _periods)
              ListTile(
                title: Text(_formatPeriod(seconds)),
                selected: seconds == current,
                trailing: seconds == current ? const Icon(Icons.check) : null,
                onTap: () => Navigator.of(sheetContext).pop(seconds),
              ),
          ],
        ),
      );
    },
  );

几个实现细节:

  • showModalBottomSheet<int> 的泛型参数pop(seconds) 返回的 int 会作为 await 的结果赋给 selected,点击空白处取消时 selectednull
  • 当前值用 selected: true + 勾号双重标记ListTile.selected 会让该行高亮,trailing: Icon(Icons.check) 再加一个勾号,两重提示避免用户误选。
  • _periodsstatic const。这个列表在编译期确定,与设备端 uplink_service_set_tele_period_seconds 接受的合法值(2/10/30/60 秒,且必须是采集周期 2 秒的整数倍)保持一致,UI 层直接把非法值挡在外面。

二次确认对话框

BottomSheet 返回后,还不下发命令,而是再弹一个 AlertDialog

if (!context.mounted ||
    selected == null ||
    selected == vm.uploadPeriodSeconds) {
  return;
}

// 选择后再二次确认,确认前不下发设备命令。
final confirmed = await showDialog<bool>(
  context: context,
  builder: (dialogContext) => AlertDialog(
    title: const Text('确认修改上报周期'),
    content: Text('确定要把设备上报周期改为 ${_formatPeriod(selected)} 吗?'),
    actions: [
      TextButton(
        onPressed: () => Navigator.of(dialogContext).pop(false),
        child: const Text('取消'),
      ),
      FilledButton(
        onPressed: () => Navigator.of(dialogContext).pop(true),
        child: const Text('确定'),
      ),
    ],
  ),
);
if (confirmed == true && context.mounted) {
  await vm.setUploadPeriod(selected);
}

二次确认的必要性来自命令下发的真实成本:POST /api/commands/upload-period 会经过 HTTP → MQTT → 4G → 串口四层链路到达设备,端到端延迟 1~3 秒,设备执行后还会改变后续所有 tele 帧的节奏。误触一次就要等链路往返,还得再发一次命令改回来。

几个边界条件处理:

  • selected == vm.uploadPeriodSeconds:用户选了和当前一样的值,直接 return,不弹确认框。
  • context.mounted 检查在 await 之后出现两次。showModalBottomSheetshowDialog 都是异步的,await 期间 widget 可能已经被销毁(比如用户切到了别的 Tab),直接用 context 会抛异常。
  • confirmed == true:只有点”确定”才下发,点”取消”或点空白处取消都是 false / null

busy 态的 Tile 视觉

命令下发期间,Tile 的 trailing 区域从”周期值 + 下拉箭头”切换为小转圈,onTap 也置为 null

trailing: vm.uploadBusy
    ? const SizedBox(
        width: _settingsTrailingIconBox,
        child: Align(
          alignment: Alignment.centerRight,
          child: SizedBox(
            width: 22,
            height: 22,
            child: CircularProgressIndicator(strokeWidth: 2),
          ),
        ),
      )
    : Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(
            _formatPeriod(vm.uploadPeriodSeconds),
            // 右侧数值与左侧标题使用同一字号,避免视觉上偏小。
            style: titleStyle,
          ),
          // ...
        ],
      ),
onTap: vm.uploadBusy ? null : () => _changeUploadPeriod(context),

_settingsTrailingIconBox = 24 是个固定的宽度常量,保证 busy 态的转圈和非 busy 态的”数值 + 箭头”占据相同的水平空间,切换时不会出现 Tile 宽度抖动。

control_page:post-frame callback 与状态反馈

ControlPage 的核心交互是远程重启,但它的 build 方法里藏着一个容易被忽视的模式——post-frame callback 调度 SnackBar

@override
Widget build(BuildContext context) {
  return Consumer<ControlViewModel>(
    builder: (context, vm, _) {
      // ViewModel 里有一次性消息时弹 SnackBar;
      // 用 post-frame callback 避免在 build 阶段直接调度。
      final msg = vm.message;
      if (msg != null) {
        WidgetsBinding.instance.addPostFrameCallback((_) {
          if (!context.mounted) return;
          ScaffoldMessenger.of(context).clearSnackBars();
          ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
          vm.clearMessage();
        });
      }
      return Padding(/* ... */);
    },
  );
}

为什么不能在 build 里直接 showSnackBar

ScaffoldMessenger.showSnackBar 内部会调用 Scaffold 的状态方法,触发 widget 树的标记-重绘流程。如果在 build 阶段直接调用,会引发 setState 在 build 期间调用的断言错误(setState() or markNeedsBuild() called during build)。

addPostFrameCallbackshowSnackBar 推迟到当前帧渲染完成之后执行——此时 widget 树已经稳定,调用 ScaffoldMessenger 不会触发重入。这是 Flutter 处理”build 阶段需要触发的副作用”的标准模式。

_LoggedInView 里展示 uploadMessage 时用的是同一套写法:

final message = vm.uploadMessage;
if (message != null) {
  WidgetsBinding.instance.addPostFrameCallback((_) {
    if (!context.mounted) return;
    ScaffoldMessenger.of(context).clearSnackBars();
    ScaffoldMessenger.of(context)
        .showSnackBar(SnackBar(content: Text(message)));
    vm.clearUploadMessage();
  });
}

一次性消息 + clearMessage 的契约

vm.message 是个”一次性消息”字段:ViewModel 在命令下发完成(成功或失败)后设置它,UI 读取后必须调用 vm.clearMessage() 清掉,否则下一次 build 重建时会再次弹出同一个 SnackBar。

这个契约的三个关键点:

  • clearSnackBars() 先清后弹。避免短时间内多次命令完成时 SnackBar 队列堆积。
  • context.mounted 检查。post-frame callback 可能在 widget 已经销毁后才触发(比如用户快速切 Tab),此时 context 已经失效,直接用会抛异常。
  • clearMessage()showSnackBar 之后调用。如果先 clear 再 show,build 重建时 message 已经是 null,不会有问题;但反过来写更清晰地表达”读到消息 → 展示 → 清理”的时序。

命令下发的状态反馈

重启按钮的视觉状态完全由 vm.busy 驱动:

FilledButton.icon(
  onPressed: vm.busy ? null : () => _confirmReboot(context, vm),
  icon: vm.busy
      ? const SizedBox(
          width: 16, height: 16,
          child: CircularProgressIndicator(strokeWidth: 2),
        )
      : const Icon(Icons.restart_alt),
  label: Text(vm.busy ? '等待设备确认...' : '重启设备'),
),

三态反馈:

  • 空闲:按钮可点,显示”重启设备” + 重启图标。
  • busy:按钮置灰,图标换为小转圈,文案改为”等待设备确认…”——文案明确告诉用户当前在等什么,而不是笼统的”加载中”。
  • 完成vm.message 被设置,post-frame callback 弹 SnackBar 反馈成功/失败,按钮回到空闲态。

“等待设备确认…”这个文案背后是 ApiService.sendReboot 的契约:新后端会等待设备 ACK 后再返回终态,所以 busy 期间 App 真的在等设备回包,而不是等 HTTP 响应。这与设备端”先发 ack 再延时 500ms 复位”的设计配合,保证用户看到”成功”时设备确实已经收到命令。

重启二次确认

重启比改周期更危险,二次确认对话框同样不可省:

Future<void> _confirmReboot(BuildContext context, ControlViewModel vm) async {
  final ok = await showDialog<bool>(
    context: context,
    builder: (ctx) => AlertDialog(
      title: const Text('确认重启'),
      content: const Text('设备将立即重启,是否继续?'),
      actions: [
        TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
        FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('重启')),
      ],
    ),
  );
  if (ok == true) vm.reboot();
}

重启会让所有采集/上报任务暂停约 30 秒,期间的 tele 帧会丢失。确认对话框的文案直接说”设备将立即重启”,不绕弯子,让用户明确知道后果。if (ok == true) vm.reboot()——只有点”重启”才执行,点”取消”或返回都静默忽略。

小结

  • UserPage 用顶层 Consumer<UserViewModel> + vm.isLoggedIn 做两态切换,登录态变化时整棵子树替换,无需手动 push/pop
  • _LoginFormViewState 只持有 Form key 和控制器,登录后随 widget 销毁自动释放,避免表单数据残留
  • Form 校验只做非空检查,用户名 trim() 而密码不 trim()textInputAction + onFieldSubmitted 让软键盘用户一路回车完成登录
  • vm.busy 同时驱动按钮置灰和文案/图标替换,是处理异步操作的统一约定
  • _UploadPeriodTile 用 BottomSheet 选周期 + AlertDialog 二次确认,把非法值挡在 UI 层、把误触成本挡在确认框前
  • control_pageaddPostFrameCallback 调度 SnackBar,避免在 build 阶段触发 ScaffoldMessenger 导致断言错误
  • 一次性消息契约:UI 读取 vm.message 后必须 clearMessage(),配合 clearSnackBars() 先清后弹,避免 SnackBar 队列堆积
  • “等待设备确认…”文案对应 sendReboot 等待设备 ACK 的契约,与设备端延时复位设计配合,保证 UI 反馈与设备真实状态一致

后续阅读