开源鸿蒙跨平台Flutter开发:幼儿疫苗全生命周期追踪系统:基于 Flutter 的免疫接种档案与状态机设计
文章摘要: 本文构建了一个基于Flutter的幼儿疫苗全生命周期追踪系统,采用领域驱动设计(DDD)解决传统纸质接种本的痛点。系统通过强类型状态机(enum InjectionStatus)管理疫苗状态流转,利用DateTime计算精准接种时间,并引入传染病学模型"免疫缺口指数(IGI)"量化延期风险。核心代码展示了充血模型设计(VaccineRecord实体含业务逻辑)和空安
欢迎加入开源鸿蒙跨平台社区:
https://openharmonycrossplatform.csdn.net
示例效果




前言
在全球公共卫生与预防医学领域,计划免疫(Planned Immunization)是阻断传染病传播、构建群体免疫屏障(Herd Immunity)最具经济效益且绝对核心的医疗干预手段。自儿童出生起至六岁,其需要接受涵盖结核病、乙肝、脊髓灰质炎、百白破等数十剂次的一类(强制免费)与二类(自愿自费)疫苗接种。
然而,传统的纸质“疫苗接种本”在实际应用中暴露出极度致命的脆弱性:家长对长达数年的接种时间节点极易产生遗忘;不同疫苗之间存在的“最小间隔天数”、“联合疫苗替代逻辑”以及“活疫苗冲突禁忌”等规则极为晦涩;一旦发生延期接种(Delayed Injection),纸质本根本无法提供任何风险预警或智能重排策略。
为解决这一医疗信息学痛点,本文将彻底抛弃简单的“待办列表”思维,基于领域驱动设计(DDD)的底层逻辑,运用 Flutter 极其强大的绘图引擎和跨端算力,构建一套工业级的“幼儿疫苗全生命周期追踪系统”。文章将深入剖析疫苗实体的有限状态机(FSM)、时间轴拓扑渲染管线,并首次将传染病学中的“免疫缺口指数(Immunization Gap Index, IGI)”构建为数学模型并融入代码之中,呈现超万字级别的硬核极客技术申论。
第一章:系统工程背景与免疫接种记录的数字化痛点
在构建电子免疫档案(Electronic Immunization Registry, EIR)系统时,工程师面临的挑战远超过普通的 CRUD(增删改查)应用。幼儿的疫苗接种是一个跨越数年、具备严格时间约束(Time-Constrained)以及严格顺序依赖(Sequential Dependency)的医学网络。
| 核心领域维度 | 纸质化/低阶系统痛点 | 本系统的智能化设计应对策略 |
|---|---|---|
| 状态流转追踪 | 只能用印章或手写标注,无法追踪“已延期”的异常状态 | 构建强类型 enum 的状态机,时间差触发底层红灯预警 |
| 时间排期计算 | 依赖家长的日历备忘录,极易漏种,产生群体免疫缺口 | DateTime 偏移量计算,精准推演靶向接种日(Target Date) |
| 视觉呈现结构 | 枯燥且杂乱无章的密布表格,毫无重要性可言 | 基于 CustomPaint 的微缩拓扑时间轴,视觉降噪 |
| 风险防范机制 | 无任何风险量化手段,家长对漏种的危害缺乏直观认知 | 引入衰变常数积分的风险方程 I G I IGI IGI,数据驱动恐慌感干预 |
针对以上痛点,本系统必须在数据建模阶段确立极高的边界隔离,在 UI 渲染阶段保持极度的克制与清晰。
第二章:领域驱动设计下的疫苗实体状态建模
为了将复杂的免疫规划程序映射为计算机内存中的指针流动,我们需要对核心实体进行极其严密的面向对象建模。以下是该系统内部运转的统一建模语言(UML)领域模型抽象图。
在此模型中,VaccineRecord 不再是一个贫血模型(Anemic Domain Model),它封装了属于自己生命周期内的业务行为逻辑(如月龄格式化)。而 InjectionStatus 和 VaccineCategory 作为枚举类型,构筑了内存中牢不可破的逻辑防线。
第三章:核心代码剖析(一)免疫追踪系统的数据流转与状态机引擎
在 Dart 的核心领域层,最重要的一环便是建立疫苗从“待接种”到“已完成”,或是堕落入“已延误”的状态流转引擎。
3.1 核心实体类定义
/// 领域模型:接种状态
enum InjectionStatus { completed, pending, delayed }
/// 领域实体:疫苗接种记录
class VaccineRecord {
final String id;
final String vaccineName;
final String diseaseTarget;
final int targetAgeInMonths; // 目标接种年龄(以月为单位,0代表出生时)
final VaccineCategory category;
InjectionStatus status;
DateTime? scheduledDate;
DateTime? actualDate;
String? batchNumber;
String? clinicName;
VaccineRecord({
required this.id,
required this.vaccineName,
required this.diseaseTarget,
required this.targetAgeInMonths,
required this.category,
this.status = InjectionStatus.pending,
// ... 其他属性注入
});
/// 领域行为:获取预期接种的格式化年龄描述
String get ageDescription {
if (targetAgeInMonths == 0) return '出生时';
if (targetAgeInMonths < 12) return '$targetAgeInMonths 个月';
if (targetAgeInMonths % 12 == 0) return '${targetAgeInMonths ~/ 12} 岁';
return '${targetAgeInMonths ~/ 12}岁 ${targetAgeInMonths % 12}个月';
}
}
3.2 深度点评与业务隔离性分析
-
可空类型 (Null Safety) 在医学排期中的哲学
-
在上述类结构中,
scheduledDate(应种日期) 和actualDate(实种日期) 被严格定义为了DateTime?(可空类型)。这是极度契合真实医疗场景的设计:对于未来的活疫苗(如 18个月的甲肝),其应种日期可能因为前一剂次意外发烧导致的推迟而处于“暂不确定”状态;而实种日期在status非completed状态下必定为空。Dart 在编译期(AOT)就能通过这种 Null Safety 机制拦截下任何企图对未接种疫苗执行时间查账的致命崩溃风险。
-
业务自治与只读映射
-
ageDescription被设计为通过 getter 实时动态推演的只读属性,彻底屏蔽了“X个月与X岁混杂”带来的前端繁杂格式化工作。这是典型的充血模型(Rich Domain Model)表现,使得数据本身的语义在领域层就得到了自我消化。
第四章:核心代码剖析(二)群体免疫与延期接种风险数学模型
这是一个普通的打卡 App 与真正的医疗健康系统(Healthcare System)之间最核心的分水岭。对于家长而言,一句“你漏打疫苗了”缺乏震慑力。我们必须引入传染病学模型,计算出延误疫苗造成的“免疫缺口风险”。
4.1 数学方程式构建与 KaTeX 推演
我们定义“免疫缺口指数”(Immunization Gap Index, I G I IGI IGI)。对于任意一个被标记为“延误(delayed)”状态的疫苗,其风险并非线性增长,而是呈现为一种随天数指数级放大的裂变趋势,同时与该疫苗在国家计划中的强制程度(权重)息息相关。
其核心方程设定如下:
I G I = ∑ i = 1 n [ ( e λ ⋅ Δ t i − 1 ) × ω i ] ∀ S i = delayed IGI = \sum_{i=1}^{n} \Big[ \big( e^{\lambda \cdot \Delta t_i} - 1 \big) \times \omega_i \Big] \quad \forall \, S_i = \text{delayed} IGI=i=1∑n[(eλ⋅Δti−1)×ωi]∀Si=delayed
物理参数深度解剖:
- Δ t i \Delta t_i Δti:当前系统时间与疫苗应种日期的差值(绝对延期天数: t c u r r e n t − t s c h e d u l e d t_{current} - t_{scheduled} tcurrent−tscheduled)。
- λ \lambda λ:衰变常数(代码中设定为 0.015 0.015 0.015),反映了婴儿体内母传抗体消耗殆尽后,暴露在真实病原体环境中的日均危险系数增量。
- ω i \omega_i ωi:靶向权重因子。国家强制规划的“一类疫苗”(如乙肝、脊灰)由于关系到致死率或重大致残率,权重 ω i = 2.0 \omega_i = 2.0 ωi=2.0;而水痘等“二类疫苗”权重设定为 ω i = 0.8 \omega_i = 0.8 ωi=0.8。
4.2 数学引擎代码实现
/// 数学引擎:延期接种风险评估模型与免疫缺口计算
class VaccineRiskCalculator {
static double calculateRiskIndex(List<VaccineRecord> records, DateTime currentDate) {
double totalRisk = 0.0;
const double lambda = 0.015; // 延期衰变常数
for (var record in records) {
if (record.status == InjectionStatus.delayed && record.scheduledDate != null) {
int delayDays = currentDate.difference(record.scheduledDate!).inDays;
if (delayDays > 0) {
// 强制规划疫苗权重为 2.0,自费疫苗为 0.8
double weight = record.category == VaccineCategory.mandatory ? 2.0 : 0.8;
// 套用积分评估方程
totalRisk += (exp(lambda * delayDays) - 1.0) * weight;
}
}
}
return totalRisk;
}
}
4.3 深度点评
这段代码是系统最锋利的刀刃。它在每一次 UI 构建阶段被无缝触发执行 O ( N ) O(N) O(N) 的高效遍历。当 I G I IGI IGI 超过设定阈值(例如 10)时,左侧控制台的风险背景色将从极具安全感的浅绿色(Color(0xFFEAFAF1))瞬间转变为血红色报警状态(Color(0xFFFDEDEC))。这在心理学层面构建了一种“恐慌干预机制”,倒逼使用者(家长)立即联系社区卫生服务中心进行补种。
第五章:核心代码剖析(三)Flutter 时间轴拓扑学渲染机制 (CustomPaint)
在客户端 UI 的展现形式中,任何垂直连接的列表都面临一个巨大的渲染难题:时间轴的连续性绘制。普通的 Container 加边框无法实现穿透缝隙的连贯线条,这时候必须动用极其底层的 CustomPaint 在 GPU 层面上徒手画线。
5.1 底层拓扑绘制引擎
/// 核心:时间轴底座拓扑学绘制引擎
class TimelineNodePainter extends CustomPainter {
final Color color;
final bool isLast;
final bool isCompleted;
TimelineNodePainter({
required this.color,
required this.isLast,
required this.isCompleted,
});
void paint(Canvas canvas, Size size) {
final double centerX = size.width / 2;
final double nodeY = 40.0; // 节点圆圈的 Y 轴位置
// 1. 实例化轨道画笔
final trackPaint = Paint()
..color = isCompleted ? color.withOpacity(0.5) : const Color(0xFFEAEDED)
..strokeWidth = 4.0
..strokeCap = StrokeCap.round;
// 2. 绘制下半部分轨道 (如果不是终点节点,则连线直达物理底部)
if (!isLast) {
canvas.drawLine(Offset(centerX, nodeY), Offset(centerX, size.height), trackPaint);
}
// 3. 绘制上半部分轨道 (承接上一个节点)
canvas.drawLine(Offset(centerX, 0), Offset(centerX, nodeY), trackPaint);
// 4. 绘制状态节点圆环
final nodeOuterPaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill;
final nodeInnerPaint = Paint()
..color = color
..style = isCompleted ? PaintingStyle.fill : PaintingStyle.stroke
..strokeWidth = 4.0;
// 利用白色实心外圈先执行像素遮罩(Masking),擦除背景中的线段
canvas.drawCircle(Offset(centerX, nodeY), 12.0, nodeOuterPaint);
canvas.drawCircle(Offset(centerX, nodeY), 8.0, nodeInnerPaint);
}
bool shouldRepaint(covariant TimelineNodePainter oldDelegate) {
return oldDelegate.color != color ||
oldDelegate.isLast != isLast ||
oldDelegate.isCompleted != isCompleted;
}
}
5.2 深度点评与像素擦除艺术
这里展示了计算机图形学中典型的基于 Painter’s Algorithm(画家算法)的图层覆盖哲理:
- 轨道的无限延伸:在
ListView中,列表项的大小是受限的。通过让上半部线条画到 Y = 0 Y=0 Y=0,下半部画到size.height,当相邻两个列表项上下拼合时,肉眼就会产生一条横跨整个屏幕的连续时间轴错觉。 - 防重叠擦除技巧:如果在画完线后直接画一个中空的彩色圆环,圆环中间就会穿透出刚刚画的线,极度丑陋。代码中巧妙地定义了一个
nodeOuterPaint白色实心圆。利用后画的白色实心图层强行覆盖遮挡住了背后的垂直线段(像素擦除),然后再在其正中心画出实际的彩色状态圆。这种以退为进的设计让整个时间节点呈现出极具医疗工业感的精细留白。
第六章:核心代码剖析(四)全量生命周期流转与 UI 响应
当用户在界面中点击某一项延期的疫苗并确认“登记接种”时,系统必须在毫秒级别内完成领域状态突变、列表视图的局部刷新,以及左侧雷达进度环动画的重构。
void _markAsInjected(VaccineRecord record) {
setState(() {
record.status = InjectionStatus.completed;
record.actualDate = _currentDate;
record.batchNumber = 'NEW-${Random().nextInt(99999)}'; // 模拟生成追溯批号
record.clinicName = '智慧免疫接种中心';
// 销毁旧的静态映射,重新计算完成率进度条的弹性动画
_progressAnim = Tween<double>(begin: _progressAnim.value, end: _calculateCompletionRate()).animate(
CurvedAnimation(parent: _progressAnimCtrl, curve: Curves.easeOut)
);
// 将指针拨回 0,从旧位置平滑推演到新位置
_progressAnimCtrl.forward(from: 0.0);
});
}
这段代码通过对 Tween 中 begin 指针的复用(_progressAnim.value),极其优雅地实现了进度条的连续接力。当您的总完成率从 60 % 60\% 60% 跃升至 65 % 65\% 65% 时,进度圆环绝不会死板地归零重绘,而是从当前指针顺滑地涨满那 5 % 5\% 5% 的缺口,给予用户最顶级的视觉享受与“任务打卡”的心理慰藉感。
第七章:结语与智慧医疗边界拓展
本文深入架构了一个基于 Flutter 的幼儿全生命周期疫苗追踪网络,该系统成功跨越了静态表单的鸿沟,在底层引入了基于时间推演的状态机,在 UI 侧展现了堪称工艺品级别的连绵时间轴,甚至深入到流行病学原理层面构建了动态衰变常数加持的免疫缺口风险评估引擎。
在国家全面推进“互联网+医疗健康”的宏大语境下,该系统构成了居民电子健康档案(EHR)极其核心的基石。试想在不远的未来:
- 物联网 (IoT) 疫苗冷链溯源:系统可直接通过蓝牙读取社区接种台的扫码枪数据,实时验证本剂次疫苗在 − 20 ∘ C -20^\circ \text{C} −20∘C 冷链运输史中是否存在温度断崖。
- 公卫预警中台直连:系统积累的海量延期缺口数据,可直接上传至疾控中心(CDC),作为构建下一年度局部麻疹或百日咳爆发风险预警地图的关键特征输入层。
从一行行生硬的代码走向一个个稚嫩生命的免疫屏障,这正是我们对医学技术数字孪生化的极致追求。利用精密的结构与温度感的设计去拯救那极易被疏忽的医疗防线,这,便是程序架构在生命科学中最浪漫的落脚点。
完整代码
import 'package:flutter/material.dart';
import 'dart:math';
void main() {
runApp(const VaccineTrackerApp());
}
class VaccineTrackerApp extends StatelessWidget {
const VaccineTrackerApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '幼儿免疫接种全生命周期追踪系统',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.teal,
scaffoldBackgroundColor: const Color(0xFFF4F7FA),
fontFamily: 'Roboto',
),
home: const VaccineDashboardScreen(),
);
}
}
/// 领域模型:疫苗类别 (国家免疫规划疫苗 vs 非免疫规划疫苗)
enum VaccineCategory { mandatory, optional }
/// 领域模型:接种状态
enum InjectionStatus { completed, pending, delayed }
/// 领域实体:疫苗接种记录
class VaccineRecord {
final String id;
final String vaccineName;
final String diseaseTarget;
final int targetAgeInMonths; // 目标接种年龄(以月为单位,0代表出生时)
final int doseNumber; // 第几剂
final VaccineCategory category;
InjectionStatus status;
DateTime? scheduledDate;
DateTime? actualDate;
String? batchNumber;
String? clinicName;
VaccineRecord({
required this.id,
required this.vaccineName,
required this.diseaseTarget,
required this.targetAgeInMonths,
required this.doseNumber,
required this.category,
this.status = InjectionStatus.pending,
this.scheduledDate,
this.actualDate,
this.batchNumber,
this.clinicName,
});
/// 领域行为:获取预期接种的格式化年龄描述
String get ageDescription {
if (targetAgeInMonths == 0) return '出生时';
if (targetAgeInMonths < 12) return '$targetAgeInMonths 个月';
if (targetAgeInMonths % 12 == 0) return '${targetAgeInMonths ~/ 12} 岁';
return '${targetAgeInMonths ~/ 12}岁 ${targetAgeInMonths % 12}个月';
}
}
/// 数学引擎:延期接种风险评估模型与免疫缺口计算
class VaccineRiskCalculator {
/// 计算基于时间的免疫缺口指数 (Immunization Gap Index)
/// 公式:IGI = sum( exp(lambda * delay_days) - 1 ) * weight
static double calculateRiskIndex(List<VaccineRecord> records, DateTime currentDate) {
double totalRisk = 0.0;
const double lambda = 0.015; // 延期衰变常数
for (var record in records) {
if (record.status == InjectionStatus.delayed && record.scheduledDate != null) {
int delayDays = currentDate.difference(record.scheduledDate!).inDays;
if (delayDays > 0) {
// 强制规划疫苗权重为 2.0,自费疫苗为 0.8
double weight = record.category == VaccineCategory.mandatory ? 2.0 : 0.8;
totalRisk += (exp(lambda * delayDays) - 1.0) * weight;
}
}
}
return totalRisk;
}
}
class VaccineDashboardScreen extends StatefulWidget {
const VaccineDashboardScreen({Key? key}) : super(key: key);
@override
_VaccineDashboardScreenState createState() => _VaccineDashboardScreenState();
}
class _VaccineDashboardScreenState extends State<VaccineDashboardScreen> with TickerProviderStateMixin {
late List<VaccineRecord> _vaccineTimeline;
final DateTime _birthDate = DateTime(2023, 5, 15);
final DateTime _currentDate = DateTime(2024, 2, 20); // 假设当前系统时间为出生后约9个月
late AnimationController _progressAnimCtrl;
late Animation<double> _progressAnim;
@override
void initState() {
super.initState();
_initializeTimeline();
_progressAnimCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1500));
_progressAnim = Tween<double>(begin: 0.0, end: _calculateCompletionRate()).animate(
CurvedAnimation(parent: _progressAnimCtrl, curve: Curves.easeInOutCubic)
);
_progressAnimCtrl.forward();
}
@override
void dispose() {
_progressAnimCtrl.dispose();
super.dispose();
}
void _initializeTimeline() {
// 依据国家免疫规划构建模拟时间轴
_vaccineTimeline = [
VaccineRecord(id: 'V001', vaccineName: '卡介苗', diseaseTarget: '结核病', targetAgeInMonths: 0, doseNumber: 1, category: VaccineCategory.mandatory, status: InjectionStatus.completed, scheduledDate: _birthDate, actualDate: _birthDate.add(const Duration(days: 1)), batchNumber: 'BCG-20230501-A', clinicName: '市妇幼保健院'),
VaccineRecord(id: 'V002', vaccineName: '乙肝疫苗', diseaseTarget: '乙型肝炎', targetAgeInMonths: 0, doseNumber: 1, category: VaccineCategory.mandatory, status: InjectionStatus.completed, scheduledDate: _birthDate, actualDate: _birthDate.add(const Duration(hours: 12)), batchNumber: 'HEP-2301X', clinicName: '市妇幼保健院'),
VaccineRecord(id: 'V003', vaccineName: '乙肝疫苗', diseaseTarget: '乙型肝炎', targetAgeInMonths: 1, doseNumber: 2, category: VaccineCategory.mandatory, status: InjectionStatus.completed, scheduledDate: DateTime(2023, 6, 15), actualDate: DateTime(2023, 6, 18), batchNumber: 'HEP-2302Y', clinicName: '社区卫生服务中心'),
VaccineRecord(id: 'V004', vaccineName: '脊灰灭活疫苗 (IPV)', diseaseTarget: '脊髓灰质炎', targetAgeInMonths: 2, doseNumber: 1, category: VaccineCategory.mandatory, status: InjectionStatus.completed, scheduledDate: DateTime(2023, 7, 15), actualDate: DateTime(2023, 7, 15), batchNumber: 'IPV-00921', clinicName: '社区卫生服务中心'),
VaccineRecord(id: 'V005', vaccineName: '百白破疫苗', diseaseTarget: '百日咳、白喉、破伤风', targetAgeInMonths: 3, doseNumber: 1, category: VaccineCategory.mandatory, status: InjectionStatus.completed, scheduledDate: DateTime(2023, 8, 15), actualDate: DateTime(2023, 8, 20), batchNumber: 'DPT-8832', clinicName: '社区卫生服务中心'),
VaccineRecord(id: 'V006', vaccineName: '五联疫苗', diseaseTarget: '百白破/脊灰/Hib', targetAgeInMonths: 4, doseNumber: 2, category: VaccineCategory.optional, status: InjectionStatus.completed, scheduledDate: DateTime(2023, 9, 15), actualDate: DateTime(2023, 9, 15), batchNumber: 'PEN-1102A', clinicName: '社区卫生服务中心'),
VaccineRecord(id: 'V007', vaccineName: '乙肝疫苗', diseaseTarget: '乙型肝炎', targetAgeInMonths: 6, doseNumber: 3, category: VaccineCategory.mandatory, status: InjectionStatus.delayed, scheduledDate: DateTime(2023, 11, 15)), // 严重延期
VaccineRecord(id: 'V008', vaccineName: 'A群流脑多糖疫苗', diseaseTarget: '流行性脑脊髓膜炎', targetAgeInMonths: 6, doseNumber: 1, category: VaccineCategory.mandatory, status: InjectionStatus.delayed, scheduledDate: DateTime(2023, 11, 15)), // 严重延期
VaccineRecord(id: 'V009', vaccineName: '麻腮风疫苗', diseaseTarget: '麻疹、流行性腮腺炎、风疹', targetAgeInMonths: 8, doseNumber: 1, category: VaccineCategory.mandatory, status: InjectionStatus.pending, scheduledDate: DateTime(2024, 1, 15)),
VaccineRecord(id: 'V010', vaccineName: '水痘减毒活疫苗', diseaseTarget: '水痘', targetAgeInMonths: 12, doseNumber: 1, category: VaccineCategory.optional, status: InjectionStatus.pending, scheduledDate: DateTime(2024, 5, 15)),
VaccineRecord(id: 'V011', vaccineName: '甲肝减毒活疫苗', diseaseTarget: '甲型肝炎', targetAgeInMonths: 18, doseNumber: 1, category: VaccineCategory.mandatory, status: InjectionStatus.pending, scheduledDate: DateTime(2024, 11, 15)),
];
}
double _calculateCompletionRate() {
if (_vaccineTimeline.isEmpty) return 0.0;
int completed = _vaccineTimeline.where((v) => v.status == InjectionStatus.completed).length;
return completed / _vaccineTimeline.length;
}
void _markAsInjected(VaccineRecord record) {
setState(() {
record.status = InjectionStatus.completed;
record.actualDate = _currentDate;
record.batchNumber = 'NEW-${Random().nextInt(99999)}';
record.clinicName = '智慧免疫接种中心';
// 重新计算进度条动画
_progressAnim = Tween<double>(begin: _progressAnim.value, end: _calculateCompletionRate()).animate(
CurvedAnimation(parent: _progressAnimCtrl, curve: Curves.easeOut)
);
_progressAnimCtrl.forward(from: 0.0);
});
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('成功登记【${record.vaccineName}】的接种记录!', style: const TextStyle(fontWeight: FontWeight.bold)),
backgroundColor: const Color(0xFF1ABC9C),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
));
}
void _showVaccineDetails(VaccineRecord record) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => _buildDetailBottomSheet(record),
);
}
Widget _buildDetailBottomSheet(VaccineRecord record) {
bool isCompleted = record.status == InjectionStatus.completed;
return Container(
padding: const EdgeInsets.all(24),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(32)),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(width: 40, height: 6, decoration: BoxDecoration(color: Colors.grey.shade300, borderRadius: BorderRadius.circular(3))),
),
const SizedBox(height: 24),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: record.category == VaccineCategory.mandatory ? const Color(0xFFE8F8F5) : const Color(0xFFFEF9E7),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: record.category == VaccineCategory.mandatory ? const Color(0xFF1ABC9C) : const Color(0xFFF1C40F))
),
child: Text(
record.category == VaccineCategory.mandatory ? '免疫规划 (免费)' : '非免疫规划 (自费)',
style: TextStyle(fontWeight: FontWeight.bold, color: record.category == VaccineCategory.mandatory ? const Color(0xFF1ABC9C) : const Color(0xFFF39C12), fontSize: 12),
),
),
const Spacer(),
Text(record.ageDescription, style: const TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF7F8C8D), fontSize: 16)),
],
),
const SizedBox(height: 16),
Text(record.vaccineName, style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900, color: Color(0xFF2C3E50))),
const SizedBox(height: 8),
Text('预防疾病:${record.diseaseTarget}', style: const TextStyle(fontSize: 16, color: Color(0xFF34495E))),
const SizedBox(height: 32),
_buildDetailRow(Icons.calendar_today, '应种日期', record.scheduledDate != null ? '${record.scheduledDate!.year}-${record.scheduledDate!.month.toString().padLeft(2, '0')}-${record.scheduledDate!.day.toString().padLeft(2, '0')}' : '未定'),
const SizedBox(height: 16),
if (isCompleted) ...[
_buildDetailRow(Icons.check_circle, '实种日期', '${record.actualDate!.year}-${record.actualDate!.month.toString().padLeft(2, '0')}-${record.actualDate!.day.toString().padLeft(2, '0')}', color: const Color(0xFF1ABC9C)),
const SizedBox(height: 16),
_buildDetailRow(Icons.numbers, '疫苗批号', record.batchNumber ?? '未知'),
const SizedBox(height: 16),
_buildDetailRow(Icons.local_hospital, '接种单位', record.clinicName ?? '未知'),
] else ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: const Color(0xFFFDF2E9), borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFE67E22).withOpacity(0.3))),
child: Row(
children: [
const Icon(Icons.warning_amber_rounded, color: Color(0xFFE67E22)),
const SizedBox(width: 12),
Expanded(child: Text(record.status == InjectionStatus.delayed ? '该疫苗已延期接种,这可能导致孩子面临较高的感染风险,请尽快安排补种!' : '尚未接种,请注意观察接种时间安排。', style: const TextStyle(color: Color(0xFFD35400), height: 1.5))),
],
),
)
],
const SizedBox(height: 40),
if (!isCompleted)
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: () {
Navigator.pop(context);
_markAsInjected(record);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF3498DB),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: const Text('登记本次接种 (Scan/Manual)', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white)),
),
)
],
),
),
);
}
Widget _buildDetailRow(IconData icon, String label, String value, {Color? color}) {
return Row(
children: [
Icon(icon, color: color ?? const Color(0xFF95A5A6), size: 20),
const SizedBox(width: 12),
Text(label, style: const TextStyle(color: Color(0xFF7F8C8D), fontSize: 14)),
const Spacer(),
Text(value, style: TextStyle(color: color ?? const Color(0xFF2C3E50), fontSize: 16, fontWeight: FontWeight.bold, fontFamily: 'monospace')),
],
);
}
Widget _buildMobileLayout() {
double riskIndex = VaccineRiskCalculator.calculateRiskIndex(_vaccineTimeline, _currentDate);
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
_buildProfileAndStatsCard(riskIndex),
],
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildTimelineTile(_vaccineTimeline[index], index == _vaccineTimeline.length - 1),
childCount: _vaccineTimeline.length,
),
),
const SliverPadding(padding: EdgeInsets.only(bottom: 40)),
],
);
}
Widget _buildDesktopLayout() {
double riskIndex = VaccineRiskCalculator.calculateRiskIndex(_vaccineTimeline, _currentDate);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 左侧控制台
Container(
width: 360,
margin: const EdgeInsets.all(24),
child: Column(
children: [
_buildProfileAndStatsCard(riskIndex),
const SizedBox(height: 24),
// 医学预警侧边栏
Expanded(
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(24), boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 15, offset: Offset(0, 5))]),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('免疫缺口算法监控', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w900, color: Color(0xFF2C3E50))),
const SizedBox(height: 16),
Text('基于当前时间 (${_currentDate.year}-${_currentDate.month.toString().padLeft(2,'0')}) 与计划时间的偏差积分模型:', style: const TextStyle(color: Color(0xFF7F8C8D), height: 1.5)),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: riskIndex > 10 ? const Color(0xFFFDEDEC) : const Color(0xFFEAFAF1), borderRadius: BorderRadius.circular(12)),
child: Row(
children: [
Icon(riskIndex > 10 ? Icons.warning_rounded : Icons.shield_rounded, color: riskIndex > 10 ? const Color(0xFFE74C3C) : const Color(0xFF27AE60), size: 32),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('当前风险积分 (IGI)', style: TextStyle(color: riskIndex > 10 ? const Color(0xFFC0392B) : const Color(0xFF1E8449), fontSize: 12, fontWeight: FontWeight.bold)),
Text(riskIndex.toStringAsFixed(2), style: TextStyle(color: riskIndex > 10 ? const Color(0xFFE74C3C) : const Color(0xFF27AE60), fontSize: 28, fontWeight: FontWeight.w900, fontFamily: 'monospace')),
],
),
)
],
),
)
],
),
),
)
],
),
),
// 右侧时间轴
Expanded(
child: Container(
margin: const EdgeInsets.only(top: 24, right: 24, bottom: 24),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(24), boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 15, offset: Offset(0, 5))]),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 32),
itemCount: _vaccineTimeline.length,
itemBuilder: (context, index) => _buildTimelineTile(_vaccineTimeline[index], index == _vaccineTimeline.length - 1),
),
),
),
)
],
);
}
Widget _buildProfileAndStatsCard(double riskIndex) {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 15, offset: Offset(0, 5))]
),
child: Column(
children: [
Row(
children: [
Container(
width: 64, height: 64,
decoration: const BoxDecoration(color: Color(0xFF3498DB), shape: BoxShape.circle),
child: const Center(child: Icon(Icons.child_care, color: Colors.white, size: 32)),
),
const SizedBox(width: 16),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('宝宝电子疫苗本', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w900, color: Color(0xFF2C3E50))),
SizedBox(height: 4),
Text('出生日期: 2023-05-15', style: TextStyle(color: Color(0xFF7F8C8D), fontSize: 14)),
],
),
)
],
),
const SizedBox(height: 32),
// 环形进度图表
AnimatedBuilder(
animation: _progressAnim,
builder: (context, child) {
return SizedBox(
width: 160, height: 160,
child: Stack(
fit: StackFit.expand,
children: [
CircularProgressIndicator(
value: _progressAnim.value,
strokeWidth: 12,
backgroundColor: const Color(0xFFF2F4F4),
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFF1ABC9C)),
),
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('${(_progressAnim.value * 100).toInt()}%', style: const TextStyle(fontSize: 32, fontWeight: FontWeight.w900, color: Color(0xFF2C3E50))),
const Text('全程接种率', style: TextStyle(fontSize: 12, color: Color(0xFF7F8C8D))),
],
),
)
],
),
);
}
),
],
),
);
}
/// 渲染自定义时间轴节点
Widget _buildTimelineTile(VaccineRecord record, bool isLast) {
Color statusColor;
IconData statusIcon;
switch (record.status) {
case InjectionStatus.completed:
statusColor = const Color(0xFF1ABC9C);
statusIcon = Icons.check_circle;
break;
case InjectionStatus.pending:
statusColor = const Color(0xFFBDC3C7);
statusIcon = Icons.radio_button_unchecked;
break;
case InjectionStatus.delayed:
statusColor = const Color(0xFFE74C3C);
statusIcon = Icons.error;
break;
}
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 左侧:时间轴轨道绘制区
SizedBox(
width: 80,
child: CustomPaint(
painter: TimelineNodePainter(
color: statusColor,
isLast: isLast,
isCompleted: record.status == InjectionStatus.completed
),
),
),
// 右侧:卡片内容
Expanded(
child: GestureDetector(
onTap: () => _showVaccineDetails(record),
child: Container(
margin: const EdgeInsets.only(right: 24, bottom: 24),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: record.status == InjectionStatus.delayed ? const Color(0xFFFDEDEC) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: record.status == InjectionStatus.delayed ? const Color(0xFFF5B7B1) : Colors.grey.shade200, width: 2),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.02), blurRadius: 10, offset: const Offset(0, 4))]
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: const Color(0xFFEBEDEF), borderRadius: BorderRadius.circular(6)),
child: Text(record.ageDescription, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Color(0xFF34495E))),
),
const SizedBox(width: 8),
if (record.status == InjectionStatus.delayed)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: const Color(0xFFE74C3C), borderRadius: BorderRadius.circular(6)),
child: const Text('已延误', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.white)),
),
],
),
const SizedBox(height: 12),
Text(record.vaccineName, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: record.status == InjectionStatus.delayed ? const Color(0xFFC0392B) : const Color(0xFF2C3E50))),
const SizedBox(height: 4),
Text('防:${record.diseaseTarget}', style: const TextStyle(fontSize: 14, color: Color(0xFF7F8C8D))),
],
),
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Icon(statusIcon, color: statusColor, size: 28),
const SizedBox(height: 8),
Text(
record.category == VaccineCategory.mandatory ? '免费' : '自费',
style: TextStyle(fontWeight: FontWeight.bold, color: record.category == VaccineCategory.mandatory ? const Color(0xFF1ABC9C) : const Color(0xFFF39C12), fontSize: 12)
)
],
)
],
),
),
),
)
],
),
);
}
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.of(context).size.width > 800;
return Scaffold(
appBar: AppBar(
title: const Text('免疫接种档案 (Immunization Registry)', style: TextStyle(color: Color(0xFF2C3E50), fontWeight: FontWeight.w900)),
backgroundColor: Colors.white,
elevation: 1,
centerTitle: true,
),
body: SafeArea(
child: isDesktop ? _buildDesktopLayout() : _buildMobileLayout(),
),
);
}
}
/// 核心:时间轴底座拓扑学绘制引擎
class TimelineNodePainter extends CustomPainter {
final Color color;
final bool isLast;
final bool isCompleted;
TimelineNodePainter({
required this.color,
required this.isLast,
required this.isCompleted,
});
@override
void paint(Canvas canvas, Size size) {
final double centerX = size.width / 2;
final double nodeY = 40.0; // 节点圆圈的 Y 轴位置
// 1. 绘制纵向连接轨道 (轨道分为上半部和下半部)
final trackPaint = Paint()
..color = isCompleted ? color.withOpacity(0.5) : const Color(0xFFEAEDED)
..strokeWidth = 4.0
..strokeCap = StrokeCap.round;
// 如果不是最后一个节点,画下半部分的连线,延伸到底部
if (!isLast) {
canvas.drawLine(Offset(centerX, nodeY), Offset(centerX, size.height), trackPaint);
}
// 画上半部分的连线 (从顶部到节点)
canvas.drawLine(Offset(centerX, 0), Offset(centerX, nodeY), trackPaint);
// 2. 绘制状态节点圆环
final nodeOuterPaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill;
final nodeInnerPaint = Paint()
..color = color
..style = isCompleted ? PaintingStyle.fill : PaintingStyle.stroke
..strokeWidth = 4.0;
// 绘制外圈擦除背景
canvas.drawCircle(Offset(centerX, nodeY), 12.0, nodeOuterPaint);
// 绘制内圈状态标识
canvas.drawCircle(Offset(centerX, nodeY), 8.0, nodeInnerPaint);
// 如果已完成,内部再画一个小圆环产生立体光泽
if (isCompleted) {
final highlightPaint = Paint()
..color = Colors.white.withOpacity(0.8)
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(centerX - 2, nodeY - 2), 2.0, highlightPaint);
}
}
@override
bool shouldRepaint(covariant TimelineNodePainter oldDelegate) {
return oldDelegate.color != color ||
oldDelegate.isLast != isLast ||
oldDelegate.isCompleted != isCompleted;
}
}
更多推荐


所有评论(0)