Browse Source

1、12导心电

guanxinyi 1 year ago
parent
commit
64701cc426

+ 2 - 0
.gitignore

@@ -44,3 +44,5 @@ app.*.map.json
 /android/app/release
 windows/flutter
 windows/flutter
+packages/flutter_ui/pubspec.lock
+packages/flutter_commom/pubspec.lock

BIN
android/app/libs/device_sdk.aar


+ 7 - 2
lib/managers/device.dart

@@ -2,15 +2,15 @@ import 'dart:convert';
 
 import 'package:get/get.dart';
 import 'package:vitalapp/global.dart';
-import 'package:vnote_device_plugin/consts/types.dart';
 import 'package:vitalapp/architecture/storage/text_storage.dart';
 import 'package:vitalapp/managers/interfaces/models/device.dart';
+import 'package:vnote_device_plugin/consts/types.dart';
 
 import 'interfaces/device.dart';
 import 'interfaces/system_setting.dart';
 
 class DeviceManager extends IDeviceManager {
-  static final _typeConvertMap = <String, String>{
+  final _typeConvertMap = <String, String>{
     "Temp": DeviceTypes.TEMP,
     "Weight": DeviceTypes.WEIGHT,
     "SpO2": DeviceTypes.SPO2,
@@ -19,6 +19,7 @@ class DeviceManager extends IDeviceManager {
     "Urine": DeviceTypes.URINE,
     "ICReader": DeviceTypes.IC_READER,
     "ECG": DeviceTypes.HEART,
+    "TwelveHeart": DeviceTypes.TWELVEHEART,
   };
   final _storage = TextStorage(fileName: "devices.cfg");
   final _deviceStorage = TextStorage(fileName: "devices.setting.cfg");
@@ -37,6 +38,10 @@ class DeviceManager extends IDeviceManager {
       settingTypes =
           jsonDecode(settingTypesString ?? '').cast<String>().toList();
     }
+    print(_typeConvertMap);
+    for (var element in settingTypes) {
+      print(_typeConvertMap[element]);
+    }
 
     final types = settingTypes
         .map((e) => _typeConvertMap[e])

+ 5 - 2
lib/pages/medical/view.dart

@@ -41,7 +41,9 @@ class MedicalPage extends GetView<MedicalController> {
     return Row(
       children: [
         _buildMedicalMenus(),
-        _buildDeviceImage(),
+        Obx(
+          () => _buildDeviceImage(controller.state.currentTab),
+        ),
         _buildMedicalInput(),
       ],
     );
@@ -130,7 +132,8 @@ class MedicalPage extends GetView<MedicalController> {
     }
   }
 
-  Widget _buildDeviceImage() {
+  Widget _buildDeviceImage(String? currentTab) {
+    if (currentTab == DeviceTypes.TWELVEHEART) return const SizedBox();
     return Expanded(
       flex: 6,
       child: Container(

+ 158 - 111
lib/pages/medical/widgets/twelve_ecg.dart

@@ -12,8 +12,9 @@ import 'package:vitalapp/pages/medical/controller.dart';
 import 'package:vitalapp/pages/medical/models/worker.dart';
 import 'package:vitalapp/pages/medical/widgets/device_status.dart';
 import 'package:vitalapp/pages/medical/widgets/device_status_position.dart';
-import 'package:vitalapp/pages/medical/widgets/ecg_view/index.dart';
 import 'package:vitalapp/pages/medical/widgets/side_bar.dart';
+import 'package:vitalapp/pages/medical/widgets/twelve_ecg_view/controller.dart';
+import 'package:vitalapp/pages/medical/widgets/twelve_ecg_view/view.dart';
 import 'package:vnote_device_plugin/consts/types.dart';
 import 'package:http/http.dart' as http;
 import 'package:vnote_device_plugin/devices/twelve_heart.dart';
@@ -58,117 +59,146 @@ class _HeartRateState extends State<TwelveHeartRate> {
       children: [
         ExamCard(
           titleText: const SizedBox(),
-          bottomPadding: 10,
-          content: Column(
-            mainAxisAlignment: MainAxisAlignment.start,
-            children: [
-              Row(
-                mainAxisAlignment: MainAxisAlignment.end,
-                children: [
-                  const SideBar(
-                    title: '心率',
-                    value: '',
-                    unit: '',
-                  ),
-                  const Expanded(child: SizedBox()),
-                  Text(
-                    _heart.isEmpty ? '--' : _heart,
-                    style: const TextStyle(
-                      fontSize: 60,
-                      color: Colors.black,
-                    ),
-                  ),
-                  const Text(
-                    "  bpm",
-                    style: TextStyle(fontSize: 25),
-                  ),
-                  const SizedBox(
-                    width: 15,
-                  )
-                ],
-              ),
-              if (_deviceError.isNotEmpty)
+          // bottomPadding: 10,
+          content: SizedBox(
+            height: 620,
+            // margin: const EdgeInsets.only(bottom: 10),
+            child: Column(
+              children: [
                 Row(
-                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
-                  crossAxisAlignment: CrossAxisAlignment.start,
+                  mainAxisAlignment: MainAxisAlignment.end,
                   children: [
-                    Container(
-                      padding: const EdgeInsets.symmetric(
-                        horizontal: 30,
-                      ),
-                      child: const Text(
-                        '心率测量出错',
-                        style: TextStyle(
-                          fontSize: 25,
-                        ),
-                      ),
+                    const SideBar(
+                      title: '心率',
+                      value: '',
+                      unit: '',
                     ),
-                    Container(
-                      padding: const EdgeInsets.symmetric(
-                        horizontal: 50,
-                      ),
-                      child: Text(
-                        _deviceError,
-                        style: const TextStyle(
-                          fontSize: 24,
-                        ),
+                    const Expanded(child: SizedBox()),
+                    Text(
+                      _heart.isEmpty ? '--' : _heart,
+                      style: const TextStyle(
+                        fontSize: 60,
+                        color: Colors.black,
                       ),
                     ),
+                    const Text(
+                      "  bpm",
+                      style: TextStyle(fontSize: 25),
+                    ),
+                    const SizedBox(
+                      width: 15,
+                    )
                   ],
                 ),
-              if (_assess.isNotEmpty)
-                Row(
-                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
-                  crossAxisAlignment: CrossAxisAlignment.start,
-                  children: [
-                    Container(
-                      padding: const EdgeInsets.symmetric(
-                        horizontal: 30,
-                      ),
-                      child: const Text(
-                        '心率评估',
-                        style: TextStyle(
-                          fontSize: 25,
-                        ),
+                // if (_deviceError.isNotEmpty)
+                //   Row(
+                //     mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                //     crossAxisAlignment: CrossAxisAlignment.start,
+                //     children: [
+                //       Container(
+                //         padding: const EdgeInsets.symmetric(
+                //           horizontal: 30,
+                //         ),
+                //         child: const Text(
+                //           '心率测量出错',
+                //           style: TextStyle(
+                //             fontSize: 25,
+                //           ),
+                //         ),
+                //       ),
+                //       Container(
+                //         padding: const EdgeInsets.symmetric(
+                //           horizontal: 50,
+                //         ),
+                //         child: Text(
+                //           _deviceError,
+                //           style: const TextStyle(
+                //             fontSize: 24,
+                //           ),
+                //         ),
+                //       ),
+                //     ],
+                //   ),
+                // if (_assess.isNotEmpty)
+                //   Row(
+                //     mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                //     crossAxisAlignment: CrossAxisAlignment.start,
+                //     children: [
+                //       Container(
+                //         padding: const EdgeInsets.symmetric(
+                //           horizontal: 30,
+                //         ),
+                //         child: const Text(
+                //           '心率评估',
+                //           style: TextStyle(
+                //             fontSize: 25,
+                //           ),
+                //         ),
+                //       ),
+                //       Container(
+                //         padding: const EdgeInsets.only(
+                //           right: 15,
+                //         ),
+                //         child: Text(
+                //           dataConvertManager.heartRateConversion(
+                //               int.parse(TwelveheartRate.toString())),
+                //           style: const TextStyle(
+                //             fontSize: 24,
+                //             fontWeight: FontWeight.bold,
+                //           ),
+                //         ),
+                //       ),
+                //     ],
+                //   ),
+                // const SizedBox(
+                //   height: 10,
+                // ),
+                // Expanded(
+                //   child: SizedBox(
+                //     height: 900,
+                //     child: LayoutBuilder(builder: (context, constraints) {
+                //       if (initEcgData == null) {
+                //         return Container();
+                //       } else {
+                //         return TwelveEcgView(
+                //           width: constraints.maxWidth,
+                //           height: constraints.maxHeight,
+                //           initData: initEcgData!,
+                //           currentIndex: 0,
+                //         );
+                //       }
+                //     }),
+                //   ),
+                // ),
+                Expanded(
+                  child: ListView(
+                    shrinkWrap: true,
+                    children: [
+                      SizedBox(
+                        height: 450,
+                        child: LayoutBuilder(builder: (context, constraints) {
+                          if (initEcgData == null) {
+                            return Container();
+                          } else {
+                            return TwelveEcgView(
+                              width: constraints.maxWidth,
+                              height: constraints.maxHeight,
+                              initData: initEcgData!,
+                              currentIndex: 0,
+                            );
+                          }
+                        }),
                       ),
-                    ),
-                    Container(
-                      padding: const EdgeInsets.only(
-                        right: 15,
-                      ),
-                      child: Text(
-                        dataConvertManager.heartRateConversion(
-                            int.parse(TwelveheartRate.toString())),
-                        style: const TextStyle(
-                          fontSize: 24,
-                          fontWeight: FontWeight.bold,
-                        ),
-                      ),
-                    ),
-                  ],
+                    ],
+                  ),
                 ),
-              const SizedBox(
-                height: 10,
-              ),
-              SizedBox(
-                height: 240,
-                child: LayoutBuilder(builder: (context, constraints) {
-                  if (initEcgData == null) {
-                    return Container();
-                  } else {
-                    return EcgView(
-                      width: constraints.maxWidth,
-                      height: constraints.maxHeight,
-                      initData: initEcgData!,
-                    );
-                  }
-                }),
-              ),
-              Container(
-                // !! 这一层 Container 不能删 ,否则会引发 EcgView 的 controller 被异常删除
-                child: _buildResetButton(),
-              ),
-            ],
+
+                // Container(
+                //   // !! 这一层 Container 不能删 ,否则会引发 TwelveEcgView 的 controller 被异常删除
+                //   child: _buildResetButton(),
+                // ),
+              ],
+            ),
           ),
         ),
         if (!isConnectFail)
@@ -280,7 +310,8 @@ class _HeartRateState extends State<TwelveHeartRate> {
   /// 重置数据以及心电图
   void resetEcgView() {
     try {
-      EcgViewController ecgViewController = Get.find<EcgViewController>();
+      TwelveEcgViewController ecgViewController =
+          Get.find<TwelveEcgViewController>();
       ecgPoint = [];
       ecgViewController.reset();
     } catch (e) {
@@ -334,7 +365,7 @@ class _HeartRateState extends State<TwelveHeartRate> {
     worker.disconnectedEvent.addListener(_onDisconnected);
     // worker.hrValueUpdateEvent.addListener(_onHrValueUpdate);
     // worker.ecgValueUpdateEvent.addListener(_onEcgValueUpdate);
-    worker.resultReceivedEvent.addListener(_onRCesultReceived);
+    worker.resultReceivedEvent.addListener(_onHrValueUpdate);
     worker.errorEvent.addListener(_onError);
   }
 
@@ -344,7 +375,7 @@ class _HeartRateState extends State<TwelveHeartRate> {
     worker.disconnectedEvent.removeListener(_onDisconnected);
     // worker.hrValueUpdateEvent.removeListener(_onHrValueUpdate);
     // worker.ecgValueUpdateEvent.removeListener(_onEcgValueUpdate);
-    worker.resultReceivedEvent.removeListener(_onRCesultReceived);
+    worker.resultReceivedEvent.removeListener(_onHrValueUpdate);
     worker.errorEvent.removeListener(_onError);
   }
 
@@ -407,7 +438,8 @@ class _HeartRateState extends State<TwelveHeartRate> {
   // 心电图数据更新
   void _onEcgValueUpdate(_, List<int> e) {
     try {
-      EcgViewController ecgViewController = Get.find<EcgViewController>();
+      TwelveEcgViewController ecgViewController =
+          Get.find<TwelveEcgViewController>();
       ecgPoint.addAll(e);
       if (ecgPoint.length > 125 * 3) {
         // 3s 后开始塞数据
@@ -419,8 +451,8 @@ class _HeartRateState extends State<TwelveHeartRate> {
   }
 
   /// 更新心率
-  void _onHrValueUpdate(_, int e) {
-    logger.i('心率更新:$e');
+  void _onHrValueUpdate(_, TwelveHeartExamData e) {
+    logger.i('心率更新:${e.heartRate}');
 
     if (_deviceError.isNotEmpty || _assess.isNotEmpty) {
       // 如果上次因错误而停止了,这里需要先重置
@@ -428,8 +460,22 @@ class _HeartRateState extends State<TwelveHeartRate> {
       _assess = '';
       resetEcgView();
     }
+
     setState(() {
-      _heart = e.toString();
+      _heart = e.heartRate.toString();
+      try {
+        TwelveEcgViewController ecgViewController =
+            Get.find<TwelveEcgViewController>();
+        ecgViewController.addData(e.ecgPoints);
+        print('🌽🌽🌽🌽🌽🌽');
+        print(e.ecgPoints.length);
+        print('🌽🌽🌽🌽🌽🌽');
+      } catch (e) {
+        print('🥥🥥🥥🥥');
+        print(e);
+        print('🥥🥥🥥🥥');
+      }
+
       showResetButton = false;
     });
   }
@@ -496,7 +542,8 @@ class _HeartRateState extends State<TwelveHeartRate> {
 
   /// 设置最终数据
   Future<void> setEcgData() async {
-    EcgViewController ecgViewController = Get.find<EcgViewController>();
+    TwelveEcgViewController ecgViewController =
+        Get.find<TwelveEcgViewController>();
     medicalController.diagnosisDataValue['Heart']?['ECG'] =
         await ecgViewController.getFullDataImageBase64();
     medicalController.diagnosisDataValue['Heart']?['ECG_POINT'] =

+ 277 - 0
lib/pages/medical/widgets/twelve_ecg_view/controller.dart

@@ -0,0 +1,277 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:ui';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+import 'dart:ui' as ui;
+import 'index.dart';
+
+class TwelveEcgViewController extends GetxController {
+  TwelveEcgViewController({
+    required this.initPoints,
+    required this.currentIndex,
+  });
+
+  final List<int> initPoints;
+  final int currentIndex;
+
+  /// 每秒的数据量
+  static int get dataPerSecond => 200;
+
+  /// 更新帧率
+  static int updateFrameRate = 25;
+
+  /// 更新周期
+  static int updatePeriod = 1000 ~/ updateFrameRate;
+
+  /// 画布时间跨度(秒)
+  int get timeSpan => 5;
+
+  /// 横坐标数据量
+  int get xDataCount => dataPerSecond * timeSpan;
+
+  /// 所有心电数据
+  List<int> allPoints = [];
+
+  /// 需绘制的新数据
+  List<List<int>> newPointsToDraw = [
+    [],
+    [],
+    [],
+    [],
+    [],
+    [],
+    [],
+    [],
+    [],
+    [],
+    [],
+    []
+  ];
+
+  /// 需绘制的历史数据
+  List<List<int>> oldPointsToDraw = [
+    [],
+    [],
+    [],
+    [],
+    [],
+    [],
+    [],
+    [],
+    [],
+    [],
+    [],
+    []
+  ];
+  List<double> yMaxList = [
+    1000,
+    1000,
+    1000,
+    5000,
+    1000,
+    8000,
+    8000,
+    8000,
+    8000,
+    8000,
+    8000,
+    8000,
+  ];
+
+  /// 启动时时间戳
+  int startTime = DateTime.now().millisecondsSinceEpoch;
+
+  /// 当前数据位(根据时间戳计算)
+  int currentDataIndex = 0;
+
+  /// 数据刷新定时器
+  Timer timer = Timer(Duration.zero, () {});
+
+  /// 发生错误时暂停了
+  bool isPaused = false;
+
+  /// 读取到数据的回调
+  void addData(List<int> data) {
+    if (allPoints.isEmpty) {
+      startTime = DateTime.now().millisecondsSinceEpoch;
+      _startTimer();
+    }
+    allPoints.addAll(data);
+    if (isPaused) {
+      isPaused = false;
+      _startTimer();
+    }
+  }
+
+  /// 打开全屏心电图弹窗
+  void openFullScreenDialog() {
+    Get.dialog(
+      const FullScreenEcgDataDialog(),
+    );
+  }
+
+  // 重置
+  void reset() {
+    timer.cancel();
+    isPaused = false;
+    allPoints.clear();
+    newPointsToDraw.clear();
+    oldPointsToDraw.clear();
+    startTime = DateTime.now().millisecondsSinceEpoch;
+    currentDataIndex = 0;
+    update(['twelve_ecg_view']);
+  }
+
+  /// 获取完整心电图的base64(带base64头)
+  Future<String> getFullDataImageBase64() async {
+    final painter = EcgPainterForAll(
+      allPoints: allPoints,
+      yMax: 600,
+    );
+    final bgPainter = GridBackgroundPainterForAll();
+    const size = Size(5000, 650);
+    // 使用离屏Canvas绘制
+    final Uint8List? bytes =
+        await _capturePainterToImage(painter, bgPainter, size);
+    if (bytes == null) {
+      return "";
+    } else {
+      return _convertToBase64Url(bytes);
+    }
+  }
+
+  /// 开启定时器,每隔一定时间添加一次数据,并且更新UI
+  void _startTimer() {
+    timer = Timer.periodic(
+      Duration(milliseconds: updatePeriod),
+      (timer) {
+        // print("timer: ${timer.tick}");
+
+        _updateData();
+      },
+    );
+  }
+
+  List<List<int>> separateLines(List<int> array) {
+    // 将数据拆分成12条线的数据
+    List<List<int>> lines = List.generate(12, (_) => []);
+    for (int i = 0; i < array.length; i++) {
+      int lineIndex = i % 12; // 计算当前数据所属的线的索引
+      lines[lineIndex].add(array[i]); // 将数据添加到对应的线中
+    }
+    return lines;
+  }
+
+  /// 每帧更新数据
+  void _updateData() {
+    // 计算当前数据位
+    currentDataIndex = (DateTime.now().millisecondsSinceEpoch - startTime) ~/
+        (1000 ~/ dataPerSecond);
+
+    /// 需显示的数据量
+    int needDataCount = currentDataIndex % xDataCount;
+
+    /// 当前周期数
+    int currentPeriod = currentDataIndex ~/ xDataCount;
+    // 计算新数据
+    try {
+      for (int i = 0; i < 12; i++) {
+        newPointsToDraw[i] = separateLines(allPoints)[i].sublist(
+          currentDataIndex - needDataCount,
+          currentDataIndex,
+        );
+        if (currentPeriod > 0) {
+          oldPointsToDraw[i] = separateLines(allPoints)[i].sublist(
+            (currentPeriod - 1) * xDataCount,
+            currentPeriod * xDataCount,
+          );
+        }
+      }
+
+      // if (currentDataIndex >= dataPerSecond * 30) {
+      //   timer.cancel();
+      //   isPaused = true;
+      // }
+      // print(
+      //     "update newPointsToDraw: ${currentDataIndex} ${needDataCount} ${newPointsToDraw.length} --currentPeriod ${currentPeriod}");
+    } catch (e) {
+      timer.cancel();
+      isPaused = true;
+    }
+    update(['twelve_ecg_view']);
+  }
+
+  /// 将字节数组转换为base64
+  String _convertToBase64Url(Uint8List imageData) {
+    String base64Image = base64Encode(imageData);
+    String base64Url = base64Image;
+    return base64Url;
+  }
+
+  /// 将CustomPainter绘制的内容转换为图片
+  Future<Uint8List?> _capturePainterToImage(
+      CustomPainter painter, CustomPainter bgPainter, Size size) async {
+    final bounds = Offset.zero & size;
+    final picture = PictureRecorder();
+    final pictureCanvas = Canvas(picture);
+
+    // 给Canvas设置绘制范围
+    pictureCanvas.clipRect(bounds);
+
+    /// 绘制背景(纯白)
+    pictureCanvas.drawColor(Colors.white, BlendMode.color);
+
+    // 在Canvas上进行绘制
+    bgPainter.paint(pictureCanvas, size);
+    painter.paint(pictureCanvas, size);
+
+    /// 绘制一圈边框
+    final borderPaint = Paint()
+      ..color = Colors.black
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = 1.0;
+    pictureCanvas.drawRect(bounds, borderPaint);
+
+    // 结束绘制
+    final recordedPicture = picture.endRecording();
+    final image =
+        await recordedPicture.toImage(size.width.toInt(), size.height.toInt());
+
+    // 转换为字节数组
+    final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
+    final bytes = byteData?.buffer.asUint8List();
+
+    return bytes;
+  }
+
+  // @override
+  // void onInit() {
+  //   super.onInit();
+  // }
+
+  @override
+  void onReady() {
+    super.onReady();
+    newPointsToDraw[currentDataIndex] = initPoints;
+    update(['twelve_ecg_view']);
+    // allPoints = initPoints;
+    // if (initPoints.length >= 3750) {
+    //   newPointsToDraw = initPoints.sublist(3375, 3750);
+    //   update(['twelve_ecg_view']);
+    //   allPoints = initPoints;
+    // } else {
+    //   newPointsToDraw = initPoints;
+    //   update(['twelve_ecg_view']);
+    //   allPoints = initPoints;
+    // }
+  }
+
+  @override
+  void onClose() {
+    super.onClose();
+    timer.cancel();
+  }
+}

+ 5 - 0
lib/pages/medical/widgets/twelve_ecg_view/index.dart

@@ -0,0 +1,5 @@
+library ecg_painter;
+
+export './controller.dart';
+export './view.dart';
+export './widgets/index.dart';

+ 66 - 0
lib/pages/medical/widgets/twelve_ecg_view/view.dart

@@ -0,0 +1,66 @@
+import 'package:vitalapp/pages/medical/widgets/twelve_ecg_view/widgets/debug_dialog.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+import 'index.dart';
+
+class TwelveEcgView extends GetView<TwelveEcgViewController> {
+  const TwelveEcgView({
+    Key? key,
+    required this.width,
+    required this.height,
+    required this.initData,
+    required this.currentIndex,
+  }) : super(key: key);
+
+  final double width;
+  final double height;
+  final List<int> initData;
+  final int currentIndex;
+
+  // 主视图
+  Widget _buildView() {
+    return Center(
+      child: ClipRect(
+        child: RepaintBoundary(
+          child: CustomPaint(
+            foregroundPainter: EcgPainter(
+              newPoints: controller.newPointsToDraw,
+              oldPoints: controller.oldPointsToDraw,
+              xDataCount: controller.xDataCount,
+              yMaxList: controller.yMaxList,
+            ),
+            painter: GridBackgroundPainter(5 * 25),
+            size: Size(width, height),
+          ),
+        ),
+      ),
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return GetBuilder<TwelveEcgViewController>(
+      init: TwelveEcgViewController(
+        initPoints: initData,
+        currentIndex: currentIndex,
+      ),
+      id: "twelve_ecg_view",
+      builder: (_) {
+        return GestureDetector(
+          onTap: () => controller.openFullScreenDialog(),
+          onDoubleTap: () async {
+            final imageBase64 = await controller.getFullDataImageBase64();
+            if (imageBase64.isNotEmpty) {
+              Get.dialog(
+                DebugDialog(imageBase64: imageBase64),
+              );
+            }
+          },
+          child: Center(
+            child: _buildView(),
+          ),
+        );
+      },
+    );
+  }
+}

+ 91 - 0
lib/pages/medical/widgets/twelve_ecg_view/widgets/debug_dialog.dart

@@ -0,0 +1,91 @@
+import 'dart:convert';
+import 'dart:typed_data';
+
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+import '../index.dart';
+
+class DebugDialog extends GetView<TwelveEcgViewController> {
+  const DebugDialog({Key? key, required this.imageBase64}) : super(key: key);
+
+  final String imageBase64;
+
+  // 主视图
+  Widget _buildView() {
+    const designWidth = 1280.0; // 设计尺寸宽度:1280
+    final width = Get.width;
+    final scale = width / designWidth; // 计算缩放比例
+    final ScrollController scrollController = ScrollController();
+    return Container(
+      width: Get.width * 0.9 / scale,
+      height: 240 * 3,
+      decoration: BoxDecoration(
+        color: Colors.white,
+        borderRadius: BorderRadius.circular(10),
+      ),
+      clipBehavior: Clip.antiAlias,
+      child: Column(
+        children: [
+          _buildHead(),
+          Expanded(
+            child: Scrollbar(
+              thumbVisibility: true,
+              thickness: 10,
+              radius: const Radius.circular(10),
+              controller: scrollController,
+              child: SingleChildScrollView(
+                controller: scrollController,
+                padding: const EdgeInsets.only(left: 10, right: 10, bottom: 20),
+                physics: const BouncingScrollPhysics(),
+                scrollDirection: Axis.horizontal,
+                child: Center(
+                  child: displayImage(imageBase64),
+                ),
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  Widget displayImage(String base64Url) {
+    // 去掉"data:image/png;base64,"前缀
+    String base64Image = base64Url.split(',').last;
+
+    // 解码Base64字符串为字节数组
+    Uint8List imageData = base64Decode(base64Image);
+
+    return Image.memory(
+      imageData,
+      fit: BoxFit.cover, // 根据需要设置适当的fit属性
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Dialog(
+      child: _buildView(),
+    );
+  }
+
+  /// 构建弹窗顶部,右侧显示关闭按钮
+  Widget _buildHead() {
+    return SizedBox(
+      height: 50,
+      child: Row(
+        mainAxisAlignment: MainAxisAlignment.end,
+        children: [
+          IconButton(
+            onPressed: () => Get.back(),
+            icon: const Icon(
+              Icons.close,
+              size: 30,
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 166 - 0
lib/pages/medical/widgets/twelve_ecg_view/widgets/ecg_painter.dart

@@ -0,0 +1,166 @@
+import 'package:flutter/material.dart';
+
+class EcgPainter extends CustomPainter {
+  final List<List<int>> newPoints;
+  final List<List<int>> oldPoints;
+  final int xDataCount;
+  final List<double> yMaxList;
+
+  EcgPainter({
+    required this.newPoints,
+    required this.oldPoints,
+    required this.xDataCount,
+    required this.yMaxList,
+  });
+
+  @override
+  void paint(Canvas canvas, Size size) {
+    for (int i = 0; i < 12; i++) {
+      final row = i ~/ 2;
+      final column = i % 2;
+      double y = (size.height / 6 * i);
+      bool isRight = ((i + 1) % 2 == 0);
+
+      Size currentSize = Size(size.width / 2, y + (size.height / 12));
+      if (newPoints.isNotEmpty && oldPoints.isEmpty) {
+        drawPoints(
+          canvas,
+          currentSize,
+          newPoints[i],
+          yMaxList[i],
+          isRight: isRight,
+        );
+      } else if (newPoints[i].isEmpty && oldPoints[i].isNotEmpty) {
+        drawPoints(
+          canvas,
+          currentSize,
+          oldPoints[i],
+          yMaxList[i],
+          isRight: isRight,
+        );
+      } else if (newPoints[i].isNotEmpty && oldPoints[i].isNotEmpty) {
+        drawPointsWithFade(
+          canvas,
+          currentSize,
+          oldPoints[i],
+          yMaxList[i],
+          offset: newPoints[i].length,
+          isRight: isRight,
+        );
+        drawPoints(
+          canvas,
+          currentSize,
+          newPoints[i],
+          yMaxList[i],
+          isRight: isRight,
+        );
+      } else {
+        drawPoints(
+          canvas,
+          currentSize,
+          newPoints[i],
+          yMaxList[i],
+          isRight: isRight,
+        );
+      }
+    }
+  }
+
+  void drawPoints(
+    Canvas canvas,
+    Size size,
+    List<int> points,
+    double yMax, {
+    bool isRight = false,
+    int offset = 0,
+  }) {
+    final paint = Paint()
+      ..color = Colors.green
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = 2.0;
+
+    final double xGridSize = size.width / xDataCount;
+    final double yScale = size.height / 2 / yMax;
+    final double yCenter = size.height / 2;
+    final path = Path();
+
+    for (int i = offset; i < points.length; i++) {
+      if (i % 5 == 0) {
+        final offsetX = i * xGridSize;
+        final offsetY = -points[i] * yScale + yCenter;
+        if (isRight) {
+          if (i == offset) {
+            path.moveTo(offsetX + size.width, offsetY);
+          } else {
+            path.lineTo(offsetX + size.width, offsetY);
+          }
+        } else {
+          if (i == offset) {
+            path.moveTo(offsetX, offsetY);
+          } else {
+            path.lineTo(offsetX, offsetY);
+          }
+        }
+      }
+    }
+    canvas.drawPath(path, paint);
+  }
+
+  void drawPointsWithFade(
+    Canvas canvas,
+    Size size,
+    List<int> points,
+    double yMax, {
+    bool isRight = false,
+    int offset = 0,
+  }) {
+    final paint = Paint()
+      ..color = Colors.green
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = 2.0;
+
+    final double xGridSize = size.width / xDataCount;
+    final double yScale = size.height / 2 / yMax;
+    final double yCenter = size.height / 2;
+    final path = Path();
+
+    for (int i = offset; i < points.length; i++) {
+      if (i % 5 == 0) {
+        final offsetX = i * xGridSize;
+        final offsetY = -points[i] * yScale + yCenter;
+        if (isRight) {
+          if (i == offset) {
+            path.moveTo(offsetX + size.width, offsetY);
+          } else {
+            path.lineTo(offsetX + size.width, offsetY);
+          }
+        } else {
+          if (i == offset) {
+            path.moveTo(offsetX, offsetY);
+          } else {
+            path.lineTo(offsetX, offsetY);
+          }
+        }
+      }
+    }
+
+    double shaderStartX = offset * xGridSize;
+    if (isRight) {
+      shaderStartX = offset * xGridSize + size.width;
+    }
+    final double shaderWidth = 30 * xGridSize;
+    Rect rect = Rect.fromLTWH(shaderStartX, 0, shaderWidth, size.height);
+    paint.shader = const LinearGradient(
+      begin: Alignment.centerLeft,
+      end: Alignment.centerRight,
+      colors: [Color.fromARGB(0, 76, 175, 79), Colors.green],
+    ).createShader(rect);
+
+    canvas.drawPath(path, paint);
+  }
+
+  @override
+  bool shouldRepaint(CustomPainter oldDelegate) {
+    return true;
+  }
+}

+ 82 - 0
lib/pages/medical/widgets/twelve_ecg_view/widgets/ecg_painter_for_all.dart

@@ -0,0 +1,82 @@
+import 'package:flutter/material.dart';
+
+class EcgPainterForAll extends CustomPainter {
+  /// 分为新数据和历史数据
+  final List<int> allPoints;
+
+  /// 横坐标数据量(横轴一共多少个点) 30s * 125 = 3750
+  final int xDataCount = 3750;
+
+  final int yGridNumsPerMv; // 多少格是一毫伏特
+
+  /// 纵坐标最大值
+  final double yMax;
+
+  EcgPainterForAll({
+    required this.allPoints,
+    required this.yMax,
+    this.yGridNumsPerMv = 5,
+  });
+
+  @override
+  void paint(Canvas canvas, Size size) {
+    if (allPoints.isNotEmpty) {
+      drawPoints(canvas, size, allPoints);
+    }
+    if (allPoints.length < xDataCount) {
+      /// 绘制一根x轴直线,补全剩余数据
+      final paint = Paint()
+        ..color = Colors.green
+        ..style = PaintingStyle.stroke
+        ..strokeWidth = 3.0;
+      final double xGridSize = size.width / xDataCount;
+      final startX = allPoints.length * xGridSize;
+      final startY = size.height / 2;
+      final endX = size.width;
+      final endY = size.height / 2;
+      canvas.drawLine(Offset(startX, startY), Offset(endX, endY), paint);
+    }
+  }
+
+  void drawPoints(
+    Canvas canvas,
+    Size size,
+    List<int> points, {
+    int offset = 0,
+  }) {
+    final paint = Paint()
+      ..color = Colors.green
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = 3.0;
+
+    final double xGridSize = size.width / xDataCount;
+    final double yScale = size.height / 2 / yMax;
+
+    final double yCenter = size.height / 2;
+
+    final path = Path();
+    for (int i = offset; i < points.length; i++) {
+      final offsetX = i * xGridSize;
+      final offsetY = -points[i] * yScale + yCenter;
+      if (i == offset) {
+        path.moveTo(offsetX, offsetY);
+      } else {
+        // 利用 cubicTo 画出平滑的曲线
+        // final double preOffsetX = (i - 1) * xGridSize;
+        // final double preOffsetY = -points[i - 1] * yScale + yCenter;
+        // final double c1x = preOffsetX + (offsetX - preOffsetX) / 2;
+        // final double c1y = preOffsetY;
+        // final double c2x = preOffsetX + (offsetX - preOffsetX) / 2;
+        // final double c2y = offsetY;
+        // path.cubicTo(c1x, c1y, c2x, c2y, offsetX, offsetY);
+        path.lineTo(offsetX, offsetY);
+      }
+    }
+    canvas.drawPath(path, paint);
+  }
+
+  @override
+  bool shouldRepaint(CustomPainter oldDelegate) {
+    return true;
+  }
+}

+ 87 - 0
lib/pages/medical/widgets/twelve_ecg_view/widgets/full_screen_ecg_data_dialog.dart

@@ -0,0 +1,87 @@
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+import '../index.dart';
+
+class FullScreenEcgDataDialog extends GetView<TwelveEcgViewController> {
+  const FullScreenEcgDataDialog({Key? key}) : super(key: key);
+
+  // 主视图
+  Widget _buildView() {
+    const designWidth = 1280.0; // 设计尺寸宽度:1280
+    final width = Get.width;
+    final scale = width / designWidth; // 计算缩放比例
+    final ScrollController scrollController = ScrollController();
+    return Container(
+      width: Get.width * 0.9 / scale,
+      height: 240 * 3,
+      decoration: BoxDecoration(
+        color: Colors.white,
+        borderRadius: BorderRadius.circular(10),
+      ),
+      clipBehavior: Clip.antiAlias,
+      child: Column(
+        children: [
+          _buildHead(),
+          Expanded(
+            child: Scrollbar(
+              thumbVisibility: true,
+              thickness: 10,
+              radius: const Radius.circular(10),
+              controller: scrollController,
+              child: SingleChildScrollView(
+                controller: scrollController,
+                padding: const EdgeInsets.only(left: 10, right: 10, bottom: 20),
+                physics: const BouncingScrollPhysics(),
+                scrollDirection: Axis.horizontal,
+                child: Container(
+                  decoration: BoxDecoration(
+                    border: Border.all(
+                      color: Colors.grey.withOpacity(0.5),
+                      width: 1,
+                    ),
+                  ),
+                  clipBehavior: Clip.hardEdge,
+                  child: CustomPaint(
+                    foregroundPainter: EcgPainterForAll(
+                      allPoints: controller.allPoints,
+                      yMax: 600,
+                    ),
+                    painter: GridBackgroundPainterForAll(),
+                    size: const Size(5000, 200),
+                  ),
+                ),
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Dialog(
+      child: _buildView(),
+    );
+  }
+
+  /// 构建弹窗顶部,右侧显示关闭按钮
+  Widget _buildHead() {
+    return SizedBox(
+      height: 50,
+      child: Row(
+        mainAxisAlignment: MainAxisAlignment.end,
+        children: [
+          IconButton(
+            onPressed: () => Get.back(),
+            icon: const Icon(
+              Icons.close,
+              size: 30,
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 113 - 0
lib/pages/medical/widgets/twelve_ecg_view/widgets/grid_background_painter.dart

@@ -0,0 +1,113 @@
+import 'package:flutter/material.dart';
+
+/// 绘制背景方格
+/// 入参:xGridNums X轴方格数
+/// 入参:yGridNums y轴方格数
+class GridBackgroundPainter extends CustomPainter {
+  final int xGridNums;
+  List<String> mLeadNames = [
+    "I",
+    "II",
+    "III",
+    "aVR",
+    "aVL",
+    "aVF",
+    "V1",
+    "V2",
+    "V3",
+    "V4",
+    "V5",
+    "V6"
+  ];
+
+  GridBackgroundPainter(this.xGridNums);
+
+  @override
+  void paint(Canvas canvas, Size size) {
+    final gridPaint = Paint()
+      ..color = Colors.grey.withOpacity(0.5)
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = 1.0;
+
+    final double xGridSize = size.width / xGridNums;
+    final double yGridSize = xGridSize;
+    final int yGridNums = (size.height / yGridSize).ceil();
+
+    final double centerY = size.height / 2;
+
+    for (int i = 0; i < xGridNums; i++) {
+      final offsetX = i * xGridSize;
+      canvas.drawLine(
+        Offset(offsetX, 0),
+        Offset(offsetX, size.height),
+        gridPaint,
+      );
+    }
+
+    for (int i = 0; i < yGridNums / 2; i++) {
+      final offsetY = (i * yGridSize);
+      canvas.drawLine(
+        Offset(0, centerY + offsetY),
+        Offset(size.width, centerY + offsetY),
+        gridPaint,
+      );
+      canvas.drawLine(
+        Offset(0, centerY - offsetY),
+        Offset(size.width, centerY - offsetY),
+        gridPaint,
+      );
+    }
+
+    canvas.drawLine(
+      Offset(size.width - 0.5, 0),
+      Offset(size.width - 0.5, size.height),
+      gridPaint,
+    );
+
+    for (int i = 0; i < 12; i++) {
+      /// 绘制两根中心线
+      final centerLinePaint = Paint()
+        ..color = const Color.fromARGB(255, 1, 4, 148)
+        ..style = PaintingStyle.stroke
+        ..strokeWidth = 1.0;
+
+      final row = i ~/ 2;
+      final column = i % 2;
+
+      canvas.drawLine(
+        Offset(column * size.width / 2, (size.height * row / 6)),
+        Offset((column + 1) * size.width / 2, (size.height * row / 6)),
+        centerLinePaint,
+      );
+
+      canvas.drawLine(
+        Offset(size.width / 2, 0),
+        Offset(size.width / 2, size.height),
+        centerLinePaint,
+      );
+
+      final textPainter = TextPainter(
+        text: TextSpan(
+          text: mLeadNames[i],
+          style: TextStyle(
+            height: 1,
+            color: const Color.fromARGB(255, 99, 99, 99),
+            fontSize: 25,
+            background: Paint()..color = Colors.white.withOpacity(0.5),
+          ),
+        ),
+        textDirection: TextDirection.ltr,
+      );
+      textPainter.layout();
+      textPainter.paint(
+        canvas,
+        Offset((column + 0.5) * size.width / 2, (size.height * row / 6)),
+      );
+    }
+  }
+
+  @override
+  bool shouldRepaint(covariant CustomPainter oldDelegate) {
+    return false;
+  }
+}

+ 157 - 0
lib/pages/medical/widgets/twelve_ecg_view/widgets/grid_background_painter_for_all.dart

@@ -0,0 +1,157 @@
+import 'package:flutter/material.dart';
+
+/// 绘制30s完整心电的背景方格
+class GridBackgroundPainterForAll extends CustomPainter {
+  final int xGridNums = 750; // 1秒25格,共30秒
+
+  final int yGridNumsPerMv; // 多少格是一毫伏特 [暂未用到该参数]
+
+  GridBackgroundPainterForAll({this.yGridNumsPerMv = 5});
+
+  @override
+  void paint(Canvas canvas, Size size) {
+    final gridPaint = Paint()
+      ..color = Colors.grey.withOpacity(0.5)
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = 1.0;
+
+    final double xGridSize = size.width / xGridNums;
+    final double yGridSize = xGridSize;
+    final double centerY = size.height / 2;
+
+    /// 计算 y 轴方格数
+    final int yGridNums = (size.height / yGridSize).ceil();
+
+    for (int i = 0; i < xGridNums; i++) {
+      final offsetX = i * xGridSize;
+      canvas.drawLine(
+        Offset(offsetX, 0),
+        Offset(offsetX, size.height),
+        gridPaint,
+      );
+    }
+
+    for (int i = 0; i < yGridNums / 2; i++) {
+      final offsetY = (i * yGridSize);
+      canvas.drawLine(
+        Offset(0, centerY + offsetY),
+        Offset(size.width, centerY + offsetY),
+        gridPaint,
+      );
+      canvas.drawLine(
+        Offset(0, centerY - offsetY),
+        Offset(size.width, centerY - offsetY),
+        gridPaint,
+      );
+    }
+
+    ///最后一根线往左移半个像素
+    canvas.drawLine(
+      Offset(size.width - 0.5, 0),
+      Offset(size.width - 0.5, size.height),
+      gridPaint,
+    );
+
+    /// 绘制一根中心线
+    final centerLinePaint = Paint()
+      ..color = Colors.grey.withOpacity(0.5)
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = 2.0;
+    canvas.drawLine(
+      Offset(0, size.height / 2),
+      Offset(size.width, size.height / 2),
+      centerLinePaint,
+    );
+
+    /// 每隔5秒绘制一根竖粗线
+    final boldLinePaint = Paint()
+      ..color = Colors.grey.withOpacity(0.5)
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = 2.0;
+    for (int i = 0; i < xGridNums; i++) {
+      if (i % 25 == 0) {
+        final offsetX = i * xGridSize;
+        canvas.drawLine(
+          Offset(offsetX, 0),
+          Offset(offsetX, size.height),
+          boldLinePaint,
+        );
+      }
+      if (i % 125 == 0) {
+        final offsetX = i * xGridSize;
+        canvas.drawLine(
+          Offset(offsetX, 0),
+          Offset(offsetX, size.height),
+          boldLinePaint,
+        );
+
+        /// 绘制文字标识
+        final textPainter = TextPainter(
+          text: TextSpan(
+            text: '${i ~/ 25}秒',
+            style: TextStyle(
+              height: 1,
+              color: const Color.fromARGB(255, 99, 99, 99),
+              fontSize: 25,
+              background: Paint()..color = Colors.white.withOpacity(0.5),
+            ),
+          ),
+          textDirection: TextDirection.ltr,
+        );
+        textPainter.layout();
+        textPainter.paint(
+          canvas,
+          Offset(offsetX + 5, size.height - 30),
+        );
+      }
+    }
+
+    /// 绘制电势差标识
+    const tagStartGridX = 5;
+    const tagStartGridY = -30;
+    final tagPointA =
+        Offset(xGridSize * tagStartGridX, centerY + yGridSize * tagStartGridY);
+    final tagPointB = Offset(
+        xGridSize * (tagStartGridX + 1), centerY + yGridSize * tagStartGridY);
+    final tagPointC = Offset(xGridSize * (tagStartGridX + 1),
+        centerY + yGridSize * (tagStartGridY - 5));
+    final tagPointD = Offset(xGridSize * (tagStartGridX + 3),
+        centerY + yGridSize * (tagStartGridY - 5));
+    final tagPointE = Offset(
+        xGridSize * (tagStartGridX + 3), centerY + yGridSize * tagStartGridY);
+    final tagPointF = Offset(
+        xGridSize * (tagStartGridX + 4), centerY + yGridSize * tagStartGridY);
+
+    final tagPaint = Paint()
+      ..color = const Color.fromARGB(255, 99, 99, 99)
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = 3.0;
+    canvas.drawLine(tagPointA, tagPointB, tagPaint);
+    canvas.drawLine(tagPointB, tagPointC, tagPaint);
+    canvas.drawLine(tagPointC, tagPointD, tagPaint);
+    canvas.drawLine(tagPointD, tagPointE, tagPaint);
+    canvas.drawLine(tagPointE, tagPointF, tagPaint);
+    final textPainter = TextPainter(
+      text: TextSpan(
+        text: '1mV',
+        style: TextStyle(
+          height: 1,
+          color: const Color.fromARGB(255, 99, 99, 99),
+          fontSize: 20,
+          background: Paint()..color = Colors.white.withOpacity(0.5),
+        ),
+      ),
+      textDirection: TextDirection.ltr,
+    );
+    textPainter.layout();
+    textPainter.paint(
+      canvas,
+      Offset(tagPointA.dx - 8, tagPointA.dy + 10),
+    );
+  }
+
+  @override
+  bool shouldRepaint(covariant CustomPainter oldDelegate) {
+    return false;
+  }
+}

+ 5 - 0
lib/pages/medical/widgets/twelve_ecg_view/widgets/index.dart

@@ -0,0 +1,5 @@
+export 'ecg_painter.dart';
+export 'ecg_painter_for_all.dart';
+export 'grid_background_painter.dart';
+export 'grid_background_painter_for_all.dart';
+export 'full_screen_ecg_data_dialog.dart';

+ 1 - 0
lib/pages/settings/devices/widgets/consts.dart

@@ -11,6 +11,7 @@ abstract class DevicesSettingConsts {
     DeviceTypes.URINE: "尿液分析仪",
     DeviceTypes.IC_READER: "人证阅读器",
     DeviceTypes.HEART: "心电",
+    DeviceTypes.TWELVEHEART: "12导心电",
   };
 
   /// 根据类型获取名称