为什么报警列表要单独拆一页

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,
        );
      },
    );
  }
}

Consumerbuilder 第三个参数用 _ 占位——因为子树不需要 child 复用,整个 Scaffold 都依赖 vm 的字段。vm.loadingvm.itemsvm.filtervm.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 - 1SliverList.builder 干净得多。separatorBuilder 这里返回一条 Divider(height: 1)——告警按时间倒序排列,每条之间用细线分隔,视觉上既分得开又不喧宾夺主。(_, _) 是新语法里不需要 context 和 index 时的占位。

筛选后无结果的空态用 SliverFillRemaining(hasScrollBody: false):它会让 _EmptyState 占满剩余空间并居中,hasScrollBody: false 表示这个区域本身不参与滚动,下拉刷新仍由外层 RefreshIndicator 接管。

FilterChip 严重等级筛选

筛选栏 _FilterBar 是一个横向可滚动的 Row,里面四个 FilterChip:全部 / 提示 / 警告 / 严重。注意这里采用的是单选语义——currentAlarmSeverity?(可空枚举),点”全部”传 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()FilterChiponSelected 回调带了 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),
      ],
    );
  }
}

ListTileleading 是一个 CircleAvatar,背景色用严重等级颜色加 15% 透明度(withValues(alpha: 0.15)),图标用同色实心——这种”浅底深标”的画法在 Material 3 里很常见,既突出又不刺眼。alarm.displayIconalarm.displayTitleAlarm 模型上的 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,
        ),
      ],
    ),
  );
}

paddingfromLTRB(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,显示 _EmptyStatefilter == 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 表示列表非空(避免空态时还飘着一个按钮)。没有未读时返回 nullScaffold.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,
};

criticalcs.error,这是 ColorScheme.fromSeed 从工业蓝种子色派生出的红色槽,暗色模式下会自动调到合适的明度。infocs.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 / 空态 / 列表三态切换无需 setState
  • CustomScrollView + SliverToBoxAdapter + SliverList.separated 让筛选栏和列表共享一个滚动控制器,避免嵌套滚动手势冲突,separatorBuilderDivider(height: 1) 做轻量分隔
  • FilterChip 采用单选语义(可空枚举 + “全部”清空),图标颜色与列表色环一致,筛选栏同时充当颜色图例
  • _AlarmTile 的展开状态托管在 ViewModel,列表项重建不丢状态;详情区 padding 左侧 72 对齐 ListTile 文字起点
  • 空态分三种:首次加载转圈、无筛选全空、筛选后局部空(SliverFillRemaining),filter == null 条件决定走哪条分支
  • FloatingActionButton.extended 把未读数写进 label,unread > 0 && items.isNotEmpty 双条件控制显隐,比 Badge 更直白
  • 严重等级颜色映射集中在一个 switch 表达式里,critical → cs.errorwarn → Colors.orangeinfo → cs.primary,全页复用

后续阅读