为什么报警列表要单独拆一页
PipeMonitor 的设备端每 2 秒采样一次,靠”边沿触发”保证同一条规则不会刷屏。但即便如此,一天积累下来的告警条目仍然可能上百条——流量超限、传感器故障、MCU 重启、网关掉线都会产生事件。运维人员打开 App 后最关心的是”现在有什么没处理”,所以告警 Tab 的 UI 要解决三件事:能按严重等级快速过滤、能展开看详情、能一眼看到还有几条没确认。
这一篇拆 alarm_page.dart 这一个文件。它用 Consumer 做响应式绑定,CustomScrollView + SliverList.separated 做滚动列表,FilterChip 做严重等级筛选,_AlarmTile 做展开收起,再用一个 FloatingActionButton.extended 承载未读计数。整体不到 260 行,但几个细节值得记录。主题色取自 theme.dart 里的工业蓝种子色 0xFF1976D2,由 ColorScheme.fromSeed 派生出整套 Material 3 调色板。
Consumer + ChangeNotifier:响应式数据绑定
AlarmPage 本身是个 StatelessWidget,所有状态都交给 AlarmViewModel。页面根节点用 Consumer<AlarmViewModel> 包住,ViewModel 继承 ChangeNotifier,数据变化时调用 notifyListeners() 触发 Consumer 重建:
/// 警报页:筛选栏 + 列表 + 下拉刷新 + 浮动"标记已读"按钮。
class AlarmPage extends StatelessWidget {
const AlarmPage({super.key});
@override
Widget build(BuildContext context) {
return Consumer<AlarmViewModel>(
builder: (context, vm, _) {
return Scaffold(
body: RefreshIndicator(
onRefresh: vm.load,
child: vm.loading && vm.items.isEmpty
// 首次拉取且无数据:居中 loading。
? const Center(child: CircularProgressIndicator())
: vm.items.isEmpty && vm.filter == null
// 拉完依然为空且无筛选:空态。
? const _EmptyState()
: CustomScrollView(slivers: [...]),
),
// 有未读且列表非空时悬浮按钮:一键全部已读。
floatingActionButton: vm.unread > 0 && vm.items.isNotEmpty
? FloatingActionButton.extended(
onPressed: vm.markAllRead,
icon: const Icon(Icons.done_all),
label: Text('标记已读 (${vm.unread})'),
)
: null,
);
},
);
}
}
Consumer 的 builder 第三个参数用 _ 占位——因为子树不需要 child 复用,整个 Scaffold 都依赖 vm 的字段。vm.loading、vm.items、vm.filter、vm.unread 任一变化都会让 Consumer 重新执行 builder,所以 loading → 空态 → 列表三态切换不需要手动 setState,全靠 ViewModel 驱动。
RefreshIndicator.onRefresh 直接绑 vm.load,下拉刷新时 ViewModel 重新拉取并 notifyListeners(),UI 自动更新。这种”UI 只描述状态、状态全在 ViewModel”的写法让页面非常薄,测试时也只需 mock 一个 ViewModel。
CustomScrollView + SliverList.separated 的滚动列表
列表主体放在 CustomScrollView 里,分两个 sliver:顶部筛选栏用 SliverToBoxAdapter 包一层,列表用 SliverList.separated:
CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _FilterBar(
current: vm.filter,
onChanged: vm.setFilter,
),
),
if (vm.items.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: _EmptyState(),
)
else
SliverList.separated(
itemCount: vm.items.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) {
final alarm = vm.items[i];
return _AlarmTile(
alarm: alarm,
expanded: vm.isExpanded(alarm.seq),
onTap: () => vm.toggleExpanded(alarm.seq),
);
},
),
],
),
为什么用 CustomScrollView 而不是 ListView?因为筛选栏要随列表一起滚动,又要在筛选后让列表区域独立滚动。把筛选栏做成 SliverToBoxAdapter,它和 SliverList 共享同一个 ScrollController,向上滚筛选栏会滑出视口,向下滚又回来,整体一气呵成,不会出现”外层 ListView 套内层 ListView”那种嵌套滚动的手势冲突。
SliverList.separated 是 Flutter 3.7 后的语法,比手写 itemCount * 2 - 1 的 SliverList.builder 干净得多。separatorBuilder 这里返回一条 Divider(height: 1)——告警按时间倒序排列,每条之间用细线分隔,视觉上既分得开又不喧宾夺主。(_, _) 是新语法里不需要 context 和 index 时的占位。
筛选后无结果的空态用 SliverFillRemaining(hasScrollBody: false):它会让 _EmptyState 占满剩余空间并居中,hasScrollBody: false 表示这个区域本身不参与滚动,下拉刷新仍由外层 RefreshIndicator 接管。
FilterChip 严重等级筛选
筛选栏 _FilterBar 是一个横向可滚动的 Row,里面四个 FilterChip:全部 / 提示 / 警告 / 严重。注意这里采用的是单选语义——current 是 AlarmSeverity?(可空枚举),点”全部”传 null 清掉筛选,点其他 chip 传对应枚举值:
/// 严重等级筛选栏。
class _FilterBar extends StatelessWidget {
const _FilterBar({required this.current, required this.onChanged});
final AlarmSeverity? current;
final ValueChanged<AlarmSeverity?> onChanged;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(
children: [
_buildChip(
context,
label: '全部',
selected: current == null,
onTap: () => onChanged(null),
),
const SizedBox(width: 8),
_buildChip(
context,
label: '提示',
icon: Icons.info_outline,
iconColor: Theme.of(context).colorScheme.primary,
selected: current == AlarmSeverity.info,
onTap: () => onChanged(AlarmSeverity.info),
),
const SizedBox(width: 8),
_buildChip(
context,
label: '警告',
icon: Icons.warning_amber_rounded,
iconColor: Colors.orange,
selected: current == AlarmSeverity.warn,
onTap: () => onChanged(AlarmSeverity.warn),
),
const SizedBox(width: 8),
_buildChip(
context,
label: '严重',
icon: Icons.error_outline,
iconColor: Theme.of(context).colorScheme.error,
selected: current == AlarmSeverity.critical,
onTap: () => onChanged(AlarmSeverity.critical),
),
],
),
);
}
Widget _buildChip(
BuildContext context, {
required String label,
IconData? icon,
Color? iconColor,
required bool selected,
required VoidCallback onTap,
}) {
return FilterChip(
label: Text(label),
avatar: icon != null ? Icon(icon, size: 18, color: iconColor) : null,
selected: selected,
onSelected: (_) => onTap(),
);
}
}
每个 chip 的 avatar 图标颜色和列表里 _AlarmTile 的色环保持一致:提示用 colorScheme.primary(工业蓝),警告用 Colors.orange,严重用 colorScheme.error(Material 3 红色)。这样筛选栏本身就成了一张”颜色图例”,用户扫一眼就知道哪种颜色代表哪一级。
用单选而非多选是刻意的取舍:运维场景下,要么看全部、要么只盯最严重的那一级。多选需要额外的 Set<AlarmSeverity> 状态管理,而单选一个可空枚举就够用,onChanged 把新值交给 vm.setFilter,ViewModel 里做过滤并 notifyListeners()。FilterChip 的 onSelected 回调带了 bool selected 参数,但这里用 (_) => onTap() 忽略它——因为选中状态完全由 current 决定,不允许”点已选中的取消”。
_AlarmTile:展开收起详情
每条告警是一个 _AlarmTile,默认只显示一行摘要(图标 + 标题 + 时间 + 已读勾),点击后展开详情区域。展开状态不在 tile 内部维护,而是由 vm.isExpanded(alarm.seq) 查询、vm.toggleExpanded(alarm.seq) 切换——这样列表项重建时(比如滚动出视口又回来)展开状态不会丢:
/// 单条告警条目:左侧色环 + 中间标题/时间 + 右侧已读勾,点击展开详情。
class _AlarmTile extends StatelessWidget {
const _AlarmTile({
required this.alarm,
required this.expanded,
required this.onTap,
});
final Alarm alarm;
final bool expanded;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
// 颜色随严重等级映射:红 / 橙 / 蓝。
final color = switch (alarm.severity) {
AlarmSeverity.critical => cs.error,
AlarmSeverity.warn => Colors.orange,
AlarmSeverity.info => cs.primary,
};
return Column(
children: [
ListTile(
leading: CircleAvatar(
backgroundColor: color.withValues(alpha: 0.15),
child: Icon(alarm.displayIcon, color: color),
),
title: Text(
alarm.displayTitle,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
DateFormat('yyyy-MM-dd HH:mm:ss').format(alarm.timestamp),
),
// 已确认的告警末尾打个绿勾。
trailing: alarm.acknowledged
? const Icon(Icons.check_circle, color: Colors.green, size: 20)
: null,
onTap: onTap,
),
if (expanded) _buildDetail(context),
],
);
}
}
ListTile 的 leading 是一个 CircleAvatar,背景色用严重等级颜色加 15% 透明度(withValues(alpha: 0.15)),图标用同色实心——这种”浅底深标”的画法在 Material 3 里很常见,既突出又不刺眼。alarm.displayIcon 和 alarm.displayTitle 是 Alarm 模型上的 getter,把告警码映射成中文标题和对应图标:
/// 告警标题的中文映射。
String get displayTitle => switch (code) {
'OVER_FLOW' => '瞬时流量超上限',
'UNDER_FLOW' => '瞬时流量低下限',
'OVER_PRESSURE' => '压力超上限',
'OVER_TEMP' => '温度超上限',
'SENSOR_FAULT' => '传感器故障',
'MCU_RESTART' => '单片机已重启',
'GATEWAY_OFFLINE' => '网关掉线',
'GATEWAY_ONLINE' => '网关恢复在线',
'DEVICE_OFFLINE' => '设备离线',
'DEVICE_ONLINE' => '设备恢复在线',
_ => code,
};
/// 根据告警码返回对应图标。
IconData get displayIcon => switch (code) {
'OVER_FLOW' || 'UNDER_FLOW' => Icons.water_drop_outlined,
'OVER_PRESSURE' => Icons.compress,
'OVER_TEMP' => Icons.thermostat,
'SENSOR_FAULT' => Icons.sensors_off,
'MCU_RESTART' => Icons.power_settings_new,
'GATEWAY_OFFLINE' || 'GATEWAY_ONLINE' => Icons.router,
'DEVICE_OFFLINE' || 'DEVICE_ONLINE' => Icons.cloud_off,
_ => Icons.warning_amber_rounded,
};
把映射放在模型上而不是 UI 层,好处是任何页面用到 Alarm 都能直接拿到可读标题,不会出现一处改了另一处忘改。Dart 3 的 switch 表达式让这种枚举式映射写得很紧凑,|| 还能合并多个 case。
展开后的详情区用 _buildDetail 渲染,左侧缩进 72 对齐 ListTile 的文字起点:
Widget _buildDetail(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(72, 0, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_detailRow('告警代码', alarm.code, cs),
if (alarm.value != null)
_detailRow('触发值', alarm.value!.toStringAsFixed(2), cs),
_detailRow(
'状态',
alarm.acknowledged ? '已确认' : '未确认',
cs,
),
_detailRow(
'等级',
switch (alarm.severity) {
AlarmSeverity.critical => '严重',
AlarmSeverity.warn => '警告',
AlarmSeverity.info => '提示',
},
cs,
),
],
),
);
}
padding 的 fromLTRB(72, 0, 16, 12) 里那个 72 是有讲究的——ListTile 默认 leading 图标区 + 内容起始的左缩进正好是 72 逻辑像素,详情区这么对齐后,“告警代码”标签会和上面的标题左边缘齐平,视觉上像标题的延续而不是另起一块。alarm.value 是可空的,状态类报警(比如 MCU 重启)没有触发值,用 if (alarm.value != null) 跳过那一行。
空态处理的三种情况
页面里有三处空态判断,对应三种不同场景,写在同一个三元表达式链里:
child: vm.loading && vm.items.isEmpty
// 首次拉取且无数据:居中 loading。
? const Center(child: CircularProgressIndicator())
: vm.items.isEmpty && vm.filter == null
// 拉完依然为空且无筛选:空态。
? const _EmptyState()
: CustomScrollView(slivers: [...]),
- 首次拉取中:
loading == true && items.isEmpty,显示居中转圈。这个分支只在”还没任何数据”时触发,避免下拉刷新时把已加载的列表替换成 loading。 - 拉完依然为空且未筛选:
items.isEmpty && filter == null,显示_EmptyState。filter == null的条件很关键——如果用户选了”严重”筛选后结果为空,应该走CustomScrollView里的SliverFillRemaining显示空态,而不是这个全局空态,这样筛选栏还在,用户能换一个等级再试。 - 筛选后为空:列表区用
SliverFillRemaining填充_EmptyState,筛选栏保留在顶部。
_EmptyState 本身很简洁,一个灰度图标加一行文字:
/// 空态视图。
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.notifications_none,
size: 48,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(height: 8),
const Text('暂无警报'),
],
),
);
}
}
图标用 colorScheme.outline——这是 Material 3 里专门给”次要、低对比”元素留的色槽,比 onSurface 更弱,不会和真正的内容抢注意力。
未读数 FloatingActionButton 徽章
页面右下角的”标记已读”按钮用 FloatingActionButton.extended 实现,未读数直接拼进 label:
floatingActionButton: vm.unread > 0 && vm.items.isNotEmpty
? FloatingActionButton.extended(
onPressed: vm.markAllRead,
icon: const Icon(Icons.done_all),
label: Text('标记已读 (${vm.unread})'),
)
: null,
两个条件控制显隐:unread > 0 表示有未读,items.isNotEmpty 表示列表非空(避免空态时还飘着一个按钮)。没有未读时返回 null,Scaffold.floatingActionButton 接受 null,按钮直接消失,不会占位。
用 FloatingActionButton.extended 而不是普通 FloatingActionButton + Badge,是因为”未读数”本身就是按钮的主要信息——不是某个图标的附属标记,而是用户要执行的动作(标记已读 N 条)。把数字写进 label,按钮宽度自适应内容,一眼就能看到”还有 3 条要处理”。点一下调 vm.markAllRead,ViewModel 把所有告警的 acknowledged 翻 true 并 notifyListeners(),列表里每条末尾立刻冒出绿勾,按钮自己也因 unread 归零而消失。
颜色随严重等级映射
整页的颜色逻辑集中在一处 switch 表达式里,严格按 Material 3 ColorScheme 取色:
final cs = Theme.of(context).colorScheme;
// 颜色随严重等级映射:红 / 橙 / 蓝。
final color = switch (alarm.severity) {
AlarmSeverity.critical => cs.error,
AlarmSeverity.warn => Colors.orange,
AlarmSeverity.info => cs.primary,
};
critical 用 cs.error,这是 ColorScheme.fromSeed 从工业蓝种子色派生出的红色槽,暗色模式下会自动调到合适的明度。info 用 cs.primary,和品牌色一致。warn 没有走 ColorScheme——Material 3 的 ColorScheme 没有专门的”警告橙”槽,tertiary 虽然偏暖但色调不稳定,所以这里直接用 Colors.orange 固定值。筛选栏的 chip 图标颜色也复用了同一套映射,保证全页一致。
theme.dart 里种子色驱动整套调色板:
/// 工业蓝。PipeMonitor 的主品牌色。
static const Color _seed = Color(0xFF1976D2);
/// 亮色主题。
static ThemeData light() {
return ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: _seed,
brightness: Brightness.light,
),
useMaterial3: true,
appBarTheme: const AppBarTheme(centerTitle: false),
);
}
ColorScheme.fromSeed 会根据种子色生成完整的 30+ 色槽(primary、onPrimary、primaryContainer、error、outline……),亮色 / 暗色两套都从同一个种子派生,保证品牌色统一。UI 里只要坚持用 colorScheme.xxx 取色,切换暗色模式时所有颜色自动适配,不需要在页面里写任何 if (dark) 分支。
小结
Consumer<AlarmViewModel>把整页重建交给 ViewModel 的notifyListeners(),StatelessWidget只负责描述状态,loading / 空态 / 列表三态切换无需setStateCustomScrollView+SliverToBoxAdapter+SliverList.separated让筛选栏和列表共享一个滚动控制器,避免嵌套滚动手势冲突,separatorBuilder用Divider(height: 1)做轻量分隔FilterChip采用单选语义(可空枚举 + “全部”清空),图标颜色与列表色环一致,筛选栏同时充当颜色图例_AlarmTile的展开状态托管在 ViewModel,列表项重建不丢状态;详情区padding左侧 72 对齐ListTile文字起点- 空态分三种:首次加载转圈、无筛选全空、筛选后局部空(
SliverFillRemaining),filter == null条件决定走哪条分支 FloatingActionButton.extended把未读数写进 label,unread > 0 && items.isNotEmpty双条件控制显隐,比 Badge 更直白- 严重等级颜色映射集中在一个
switch表达式里,critical → cs.error、warn → Colors.orange、info → cs.primary,全页复用