为什么把登录页和设置页做成同一个页面
PipeMonitor 的”用户”Tab 是个有点反直觉的设计:登录表单和登录后的设置面板共用同一个 UserPage,靠 Consumer<UserViewModel> 在两态之间切换 UI,而不是登录成功后 push 到一个独立的设置页。
这种设计来自监控类应用的使用场景:运维人员打开 App,第一眼看到的应该是”这是一个监控系统”,而不是被登录页拦在门外。三个数据 Tab 在未登录态显示空壳,用户 Tab 在未登录态显示登录表单。登录成功后,AuthRepository 触发 notifyListeners(),GoRouter 的 refreshListenable 让各 Tab 重新评估 builder,用户 Tab 内部则根据 isLoggedIn 直接重建为设置面板——整个过程没有页面跳转,没有路由栈变化,只是同一棵 widget 子树的替换。
控制页 ControlPage 也是这套交互闭环的一部分:它承载远程重启、参数下发等会改变设备运行状态的操作,需要处理”用户点击 → 二次确认 → 命令下发 → 等待 ACK → 反馈结果”的完整链路。这篇把 user_page.dart 和 control_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.isLoggedIn 为 true 时直接返回 _LoggedInView,原来的 _LoginFormView 连同其 TextEditingController 一起被销毁。
_LoginFormView 是 StatefulWidget,但它的 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 + AuthRepository 的 notifyListeners 兜底,_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 会自动把每个 TextFormField 的 validator 返回的错误文案显示在输入框下方,不需要手动管理错误状态。
错误信息的展示还有一个”仅 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 态防抖:按钮置灰与小转圈
登录请求是异步的,用户在等待响应期间可能多次点击按钮,导致重复提交。_LoginFormView 用 vm.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,点击空白处取消时selected为null。- 当前值用
selected: true+ 勾号双重标记。ListTile.selected会让该行高亮,trailing: Icon(Icons.check)再加一个勾号,两重提示避免用户误选。 _periods是static 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之后出现两次。showModalBottomSheet和showDialog都是异步的,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)。
addPostFrameCallback 把 showSnackBar 推迟到当前帧渲染完成之后执行——此时 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_LoginFormView的State只持有 Form key 和控制器,登录后随 widget 销毁自动释放,避免表单数据残留- Form 校验只做非空检查,用户名
trim()而密码不trim();textInputAction+onFieldSubmitted让软键盘用户一路回车完成登录 vm.busy同时驱动按钮置灰和文案/图标替换,是处理异步操作的统一约定_UploadPeriodTile用 BottomSheet 选周期 + AlertDialog 二次确认,把非法值挡在 UI 层、把误触成本挡在确认框前control_page用addPostFrameCallback调度 SnackBar,避免在 build 阶段触发ScaffoldMessenger导致断言错误- 一次性消息契约:UI 读取
vm.message后必须clearMessage(),配合clearSnackBars()先清后弹,避免 SnackBar 队列堆积 - “等待设备确认…”文案对应
sendReboot等待设备 ACK 的契约,与设备端延时复位设计配合,保证 UI 反馈与设备真实状态一致